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.
Files changed (86) hide show
  1. docsif-0.1.0.dist-info/METADATA +393 -0
  2. docsif-0.1.0.dist-info/RECORD +86 -0
  3. docsif-0.1.0.dist-info/WHEEL +4 -0
  4. docsif-0.1.0.dist-info/entry_points.txt +3 -0
  5. docsif-0.1.0.dist-info/licenses/LICENSE +21 -0
  6. sif/__init__.py +24 -0
  7. sif/_version.py +3 -0
  8. sif/cli/__init__.py +11 -0
  9. sif/cli/commands/__init__.py +18 -0
  10. sif/cli/commands/bench.py +148 -0
  11. sif/cli/commands/collection.py +360 -0
  12. sif/cli/commands/context.py +154 -0
  13. sif/cli/commands/get.py +200 -0
  14. sif/cli/commands/index.py +294 -0
  15. sif/cli/commands/ls.py +67 -0
  16. sif/cli/commands/mcp.py +93 -0
  17. sif/cli/commands/pull.py +111 -0
  18. sif/cli/commands/search.py +574 -0
  19. sif/cli/config.py +206 -0
  20. sif/cli/formatters.py +269 -0
  21. sif/cli/main.py +205 -0
  22. sif/config/__init__.py +30 -0
  23. sif/config/constants.py +52 -0
  24. sif/config/settings.py +217 -0
  25. sif/core/__init__.py +21 -0
  26. sif/core/collection.py +161 -0
  27. sif/core/context.py +167 -0
  28. sif/core/document.py +125 -0
  29. sif/core/models.py +298 -0
  30. sif/database/__init__.py +23 -0
  31. sif/database/connection.py +139 -0
  32. sif/database/database.py +136 -0
  33. sif/database/migrations.py +187 -0
  34. sif/database/repositories.py +500 -0
  35. sif/database/repository.py +171 -0
  36. sif/database/schema.py +362 -0
  37. sif/embedding/__init__.py +22 -0
  38. sif/embedding/cache.py +196 -0
  39. sif/embedding/embedder.py +328 -0
  40. sif/embedding/factory.py +92 -0
  41. sif/embedding/manager.py +200 -0
  42. sif/embedding/model.py +126 -0
  43. sif/indexing/__init__.py +21 -0
  44. sif/indexing/chunker.py +226 -0
  45. sif/indexing/indexer.py +226 -0
  46. sif/indexing/parser.py +192 -0
  47. sif/indexing/scanner.py +148 -0
  48. sif/indexing/watcher.py +156 -0
  49. sif/mcp/README.md +258 -0
  50. sif/mcp/__init__.py +9 -0
  51. sif/mcp/claude_desktop_config.json +16 -0
  52. sif/mcp/cli.py +92 -0
  53. sif/mcp/protocol.py +336 -0
  54. sif/mcp/server.py +433 -0
  55. sif/mcp/server_http.py +54 -0
  56. sif/mcp/test_mcp.py +108 -0
  57. sif/mcp/tools.py +497 -0
  58. sif/mcp/transport_http.py +424 -0
  59. sif/mcp/transport_stdio.py +292 -0
  60. sif/mcp_server/__init__.py +26 -0
  61. sif/mcp_server/handlers.py +68 -0
  62. sif/mcp_server/server.py +58 -0
  63. sif/mcp_server/tools.py +216 -0
  64. sif/mcp_server/transport.py +221 -0
  65. sif/models/__init__.py +70 -0
  66. sif/models/collection.py +51 -0
  67. sif/models/context.py +45 -0
  68. sif/models/document.py +76 -0
  69. sif/models/download.py +208 -0
  70. sif/models/embedding.py +71 -0
  71. sif/models/search.py +92 -0
  72. sif/search/__init__.py +22 -0
  73. sif/search/benchmark.py +110 -0
  74. sif/search/bm25.py +216 -0
  75. sif/search/expansion.py +157 -0
  76. sif/search/hybrid.py +323 -0
  77. sif/search/rerank.py +390 -0
  78. sif/search/rrf.py +186 -0
  79. sif/search/snippets.py +130 -0
  80. sif/search/strategy.py +97 -0
  81. sif/search/vector.py +177 -0
  82. sif/utils/__init__.py +26 -0
  83. sif/utils/logging.py +143 -0
  84. sif/utils/paths.py +82 -0
  85. sif/utils/progress.py +83 -0
  86. sif/utils/text.py +128 -0
sif/indexing/parser.py ADDED
@@ -0,0 +1,192 @@
1
+ """Document parsing utilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import re
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import frontmatter
12
+
13
+
14
+ @dataclass
15
+ class ParsedDocument:
16
+ """Result of parsing a document."""
17
+
18
+ path: str
19
+ content: str
20
+ title: str
21
+ metadata: dict[str, Any]
22
+ checksum: str
23
+ mtime: float
24
+
25
+
26
+ class MarkdownParser:
27
+ """Parser for Markdown documents."""
28
+
29
+ def __init__(self) -> None:
30
+ self.title_pattern = re.compile(r"^#\s+(.+)$", re.MULTILINE)
31
+
32
+ def parse(self, file_path: Path) -> ParsedDocument:
33
+ """Parse a markdown file.
34
+
35
+ Args:
36
+ file_path: Path to the markdown file
37
+
38
+ Returns:
39
+ Parsed document
40
+ """
41
+ # Read file
42
+ content = file_path.read_text(encoding="utf-8")
43
+
44
+ # Get file stats
45
+ stat = file_path.stat()
46
+ mtime = stat.st_mtime
47
+
48
+ # Parse frontmatter
49
+ try:
50
+ post = frontmatter.loads(content)
51
+ body = post.content
52
+ metadata = dict(post.metadata)
53
+ except Exception:
54
+ body = content
55
+ metadata = {}
56
+
57
+ # Extract title
58
+ title = metadata.get("title", "")
59
+ if not title:
60
+ # Try to extract from first H1
61
+ match = self.title_pattern.search(body)
62
+ title = match.group(1).strip() if match else file_path.stem
63
+
64
+ # Calculate checksum
65
+ checksum = hashlib.sha256(content.encode()).hexdigest()
66
+
67
+ return ParsedDocument(
68
+ path=str(file_path.resolve()),
69
+ content=body,
70
+ title=title,
71
+ metadata=metadata,
72
+ checksum=checksum,
73
+ mtime=mtime,
74
+ )
75
+
76
+
77
+ class TextParser:
78
+ """Parser for plain text documents."""
79
+
80
+ def parse(self, file_path: Path) -> ParsedDocument:
81
+ """Parse a text file."""
82
+ content = file_path.read_text(encoding="utf-8")
83
+ stat = file_path.stat()
84
+
85
+ # Use first line as title if not too long
86
+ lines = content.split("\n")
87
+ title = ""
88
+ for line in lines:
89
+ stripped = line.strip()
90
+ if stripped and len(stripped) < 100:
91
+ title = stripped
92
+ break
93
+
94
+ if not title:
95
+ title = file_path.stem
96
+
97
+ checksum = hashlib.sha256(content.encode()).hexdigest()
98
+
99
+ return ParsedDocument(
100
+ path=str(file_path.resolve()),
101
+ content=content,
102
+ title=title,
103
+ metadata={},
104
+ checksum=checksum,
105
+ mtime=stat.st_mtime,
106
+ )
107
+
108
+
109
+ class CodeParser:
110
+ """Parser for code files."""
111
+
112
+ # File extensions to language mapping
113
+ LANGUAGE_MAP = {
114
+ ".py": "python",
115
+ ".js": "javascript",
116
+ ".ts": "typescript",
117
+ ".jsx": "jsx",
118
+ ".tsx": "tsx",
119
+ ".rs": "rust",
120
+ ".go": "go",
121
+ ".java": "java",
122
+ ".cpp": "cpp",
123
+ ".c": "c",
124
+ ".h": "c",
125
+ ".hpp": "cpp",
126
+ ".rb": "ruby",
127
+ ".php": "php",
128
+ ".swift": "swift",
129
+ ".kt": "kotlin",
130
+ ".scala": "scala",
131
+ ".r": "r",
132
+ ".sh": "bash",
133
+ ".yaml": "yaml",
134
+ ".yml": "yaml",
135
+ ".json": "json",
136
+ ".toml": "toml",
137
+ ".xml": "xml",
138
+ ".sql": "sql",
139
+ }
140
+
141
+ def parse(self, file_path: Path) -> ParsedDocument:
142
+ """Parse a code file."""
143
+ content = file_path.read_text(encoding="utf-8")
144
+ stat = file_path.stat()
145
+
146
+ # Detect language
147
+ ext = file_path.suffix.lower()
148
+ language = self.LANGUAGE_MAP.get(ext, "text")
149
+
150
+ # Try to extract title from comments
151
+ title = file_path.stem
152
+
153
+ # For Python, look for module docstring
154
+ if language == "python":
155
+ match = re.search(r'^[\'"]{3}(.+?)[\'"]{3}', content, re.MULTILINE | re.DOTALL)
156
+ if match:
157
+ docstring = match.group(1).strip()
158
+ first_line = docstring.split("\n")[0].strip()
159
+ if first_line:
160
+ title = first_line[:100]
161
+
162
+ checksum = hashlib.sha256(content.encode()).hexdigest()
163
+
164
+ return ParsedDocument(
165
+ path=str(file_path.resolve()),
166
+ content=content,
167
+ title=title,
168
+ metadata={"language": language},
169
+ checksum=checksum,
170
+ mtime=stat.st_mtime,
171
+ )
172
+
173
+
174
+ def create_parser(file_path: Path) -> MarkdownParser | TextParser | CodeParser | None:
175
+ """Create appropriate parser for a file.
176
+
177
+ Args:
178
+ file_path: Path to the file
179
+
180
+ Returns:
181
+ Parser instance or None
182
+ """
183
+ ext = file_path.suffix.lower()
184
+
185
+ if ext in [".md", ".markdown", ".mdx"]:
186
+ return MarkdownParser()
187
+ if ext in CodeParser.LANGUAGE_MAP:
188
+ return CodeParser()
189
+ if ext in [".txt", ".rst", ".adoc"]:
190
+ return TextParser()
191
+
192
+ return None
@@ -0,0 +1,148 @@
1
+ """File scanning utilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import fnmatch
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+
9
+
10
+ @dataclass
11
+ class ScanResult:
12
+ """Result of scanning a directory."""
13
+
14
+ files: list[Path] = field(default_factory=list)
15
+ scanned_dirs: set[Path] = field(default_factory=set)
16
+ file_count: int = 0
17
+
18
+ def __post_init__(self):
19
+ self.file_count = len(self.files)
20
+
21
+
22
+ class FileScanner:
23
+ """Scanner for finding files matching patterns."""
24
+
25
+ DEFAULT_IGNORE_PATTERNS = [
26
+ "node_modules",
27
+ ".git",
28
+ ".svn",
29
+ ".hg",
30
+ "__pycache__",
31
+ ".pytest_cache",
32
+ ".mypy_cache",
33
+ ".tox",
34
+ ".venv",
35
+ "venv",
36
+ "env",
37
+ ".env",
38
+ "dist",
39
+ "build",
40
+ "*.egg-info",
41
+ ".DS_Store",
42
+ "Thumbs.db",
43
+ "*.swp",
44
+ "*.swo",
45
+ "*~",
46
+ ".idea",
47
+ ".vscode",
48
+ ]
49
+
50
+ def __init__(self) -> None:
51
+ pass
52
+
53
+ def scan(
54
+ self,
55
+ root_path: Path,
56
+ pattern: str = "**/*.md",
57
+ ignore_patterns: list[str] | None = None,
58
+ ) -> ScanResult:
59
+ """Scan a directory for files matching a pattern.
60
+
61
+ Args:
62
+ root_path: Root directory to scan
63
+ pattern: Glob pattern to match files
64
+ ignore_patterns: Patterns to ignore
65
+
66
+ Returns:
67
+ Scan result with matching files
68
+ """
69
+ root_path = Path(root_path).resolve()
70
+
71
+ # Combine default and custom ignore patterns
72
+ all_ignore_patterns = list(self.DEFAULT_IGNORE_PATTERNS)
73
+ if ignore_patterns:
74
+ all_ignore_patterns.extend(ignore_patterns)
75
+
76
+ result = ScanResult()
77
+ result.scanned_dirs.add(root_path)
78
+
79
+ # Use rglob for recursive matching
80
+ for file_path in root_path.rglob(pattern):
81
+ if not file_path.is_file():
82
+ continue
83
+
84
+ # Check if file should be ignored
85
+ if self._should_ignore(file_path, root_path, all_ignore_patterns):
86
+ continue
87
+
88
+ result.files.append(file_path)
89
+
90
+ result.file_count = len(result.files)
91
+ return result
92
+
93
+ def _should_ignore(
94
+ self,
95
+ file_path: Path,
96
+ root_path: Path,
97
+ ignore_patterns: list[str],
98
+ ) -> bool:
99
+ """Check if a file should be ignored.
100
+
101
+ Args:
102
+ file_path: Path to check
103
+ root_path: Root directory
104
+ ignore_patterns: Patterns to ignore
105
+
106
+ Returns:
107
+ True if file should be ignored
108
+ """
109
+ # Get relative path from root
110
+ try:
111
+ rel_path = file_path.relative_to(root_path)
112
+ except ValueError:
113
+ return False
114
+
115
+ # Check each part of the path against ignore patterns
116
+ for part in rel_path.parts:
117
+ for pattern in ignore_patterns:
118
+ if fnmatch.fnmatch(part, pattern):
119
+ return True
120
+ if fnmatch.fnmatch(str(rel_path), pattern):
121
+ return True
122
+
123
+ # Check filename
124
+ return any(fnmatch.fnmatch(file_path.name, pattern) for pattern in ignore_patterns)
125
+
126
+ def get_stats(self, scan_result: ScanResult) -> dict:
127
+ """Get statistics about a scan result.
128
+
129
+ Args:
130
+ scan_result: Scan result
131
+
132
+ Returns:
133
+ Statistics dictionary
134
+ """
135
+ total_size = sum(f.stat().st_size for f in scan_result.files if f.exists())
136
+
137
+ # Count by extension
138
+ extensions = {}
139
+ for f in scan_result.files:
140
+ ext = f.suffix.lower() or "(no extension)"
141
+ extensions[ext] = extensions.get(ext, 0) + 1
142
+
143
+ return {
144
+ "file_count": scan_result.file_count,
145
+ "total_size_bytes": total_size,
146
+ "total_size_mb": round(total_size / (1024 * 1024), 2),
147
+ "extensions": extensions,
148
+ }
@@ -0,0 +1,156 @@
1
+ """File system watcher for auto-indexing."""
2
+
3
+ from pathlib import Path
4
+
5
+ from watchdog.events import FileSystemEvent, FileSystemEventHandler
6
+ from watchdog.observers import Observer
7
+
8
+ from sif.utils.logging import get_logger
9
+
10
+
11
+ logger = get_logger(__name__)
12
+
13
+
14
+ class IndexingEventHandler(FileSystemEventHandler):
15
+ """Event handler for file system changes."""
16
+
17
+ def __init__(
18
+ self,
19
+ collection_id: str,
20
+ indexer: "DocumentIndexer", # noqa: F821
21
+ extensions: set[str] | None = None,
22
+ ) -> None:
23
+ """Initialize event handler.
24
+
25
+ Args:
26
+ collection_id: Collection ID
27
+ indexer: Document indexer
28
+ extensions: File extensions to watch
29
+ """
30
+ self._collection_id = collection_id
31
+ self._indexer = indexer
32
+ self._extensions = extensions or {".md", ".markdown"}
33
+
34
+ def on_created(self, event: FileSystemEvent) -> None:
35
+ """Handle file creation."""
36
+ if event.is_directory:
37
+ return
38
+
39
+ if self._should_handle(event.src_path):
40
+ logger.info(f"File created: {event.src_path}")
41
+ # Trigger incremental index
42
+
43
+ def on_modified(self, event: FileSystemEvent) -> None:
44
+ """Handle file modification."""
45
+ if event.is_directory:
46
+ return
47
+
48
+ if self._should_handle(event.src_path):
49
+ logger.info(f"File modified: {event.src_path}")
50
+ # Trigger incremental index
51
+
52
+ def on_deleted(self, event: FileSystemEvent) -> None:
53
+ """Handle file deletion."""
54
+ if event.is_directory:
55
+ return
56
+
57
+ if self._should_handle(event.src_path):
58
+ logger.info(f"File deleted: {event.src_path}")
59
+ # Trigger removal from index
60
+
61
+ def on_moved(self, event: FileSystemEvent) -> None:
62
+ """Handle file move/rename."""
63
+ if event.is_directory:
64
+ return
65
+
66
+ if self._should_handle(event.src_path):
67
+ logger.info(f"File moved: {event.src_path} -> {event.dest_path}")
68
+ # Trigger update
69
+
70
+ def _should_handle(self, path: str) -> bool:
71
+ """Check if a path should be handled.
72
+
73
+ Args:
74
+ path: File path
75
+
76
+ Returns:
77
+ True if path should be handled
78
+ """
79
+ return any(path.endswith(ext) for ext in self._extensions)
80
+
81
+
82
+ class FileWatcher:
83
+ """File system watcher for auto-indexing.
84
+
85
+ Watches directories for changes and triggers incremental
86
+ indexing when files are created, modified, or deleted.
87
+ """
88
+
89
+ def __init__(
90
+ self,
91
+ indexer: "DocumentIndexer", # noqa: F821
92
+ extensions: set[str] | None = None,
93
+ ) -> None:
94
+ """Initialize file watcher.
95
+
96
+ Args:
97
+ indexer: Document indexer
98
+ extensions: File extensions to watch
99
+ """
100
+ self._indexer = indexer
101
+ self._extensions = extensions or {".md", ".markdown"}
102
+ self._observer: Observer | None = None
103
+ self._handlers: dict[str, IndexingEventHandler] = {}
104
+
105
+ def start(self, collection_id: str, paths: list[str]) -> None:
106
+ """Start watching paths.
107
+
108
+ Args:
109
+ collection_id: Collection ID
110
+ paths: Paths to watch
111
+ """
112
+ logger.info(f"Starting file watcher for collection: {collection_id}")
113
+
114
+ self._observer = Observer()
115
+
116
+ handler = IndexingEventHandler(
117
+ collection_id=collection_id,
118
+ indexer=self._indexer,
119
+ extensions=self._extensions,
120
+ )
121
+
122
+ for path in paths:
123
+ if Path(path).exists():
124
+ self._observer.schedule(handler, path, recursive=True)
125
+ logger.info(f"Watching: {path}")
126
+
127
+ self._observer.start()
128
+ self._handlers[collection_id] = handler
129
+
130
+ def stop(self, collection_id: str | None = None) -> None:
131
+ """Stop watching.
132
+
133
+ Args:
134
+ collection_id: Collection to stop watching (None for all)
135
+ """
136
+ if collection_id and collection_id in self._handlers:
137
+ del self._handlers[collection_id]
138
+
139
+ if not collection_id or not self._handlers:
140
+ if self._observer:
141
+ self._observer.stop()
142
+ self._observer.join()
143
+ self._observer = None
144
+
145
+ logger.info("File watcher stopped")
146
+
147
+ def is_watching(self, collection_id: str) -> bool:
148
+ """Check if a collection is being watched.
149
+
150
+ Args:
151
+ collection_id: Collection ID
152
+
153
+ Returns:
154
+ True if collection is being watched
155
+ """
156
+ return collection_id in self._handlers