docsif 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- docsif-0.1.0.dist-info/METADATA +393 -0
- docsif-0.1.0.dist-info/RECORD +86 -0
- docsif-0.1.0.dist-info/WHEEL +4 -0
- docsif-0.1.0.dist-info/entry_points.txt +3 -0
- docsif-0.1.0.dist-info/licenses/LICENSE +21 -0
- sif/__init__.py +24 -0
- sif/_version.py +3 -0
- sif/cli/__init__.py +11 -0
- sif/cli/commands/__init__.py +18 -0
- sif/cli/commands/bench.py +148 -0
- sif/cli/commands/collection.py +360 -0
- sif/cli/commands/context.py +154 -0
- sif/cli/commands/get.py +200 -0
- sif/cli/commands/index.py +294 -0
- sif/cli/commands/ls.py +67 -0
- sif/cli/commands/mcp.py +93 -0
- sif/cli/commands/pull.py +111 -0
- sif/cli/commands/search.py +574 -0
- sif/cli/config.py +206 -0
- sif/cli/formatters.py +269 -0
- sif/cli/main.py +205 -0
- sif/config/__init__.py +30 -0
- sif/config/constants.py +52 -0
- sif/config/settings.py +217 -0
- sif/core/__init__.py +21 -0
- sif/core/collection.py +161 -0
- sif/core/context.py +167 -0
- sif/core/document.py +125 -0
- sif/core/models.py +298 -0
- sif/database/__init__.py +23 -0
- sif/database/connection.py +139 -0
- sif/database/database.py +136 -0
- sif/database/migrations.py +187 -0
- sif/database/repositories.py +500 -0
- sif/database/repository.py +171 -0
- sif/database/schema.py +362 -0
- sif/embedding/__init__.py +22 -0
- sif/embedding/cache.py +196 -0
- sif/embedding/embedder.py +328 -0
- sif/embedding/factory.py +92 -0
- sif/embedding/manager.py +200 -0
- sif/embedding/model.py +126 -0
- sif/indexing/__init__.py +21 -0
- sif/indexing/chunker.py +226 -0
- sif/indexing/indexer.py +226 -0
- sif/indexing/parser.py +192 -0
- sif/indexing/scanner.py +148 -0
- sif/indexing/watcher.py +156 -0
- sif/mcp/README.md +258 -0
- sif/mcp/__init__.py +9 -0
- sif/mcp/claude_desktop_config.json +16 -0
- sif/mcp/cli.py +92 -0
- sif/mcp/protocol.py +336 -0
- sif/mcp/server.py +433 -0
- sif/mcp/server_http.py +54 -0
- sif/mcp/test_mcp.py +108 -0
- sif/mcp/tools.py +497 -0
- sif/mcp/transport_http.py +424 -0
- sif/mcp/transport_stdio.py +292 -0
- sif/mcp_server/__init__.py +26 -0
- sif/mcp_server/handlers.py +68 -0
- sif/mcp_server/server.py +58 -0
- sif/mcp_server/tools.py +216 -0
- sif/mcp_server/transport.py +221 -0
- sif/models/__init__.py +70 -0
- sif/models/collection.py +51 -0
- sif/models/context.py +45 -0
- sif/models/document.py +76 -0
- sif/models/download.py +208 -0
- sif/models/embedding.py +71 -0
- sif/models/search.py +92 -0
- sif/search/__init__.py +22 -0
- sif/search/benchmark.py +110 -0
- sif/search/bm25.py +216 -0
- sif/search/expansion.py +157 -0
- sif/search/hybrid.py +323 -0
- sif/search/rerank.py +390 -0
- sif/search/rrf.py +186 -0
- sif/search/snippets.py +130 -0
- sif/search/strategy.py +97 -0
- sif/search/vector.py +177 -0
- sif/utils/__init__.py +26 -0
- sif/utils/logging.py +143 -0
- sif/utils/paths.py +82 -0
- sif/utils/progress.py +83 -0
- sif/utils/text.py +128 -0
sif/search/strategy.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Search strategy interface using the Strategy pattern."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any, Protocol
|
|
6
|
+
|
|
7
|
+
from sif.models.search import SearchOptions, SearchResult
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class SearchContext:
|
|
12
|
+
"""Context passed to search strategies.
|
|
13
|
+
|
|
14
|
+
Contains all necessary information for performing a search,
|
|
15
|
+
including pre-computed embeddings and context strings.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
query: str
|
|
19
|
+
query_embedding: list[float] | None = None
|
|
20
|
+
expanded_query: str | None = None
|
|
21
|
+
collection_ids: list[str] | None = None
|
|
22
|
+
context_string: str = ""
|
|
23
|
+
filters: dict[str, Any] | None = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SearchStrategy(ABC):
|
|
27
|
+
"""Abstract base class for search strategies.
|
|
28
|
+
|
|
29
|
+
Implements the Strategy pattern to allow different search
|
|
30
|
+
algorithms to be used interchangeably.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
@abstractmethod
|
|
34
|
+
def search(
|
|
35
|
+
self,
|
|
36
|
+
context: SearchContext,
|
|
37
|
+
options: SearchOptions,
|
|
38
|
+
) -> list[SearchResult]:
|
|
39
|
+
"""Execute search and return results.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
context: Search context with query and embeddings
|
|
43
|
+
options: Search options and parameters
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
List of search results ordered by relevance
|
|
47
|
+
"""
|
|
48
|
+
...
|
|
49
|
+
|
|
50
|
+
@abstractmethod
|
|
51
|
+
def search_batch(
|
|
52
|
+
self,
|
|
53
|
+
contexts: list[SearchContext],
|
|
54
|
+
options: SearchOptions,
|
|
55
|
+
) -> list[list[SearchResult]]:
|
|
56
|
+
"""Execute batch search.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
contexts: List of search contexts
|
|
60
|
+
options: Search options and parameters
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
List of search results for each context
|
|
64
|
+
"""
|
|
65
|
+
...
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Searchable(Protocol):
|
|
69
|
+
"""Protocol for searchable repositories."""
|
|
70
|
+
|
|
71
|
+
def search_fts(
|
|
72
|
+
self,
|
|
73
|
+
query: str,
|
|
74
|
+
collection_ids: list[str] | None,
|
|
75
|
+
limit: int,
|
|
76
|
+
offset: int,
|
|
77
|
+
) -> list[tuple[str, float]]:
|
|
78
|
+
"""Search using full-text search.
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
List of (document_id, score) tuples
|
|
82
|
+
"""
|
|
83
|
+
...
|
|
84
|
+
|
|
85
|
+
def search_vector(
|
|
86
|
+
self,
|
|
87
|
+
embedding: list[float],
|
|
88
|
+
collection_ids: list[str] | None,
|
|
89
|
+
limit: int,
|
|
90
|
+
offset: int,
|
|
91
|
+
) -> list[tuple[str, float]]:
|
|
92
|
+
"""Search using vector similarity.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
List of (document_id, score) tuples
|
|
96
|
+
"""
|
|
97
|
+
...
|
sif/search/vector.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Vector similarity search implementation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sqlite3
|
|
8
|
+
|
|
9
|
+
from sif.core.models import SearchOptions, SearchResult
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class VectorSearcher:
|
|
13
|
+
"""Vector similarity search using sqlite-vec or fallback."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, db: sqlite3.Connection, embedding_dim: int = 768) -> None:
|
|
16
|
+
self.db = db
|
|
17
|
+
self.embedding_dim = embedding_dim
|
|
18
|
+
self._vec_available = self._check_vec_extension()
|
|
19
|
+
if not self._vec_available:
|
|
20
|
+
raise RuntimeError(
|
|
21
|
+
"sqlite-vec extension is not available. Install sqlite-vec to use vector search."
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
def _check_vec_extension(self) -> bool:
|
|
25
|
+
"""Check if sqlite-vec extension is available."""
|
|
26
|
+
try:
|
|
27
|
+
self.db.execute("SELECT vec_version()")
|
|
28
|
+
return True
|
|
29
|
+
except sqlite3.OperationalError:
|
|
30
|
+
return False
|
|
31
|
+
|
|
32
|
+
def search(
|
|
33
|
+
self,
|
|
34
|
+
query_embedding: list[float],
|
|
35
|
+
options: SearchOptions | None = None,
|
|
36
|
+
) -> list[SearchResult]:
|
|
37
|
+
"""Search documents by vector similarity."""
|
|
38
|
+
if options is None:
|
|
39
|
+
options = SearchOptions()
|
|
40
|
+
return self._search_with_vec(query_embedding, options)
|
|
41
|
+
|
|
42
|
+
def _search_with_vec(
|
|
43
|
+
self,
|
|
44
|
+
query_embedding: list[float],
|
|
45
|
+
options: SearchOptions,
|
|
46
|
+
) -> list[SearchResult]:
|
|
47
|
+
"""Search using sqlite-vec."""
|
|
48
|
+
# Convert embedding to vec format
|
|
49
|
+
embedding_str = self._embedding_to_vec(query_embedding)
|
|
50
|
+
|
|
51
|
+
# Build collection filter
|
|
52
|
+
collection_filter = ""
|
|
53
|
+
params = [embedding_str]
|
|
54
|
+
|
|
55
|
+
if options.collection_ids:
|
|
56
|
+
placeholders = ", ".join(["?"] * len(options.collection_ids))
|
|
57
|
+
collection_filter = f"AND d.collection_id IN ({placeholders})"
|
|
58
|
+
params.extend(options.collection_ids)
|
|
59
|
+
|
|
60
|
+
sql = f"""
|
|
61
|
+
SELECT
|
|
62
|
+
d.id as document_id,
|
|
63
|
+
d.title,
|
|
64
|
+
d.path,
|
|
65
|
+
c.name as collection_name,
|
|
66
|
+
distance as score
|
|
67
|
+
FROM document_embeddings de
|
|
68
|
+
JOIN documents d ON de.document_id = d.id
|
|
69
|
+
JOIN collections c ON d.collection_id = c.id
|
|
70
|
+
WHERE embedding MATCH ? AND k = {options.limit} {collection_filter}
|
|
71
|
+
ORDER BY distance
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
cursor = self.db.execute(sql, params)
|
|
75
|
+
results = []
|
|
76
|
+
|
|
77
|
+
for rank, row in enumerate(cursor.fetchall(), 1):
|
|
78
|
+
# Convert distance to score (lower distance = higher score)
|
|
79
|
+
# Cosine distance is 0-2, convert to 0-1 score
|
|
80
|
+
score = 1.0 - (row["score"] / 2.0)
|
|
81
|
+
|
|
82
|
+
if score < options.min_score:
|
|
83
|
+
continue
|
|
84
|
+
|
|
85
|
+
result = SearchResult(
|
|
86
|
+
document_id=row["document_id"],
|
|
87
|
+
title=row["title"] or "",
|
|
88
|
+
path=row["path"],
|
|
89
|
+
collection_name=row["collection_name"],
|
|
90
|
+
score=score,
|
|
91
|
+
rank=rank,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
if options.include_content:
|
|
95
|
+
result.content = self._get_document_content(row["document_id"])
|
|
96
|
+
|
|
97
|
+
results.append(result)
|
|
98
|
+
|
|
99
|
+
return self._attach_contexts(results)
|
|
100
|
+
|
|
101
|
+
def _attach_contexts(self, results: list[SearchResult]) -> list[SearchResult]:
|
|
102
|
+
"""Attach path context descriptions to search results via batch query."""
|
|
103
|
+
if not results:
|
|
104
|
+
return results
|
|
105
|
+
paths = list({r.path for r in results})
|
|
106
|
+
placeholders = ", ".join(["?"] * len(paths))
|
|
107
|
+
sql = f"""
|
|
108
|
+
SELECT target_id, content FROM contexts
|
|
109
|
+
WHERE context_type = 'path' AND target_id IN ({placeholders})
|
|
110
|
+
"""
|
|
111
|
+
cursor = self.db.execute(sql, paths)
|
|
112
|
+
# Normalize keys for cross-platform matching (macOS /private/tmp, etc.)
|
|
113
|
+
context_map = {
|
|
114
|
+
os.path.realpath(row["target_id"]): row["content"] for row in cursor.fetchall()
|
|
115
|
+
}
|
|
116
|
+
for result in results:
|
|
117
|
+
result.context_description = context_map.get(os.path.realpath(result.path))
|
|
118
|
+
return results
|
|
119
|
+
|
|
120
|
+
def _embedding_to_vec(self, embedding: list[float]) -> str:
|
|
121
|
+
"""Convert embedding to sqlite-vec format."""
|
|
122
|
+
return json.dumps(embedding)
|
|
123
|
+
|
|
124
|
+
def _get_document_content(self, document_id: str) -> str | None:
|
|
125
|
+
"""Get document content."""
|
|
126
|
+
cursor = self.db.execute("SELECT content FROM documents WHERE id = ?", (document_id,))
|
|
127
|
+
row = cursor.fetchone()
|
|
128
|
+
return row[0] if row else None
|
|
129
|
+
|
|
130
|
+
def add_embedding(
|
|
131
|
+
self,
|
|
132
|
+
embedding_id: str,
|
|
133
|
+
document_id: str,
|
|
134
|
+
chunk_id: str | None,
|
|
135
|
+
embedding: list[float],
|
|
136
|
+
) -> None:
|
|
137
|
+
"""Add an embedding to the index."""
|
|
138
|
+
self._add_embedding_vec(embedding_id, document_id, chunk_id, embedding)
|
|
139
|
+
|
|
140
|
+
def _add_embedding_vec(
|
|
141
|
+
self,
|
|
142
|
+
embedding_id: str,
|
|
143
|
+
document_id: str,
|
|
144
|
+
chunk_id: str | None,
|
|
145
|
+
embedding: list[float],
|
|
146
|
+
) -> None:
|
|
147
|
+
"""Add embedding using sqlite-vec."""
|
|
148
|
+
embedding_str = self._embedding_to_vec(embedding)
|
|
149
|
+
|
|
150
|
+
self.db.execute(
|
|
151
|
+
"""
|
|
152
|
+
INSERT OR REPLACE INTO document_embeddings
|
|
153
|
+
(embedding_id, document_id, chunk_id, embedding)
|
|
154
|
+
VALUES (?, ?, ?, vec_f32(?))
|
|
155
|
+
""",
|
|
156
|
+
(embedding_id, document_id, chunk_id, embedding_str),
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
def add_embeddings_batch(
|
|
160
|
+
self,
|
|
161
|
+
items: list[tuple[str, str, str | None, list[float]]],
|
|
162
|
+
) -> None:
|
|
163
|
+
"""Add multiple embeddings in a single batch operation.
|
|
164
|
+
|
|
165
|
+
Each item is a tuple of (embedding_id, document_id, chunk_id, embedding_vector).
|
|
166
|
+
"""
|
|
167
|
+
if not items:
|
|
168
|
+
return
|
|
169
|
+
rows = [(eid, doc_id, chunk_id, json.dumps(vec)) for eid, doc_id, chunk_id, vec in items]
|
|
170
|
+
self.db.executemany(
|
|
171
|
+
"""
|
|
172
|
+
INSERT OR REPLACE INTO document_embeddings
|
|
173
|
+
(embedding_id, document_id, chunk_id, embedding)
|
|
174
|
+
VALUES (?, ?, ?, vec_f32(?))
|
|
175
|
+
""",
|
|
176
|
+
rows,
|
|
177
|
+
)
|
sif/utils/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Utility functions and helpers for SIF.
|
|
2
|
+
|
|
3
|
+
This package provides various utility functions:
|
|
4
|
+
- Logging configuration
|
|
5
|
+
- File path utilities
|
|
6
|
+
- Text processing helpers
|
|
7
|
+
- Progress indicators
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from sif.utils.logging import get_logger, setup_logging
|
|
11
|
+
from sif.utils.paths import get_cache_dir, get_config_dir, get_data_dir
|
|
12
|
+
from sif.utils.progress import ProgressTracker
|
|
13
|
+
from sif.utils.text import count_tokens, normalize_text, truncate_text
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"ProgressTracker",
|
|
18
|
+
"count_tokens",
|
|
19
|
+
"get_cache_dir",
|
|
20
|
+
"get_config_dir",
|
|
21
|
+
"get_data_dir",
|
|
22
|
+
"get_logger",
|
|
23
|
+
"normalize_text",
|
|
24
|
+
"setup_logging",
|
|
25
|
+
"truncate_text",
|
|
26
|
+
]
|
sif/utils/logging.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Logging utilities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
import warnings
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# Default logging format
|
|
13
|
+
DEFAULT_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
|
14
|
+
SIMPLE_FORMAT = "%(levelname)s: %(message)s"
|
|
15
|
+
|
|
16
|
+
# Noisy third-party loggers to suppress in quiet mode
|
|
17
|
+
_NOISY_LOGGERS = [
|
|
18
|
+
"modelscope",
|
|
19
|
+
"transformers",
|
|
20
|
+
"sentence_transformers",
|
|
21
|
+
"torch",
|
|
22
|
+
"urllib3",
|
|
23
|
+
"huggingface_hub",
|
|
24
|
+
"auto_gptq",
|
|
25
|
+
"bitsandbytes",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
# Global quiet mode flag
|
|
29
|
+
_quiet_mode = False
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def is_quiet() -> bool:
|
|
33
|
+
"""Check if quiet mode is enabled."""
|
|
34
|
+
return _quiet_mode
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def set_quiet(quiet: bool = True) -> None:
|
|
38
|
+
"""Set quiet mode globally.
|
|
39
|
+
|
|
40
|
+
Use this when quiet mode is triggered after setup_logging has already run.
|
|
41
|
+
"""
|
|
42
|
+
global _quiet_mode
|
|
43
|
+
_quiet_mode = quiet
|
|
44
|
+
if quiet:
|
|
45
|
+
_apply_quiet_mode()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _apply_quiet_mode() -> None:
|
|
49
|
+
"""Apply quiet mode side effects (logging, env vars, warnings)."""
|
|
50
|
+
logging.disable(logging.INFO)
|
|
51
|
+
for name in _NOISY_LOGGERS:
|
|
52
|
+
lib_logger = logging.getLogger(name)
|
|
53
|
+
lib_logger.setLevel(logging.WARNING)
|
|
54
|
+
for h in lib_logger.handlers[:]:
|
|
55
|
+
lib_logger.removeHandler(h)
|
|
56
|
+
lib_logger.propagate = False
|
|
57
|
+
|
|
58
|
+
os.environ.setdefault("TQDM_DISABLE", "1")
|
|
59
|
+
os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error")
|
|
60
|
+
os.environ.setdefault("HF_HUB_DISABLE_PROGRESS_BARS", "1")
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
from transformers import logging as transformers_logging # noqa: PLC0415
|
|
64
|
+
|
|
65
|
+
transformers_logging.set_verbosity_error()
|
|
66
|
+
except ImportError:
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
warnings.filterwarnings("ignore")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@contextlib.contextmanager
|
|
73
|
+
def suppress_output():
|
|
74
|
+
"""Suppress both stdout and stderr output within the context block."""
|
|
75
|
+
with open(os.devnull, "w") as devnull:
|
|
76
|
+
with contextlib.redirect_stdout(devnull), contextlib.redirect_stderr(devnull):
|
|
77
|
+
yield
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@contextlib.contextmanager
|
|
81
|
+
def suppress_stdout():
|
|
82
|
+
"""Suppress stdout output within the context block."""
|
|
83
|
+
with open(os.devnull, "w") as devnull, contextlib.redirect_stdout(devnull):
|
|
84
|
+
yield
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@contextlib.contextmanager
|
|
88
|
+
def suppress_stderr():
|
|
89
|
+
"""Suppress stderr output within the context block."""
|
|
90
|
+
with open(os.devnull, "w") as devnull, contextlib.redirect_stderr(devnull):
|
|
91
|
+
yield
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def setup_logging(
|
|
95
|
+
level: str = "INFO",
|
|
96
|
+
format_str: str | None = None,
|
|
97
|
+
handler: logging.Handler | None = None,
|
|
98
|
+
) -> None:
|
|
99
|
+
"""Setup logging configuration.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
level: Logging level (DEBUG, INFO, WARNING, ERROR)
|
|
103
|
+
format_str: Log format string
|
|
104
|
+
handler: Custom log handler
|
|
105
|
+
"""
|
|
106
|
+
global _quiet_mode
|
|
107
|
+
_quiet_mode = level.upper() == "WARNING"
|
|
108
|
+
|
|
109
|
+
if format_str is None:
|
|
110
|
+
format_str = SIMPLE_FORMAT if level in ("INFO", "WARNING") else DEFAULT_FORMAT
|
|
111
|
+
|
|
112
|
+
# Create formatter
|
|
113
|
+
formatter = logging.Formatter(format_str)
|
|
114
|
+
|
|
115
|
+
# Create handler if not provided
|
|
116
|
+
if handler is None:
|
|
117
|
+
handler = logging.StreamHandler(sys.stderr)
|
|
118
|
+
handler.setFormatter(formatter)
|
|
119
|
+
|
|
120
|
+
# Configure root logger
|
|
121
|
+
root_logger = logging.getLogger("sif")
|
|
122
|
+
root_logger.setLevel(getattr(logging, level.upper()))
|
|
123
|
+
root_logger.handlers = []
|
|
124
|
+
root_logger.addHandler(handler)
|
|
125
|
+
|
|
126
|
+
# Don't propagate to parent
|
|
127
|
+
root_logger.propagate = False
|
|
128
|
+
|
|
129
|
+
# In quiet mode, suppress noisy third-party libraries and progress bars
|
|
130
|
+
if _quiet_mode:
|
|
131
|
+
_apply_quiet_mode()
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def get_logger(name: str) -> logging.Logger:
|
|
135
|
+
"""Get a logger instance.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
name: Logger name (typically __name__)
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
Logger instance
|
|
142
|
+
"""
|
|
143
|
+
return logging.getLogger(f"sif.{name}")
|
sif/utils/paths.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Path utilities for SIF."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from platformdirs import user_cache_dir, user_config_dir, user_data_dir
|
|
6
|
+
|
|
7
|
+
from sif.config.constants import APP_NAME
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def get_data_dir() -> Path:
|
|
11
|
+
"""Get the application data directory.
|
|
12
|
+
|
|
13
|
+
Returns:
|
|
14
|
+
Path to the data directory
|
|
15
|
+
"""
|
|
16
|
+
path = Path(user_data_dir(APP_NAME))
|
|
17
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
18
|
+
return path
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_cache_dir() -> Path:
|
|
22
|
+
"""Get the application cache directory.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
Path to the cache directory
|
|
26
|
+
"""
|
|
27
|
+
path = Path(user_cache_dir(APP_NAME))
|
|
28
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
return path
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_config_dir() -> Path:
|
|
33
|
+
"""Get the application config directory.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
Path to the config directory
|
|
37
|
+
"""
|
|
38
|
+
path = Path(user_config_dir(APP_NAME))
|
|
39
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
return path
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def expand_path(path: str | Path) -> Path:
|
|
44
|
+
"""Expand a path, resolving ~ and environment variables.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
path: Path to expand
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
Expanded absolute path
|
|
51
|
+
"""
|
|
52
|
+
return Path(path).expanduser().resolve()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def is_markdown_file(path: Path) -> bool:
|
|
56
|
+
"""Check if a file is a markdown file.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
path: File path to check
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
True if the file has a markdown extension
|
|
63
|
+
"""
|
|
64
|
+
from sif.config.constants import MARKDOWN_EXTENSIONS # noqa: PLC0415
|
|
65
|
+
|
|
66
|
+
return path.suffix.lower() in MARKDOWN_EXTENSIONS
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def get_relative_path(path: Path, base: Path) -> Path:
|
|
70
|
+
"""Get path relative to a base directory.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
path: Absolute path
|
|
74
|
+
base: Base directory
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
Relative path from base to path
|
|
78
|
+
"""
|
|
79
|
+
try:
|
|
80
|
+
return path.relative_to(base)
|
|
81
|
+
except ValueError:
|
|
82
|
+
return path
|
sif/utils/progress.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Progress tracking utilities."""
|
|
2
|
+
|
|
3
|
+
from typing import Protocol
|
|
4
|
+
|
|
5
|
+
from rich.progress import BarColumn, Progress as RichProgress, SpinnerColumn, TaskID, TextColumn
|
|
6
|
+
|
|
7
|
+
from sif.utils.logging import get_logger
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
logger = get_logger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ProgressTracker(Protocol):
|
|
14
|
+
"""Protocol for progress tracking."""
|
|
15
|
+
|
|
16
|
+
def start(self, description: str, total: int | None = None) -> None:
|
|
17
|
+
"""Start tracking progress."""
|
|
18
|
+
...
|
|
19
|
+
|
|
20
|
+
def update(self, advance: int = 1) -> None:
|
|
21
|
+
"""Update progress."""
|
|
22
|
+
...
|
|
23
|
+
|
|
24
|
+
def finish(self) -> None:
|
|
25
|
+
"""Finish tracking."""
|
|
26
|
+
...
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class RichProgressTracker:
|
|
30
|
+
"""Progress tracker using Rich library."""
|
|
31
|
+
|
|
32
|
+
def __init__(self) -> None:
|
|
33
|
+
"""Initialize progress tracker."""
|
|
34
|
+
self._progress: RichProgress | None = None
|
|
35
|
+
self._task_id: TaskID | None = None
|
|
36
|
+
|
|
37
|
+
def start(self, description: str, total: int | None = None) -> None:
|
|
38
|
+
"""Start tracking progress.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
description: Task description
|
|
42
|
+
total: Total items (None for indeterminate)
|
|
43
|
+
"""
|
|
44
|
+
self._progress = RichProgress(
|
|
45
|
+
SpinnerColumn(),
|
|
46
|
+
TextColumn("[progress.description]{task.description}"),
|
|
47
|
+
BarColumn() if total else None,
|
|
48
|
+
TextColumn("[progress.percentage]{task.percentage:>3.0f}%") if total else None,
|
|
49
|
+
)
|
|
50
|
+
self._progress.start()
|
|
51
|
+
self._task_id = self._progress.add_task(description, total=total)
|
|
52
|
+
|
|
53
|
+
def update(self, advance: int = 1) -> None:
|
|
54
|
+
"""Update progress.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
advance: Number of items to advance
|
|
58
|
+
"""
|
|
59
|
+
if self._progress and self._task_id is not None:
|
|
60
|
+
self._progress.update(self._task_id, advance=advance)
|
|
61
|
+
|
|
62
|
+
def finish(self) -> None:
|
|
63
|
+
"""Finish tracking."""
|
|
64
|
+
if self._progress:
|
|
65
|
+
self._progress.stop()
|
|
66
|
+
self._progress = None
|
|
67
|
+
self._task_id = None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class NullProgressTracker:
|
|
71
|
+
"""No-op progress tracker for silent operation."""
|
|
72
|
+
|
|
73
|
+
def start(self, description: str, total: int | None = None) -> None:
|
|
74
|
+
"""Start tracking (no-op)."""
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
def update(self, advance: int = 1) -> None:
|
|
78
|
+
"""Update progress (no-op)."""
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
def finish(self) -> None:
|
|
82
|
+
"""Finish tracking (no-op)."""
|
|
83
|
+
pass
|