docugraph 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 (37) hide show
  1. docugraph/__init__.py +24 -0
  2. docugraph/_version.py +28 -0
  3. docugraph/agents/__init__.py +27 -0
  4. docugraph/agents/coding_agent.py +532 -0
  5. docugraph/agents/tools/__init__.py +62 -0
  6. docugraph/agents/tools/graph_query.py +203 -0
  7. docugraph/agents/tools/memory_ops.py +305 -0
  8. docugraph/agents/tools/search_code.py +305 -0
  9. docugraph/agents/tools/search_docs.py +148 -0
  10. docugraph/core/__init__.py +34 -0
  11. docugraph/core/config.py +183 -0
  12. docugraph/core/embeddings.py +394 -0
  13. docugraph/core/llm.py +892 -0
  14. docugraph/core/models.py +145 -0
  15. docugraph/ingestion/__init__.py +64 -0
  16. docugraph/ingestion/api_docs.py +646 -0
  17. docugraph/ingestion/chunker.py +382 -0
  18. docugraph/ingestion/crawler.py +305 -0
  19. docugraph/ingestion/git_indexer.py +556 -0
  20. docugraph/ingestion/local_files.py +516 -0
  21. docugraph/interfaces/__init__.py +1 -0
  22. docugraph/interfaces/cli.py +983 -0
  23. docugraph/interfaces/mcp_server.py +918 -0
  24. docugraph/retrieval/__init__.py +54 -0
  25. docugraph/retrieval/context_builder.py +522 -0
  26. docugraph/retrieval/hybrid_search.py +534 -0
  27. docugraph/retrieval/reranker.py +532 -0
  28. docugraph/storage/__init__.py +14 -0
  29. docugraph/storage/graph_store.py +602 -0
  30. docugraph/storage/memory_store.py +529 -0
  31. docugraph/storage/vector_store.py +367 -0
  32. docugraph-0.1.0.dist-info/METADATA +354 -0
  33. docugraph-0.1.0.dist-info/RECORD +37 -0
  34. docugraph-0.1.0.dist-info/WHEEL +4 -0
  35. docugraph-0.1.0.dist-info/entry_points.txt +3 -0
  36. docugraph-0.1.0.dist-info/licenses/LICENSE +201 -0
  37. docugraph-0.1.0.dist-info/licenses/NOTICE +10 -0
@@ -0,0 +1,556 @@
1
+ """Git repository indexing for documentation and code.
2
+
3
+ Supports:
4
+ - Cloning repositories (HTTPS, SSH)
5
+ - Indexing documentation files
6
+ - Optional code file indexing with docstring extraction
7
+ - Branch/tag checkout
8
+ - Incremental updates via git pull
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import shutil
14
+ import subprocess # nosec B404 -- git CLI wrapper; argv is a list, never shell
15
+ from collections.abc import Iterator
16
+ from dataclasses import dataclass, field
17
+ from datetime import UTC, datetime
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ from docugraph.core.config import get_config
22
+ from docugraph.core.models import Document
23
+ from docugraph.ingestion.chunker import Chunker, ChunkerConfig
24
+ from docugraph.ingestion.local_files import (
25
+ IndexedFile,
26
+ LocalFileConfig,
27
+ LocalFileIndexer,
28
+ )
29
+
30
+
31
+ @dataclass
32
+ class GitIndexerConfig:
33
+ """Configuration for git repository indexing."""
34
+
35
+ # Clone settings
36
+ clone_depth: int | None = None # None for full clone, 1 for shallow
37
+ clone_timeout: int = 300 # seconds
38
+
39
+ # Branch/ref to checkout
40
+ ref: str | None = None # branch, tag, or commit hash
41
+
42
+ # Where to store cloned repos
43
+ repos_dir: Path | None = None # Default: config.storage.data_dir / "repos"
44
+
45
+ # File indexing settings
46
+ include_docs: bool = True # Index documentation files
47
+ include_code: bool = False # Index code files with docstrings
48
+
49
+ # Documentation patterns (in addition to LocalFileConfig defaults)
50
+ doc_patterns: list[str] = field(
51
+ default_factory=lambda: [
52
+ "*.md",
53
+ "*.mdx",
54
+ "*.rst",
55
+ "*.txt",
56
+ "README*",
57
+ "CHANGELOG*",
58
+ "CONTRIBUTING*",
59
+ "docs/**/*",
60
+ "documentation/**/*",
61
+ ]
62
+ )
63
+
64
+ # Code patterns (when include_code=True)
65
+ code_patterns: list[str] = field(
66
+ default_factory=lambda: [
67
+ "*.py",
68
+ "*.js",
69
+ "*.ts",
70
+ "*.java",
71
+ "*.go",
72
+ "*.rs",
73
+ ]
74
+ )
75
+
76
+ # Directories to skip
77
+ skip_dirs: list[str] = field(
78
+ default_factory=lambda: [
79
+ ".git",
80
+ "node_modules",
81
+ "__pycache__",
82
+ ".venv",
83
+ "venv",
84
+ "dist",
85
+ "build",
86
+ ".tox",
87
+ "target",
88
+ "vendor",
89
+ ]
90
+ )
91
+
92
+ # Maximum file size (1MB default)
93
+ max_file_size: int = 1024 * 1024
94
+
95
+ # Clean up cloned repos after indexing
96
+ cleanup_after_index: bool = False
97
+
98
+
99
+ @dataclass
100
+ class RepoInfo:
101
+ """Information about a git repository."""
102
+
103
+ url: str
104
+ name: str
105
+ local_path: Path
106
+ branch: str | None
107
+ commit_hash: str | None
108
+ clone_time: datetime
109
+ metadata: dict[str, Any] = field(default_factory=dict)
110
+
111
+
112
+ class GitIndexer:
113
+ """Indexes git repositories for documentation and code.
114
+
115
+ Clones repositories, indexes documentation files, and optionally
116
+ extracts code documentation (docstrings, comments).
117
+ """
118
+
119
+ def __init__(
120
+ self,
121
+ config: GitIndexerConfig | None = None,
122
+ chunker: Chunker | None = None,
123
+ ) -> None:
124
+ """Initialize the git indexer.
125
+
126
+ Args:
127
+ config: Indexer configuration
128
+ chunker: Optional chunker for document splitting
129
+ """
130
+ self._config = config or GitIndexerConfig()
131
+ self._chunker = chunker
132
+
133
+ # Set up repos directory
134
+ if self._config.repos_dir is None:
135
+ app_config = get_config()
136
+ self._repos_dir = app_config.storage.data_dir / "repos"
137
+ else:
138
+ self._repos_dir = self._config.repos_dir
139
+
140
+ self._repos_dir.mkdir(parents=True, exist_ok=True)
141
+
142
+ def _extract_repo_name(self, url: str) -> str:
143
+ """Extract repository name from URL.
144
+
145
+ Args:
146
+ url: Git repository URL
147
+
148
+ Returns:
149
+ Repository name
150
+ """
151
+ # Handle various URL formats
152
+ # https://github.com/user/repo.git
153
+ # git@github.com:user/repo.git
154
+ # https://github.com/user/repo
155
+
156
+ name = url.rstrip("/")
157
+ if name.endswith(".git"):
158
+ name = name[:-4]
159
+
160
+ # Extract last path component
161
+ if "/" in name:
162
+ name = name.split("/")[-1]
163
+ elif ":" in name:
164
+ name = name.split(":")[-1].split("/")[-1]
165
+
166
+ return name or "repo"
167
+
168
+ def _run_git_command(
169
+ self,
170
+ args: list[str],
171
+ cwd: Path | None = None,
172
+ timeout: int | None = None,
173
+ ) -> tuple[str, str, int]:
174
+ """Run a git command.
175
+
176
+ Args:
177
+ args: Git command arguments
178
+ cwd: Working directory
179
+ timeout: Command timeout in seconds
180
+
181
+ Returns:
182
+ Tuple of (stdout, stderr, return_code)
183
+ """
184
+ cmd = ["git"] + args
185
+ timeout = timeout or self._config.clone_timeout
186
+
187
+ try:
188
+ result = subprocess.run( # nosec B603 -- argv list, no shell; git only
189
+ cmd,
190
+ cwd=cwd,
191
+ capture_output=True,
192
+ text=True,
193
+ timeout=timeout,
194
+ )
195
+ return result.stdout, result.stderr, result.returncode
196
+ except subprocess.TimeoutExpired:
197
+ return "", "Command timed out", 1
198
+ except Exception as e:
199
+ return "", str(e), 1
200
+
201
+ def _get_current_commit(self, repo_path: Path) -> str | None:
202
+ """Get the current commit hash.
203
+
204
+ Args:
205
+ repo_path: Repository path
206
+
207
+ Returns:
208
+ Commit hash or None
209
+ """
210
+ stdout, _, code = self._run_git_command(
211
+ ["rev-parse", "HEAD"],
212
+ cwd=repo_path,
213
+ timeout=10,
214
+ )
215
+ if code == 0:
216
+ return stdout.strip()
217
+ return None
218
+
219
+ def _get_current_branch(self, repo_path: Path) -> str | None:
220
+ """Get the current branch name.
221
+
222
+ Args:
223
+ repo_path: Repository path
224
+
225
+ Returns:
226
+ Branch name or None (if detached HEAD)
227
+ """
228
+ stdout, _, code = self._run_git_command(
229
+ ["rev-parse", "--abbrev-ref", "HEAD"],
230
+ cwd=repo_path,
231
+ timeout=10,
232
+ )
233
+ if code == 0:
234
+ branch = stdout.strip()
235
+ return None if branch == "HEAD" else branch
236
+ return None
237
+
238
+ def clone(
239
+ self,
240
+ url: str,
241
+ name: str | None = None,
242
+ ref: str | None = None,
243
+ ) -> RepoInfo:
244
+ """Clone a git repository.
245
+
246
+ Args:
247
+ url: Repository URL
248
+ name: Optional custom name (defaults to extracted from URL)
249
+ ref: Branch, tag, or commit to checkout
250
+
251
+ Returns:
252
+ RepoInfo with repository details
253
+
254
+ Raises:
255
+ RuntimeError: If clone fails
256
+ """
257
+ name = name or self._extract_repo_name(url)
258
+ local_path = self._repos_dir / name
259
+
260
+ # Check if already cloned
261
+ if local_path.exists():
262
+ # Update existing repo
263
+ return self.update(local_path)
264
+
265
+ # Build clone command
266
+ clone_args = ["clone"]
267
+
268
+ if self._config.clone_depth is not None:
269
+ clone_args.extend(["--depth", str(self._config.clone_depth)])
270
+
271
+ if ref and self._config.clone_depth:
272
+ # For shallow clones, we need to specify the branch
273
+ clone_args.extend(["--branch", ref])
274
+
275
+ clone_args.extend([url, str(local_path)])
276
+
277
+ # Execute clone
278
+ stdout, stderr, code = self._run_git_command(clone_args)
279
+
280
+ if code != 0:
281
+ raise RuntimeError(f"Failed to clone {url}: {stderr}")
282
+
283
+ # Checkout specific ref if needed (and not already done via --branch)
284
+ if ref and not self._config.clone_depth:
285
+ checkout_args = ["checkout", ref]
286
+ _, stderr, code = self._run_git_command(checkout_args, cwd=local_path)
287
+ if code != 0:
288
+ raise RuntimeError(f"Failed to checkout {ref}: {stderr}")
289
+
290
+ return RepoInfo(
291
+ url=url,
292
+ name=name,
293
+ local_path=local_path,
294
+ branch=self._get_current_branch(local_path),
295
+ commit_hash=self._get_current_commit(local_path),
296
+ clone_time=datetime.now(UTC),
297
+ metadata={"url": url, "ref": ref},
298
+ )
299
+
300
+ def update(self, repo_path: Path | str) -> RepoInfo:
301
+ """Update an existing repository with git pull.
302
+
303
+ Args:
304
+ repo_path: Path to repository
305
+
306
+ Returns:
307
+ Updated RepoInfo
308
+
309
+ Raises:
310
+ RuntimeError: If update fails
311
+ """
312
+ repo_path = Path(repo_path).resolve()
313
+
314
+ if not (repo_path / ".git").exists():
315
+ raise RuntimeError(f"Not a git repository: {repo_path}")
316
+
317
+ # Get remote URL
318
+ stdout, _, code = self._run_git_command(
319
+ ["remote", "get-url", "origin"],
320
+ cwd=repo_path,
321
+ timeout=10,
322
+ )
323
+ url = stdout.strip() if code == 0 else "unknown"
324
+
325
+ # Pull updates
326
+ stdout, stderr, code = self._run_git_command(
327
+ ["pull", "--ff-only"],
328
+ cwd=repo_path,
329
+ )
330
+
331
+ if code != 0:
332
+ # Try fetch + reset as fallback
333
+ self._run_git_command(["fetch", "origin"], cwd=repo_path)
334
+
335
+ return RepoInfo(
336
+ url=url,
337
+ name=repo_path.name,
338
+ local_path=repo_path,
339
+ branch=self._get_current_branch(repo_path),
340
+ commit_hash=self._get_current_commit(repo_path),
341
+ clone_time=datetime.now(UTC),
342
+ metadata={"updated": True},
343
+ )
344
+
345
+ def _build_local_file_config(self) -> LocalFileConfig:
346
+ """Build LocalFileConfig for indexing.
347
+
348
+ Returns:
349
+ LocalFileConfig with appropriate patterns
350
+ """
351
+ patterns = []
352
+
353
+ if self._config.include_docs:
354
+ patterns.extend(self._config.doc_patterns)
355
+
356
+ if self._config.include_code:
357
+ patterns.extend(self._config.code_patterns)
358
+
359
+ return LocalFileConfig(
360
+ include_patterns=patterns,
361
+ exclude_patterns=[
362
+ "*.min.js",
363
+ "*.min.css",
364
+ "*.map",
365
+ "*.lock",
366
+ "package-lock.json",
367
+ "yarn.lock",
368
+ "Cargo.lock",
369
+ ],
370
+ skip_dirs=self._config.skip_dirs,
371
+ max_file_size=self._config.max_file_size,
372
+ follow_symlinks=False,
373
+ extract_title=True,
374
+ include_hidden=False,
375
+ )
376
+
377
+ def index_repo(
378
+ self,
379
+ repo_path: Path | str,
380
+ repo_info: RepoInfo | None = None,
381
+ ) -> Iterator[IndexedFile]:
382
+ """Index files in a repository.
383
+
384
+ Args:
385
+ repo_path: Path to repository
386
+ repo_info: Optional repository info for metadata
387
+
388
+ Yields:
389
+ IndexedFile for each indexed file
390
+ """
391
+ repo_path = Path(repo_path).resolve()
392
+
393
+ local_config = self._build_local_file_config()
394
+ indexer = LocalFileIndexer(config=local_config)
395
+
396
+ for indexed_file in indexer.index_directory(repo_path, recursive=True):
397
+ # Add repository metadata
398
+ if repo_info:
399
+ indexed_file.metadata.update(
400
+ {
401
+ "repo_url": repo_info.url,
402
+ "repo_name": repo_info.name,
403
+ "branch": repo_info.branch,
404
+ "commit": repo_info.commit_hash,
405
+ }
406
+ )
407
+
408
+ yield indexed_file
409
+
410
+ def clone_and_index(
411
+ self,
412
+ url: str,
413
+ name: str | None = None,
414
+ ref: str | None = None,
415
+ ) -> tuple[RepoInfo, list[Document]]:
416
+ """Clone a repository and index its contents.
417
+
418
+ Args:
419
+ url: Repository URL
420
+ name: Optional custom name
421
+ ref: Branch, tag, or commit to checkout
422
+
423
+ Returns:
424
+ Tuple of (RepoInfo, list of Documents)
425
+ """
426
+ # Clone repository
427
+ repo_info = self.clone(url, name=name, ref=ref or self._config.ref)
428
+
429
+ # Index files
430
+ documents = []
431
+ for indexed_file in self.index_repo(repo_info.local_path, repo_info):
432
+ documents.append(indexed_file.to_document())
433
+
434
+ # Cleanup if configured
435
+ if self._config.cleanup_after_index:
436
+ self.cleanup(repo_info.local_path)
437
+
438
+ return repo_info, documents
439
+
440
+ def clone_and_chunk(
441
+ self,
442
+ url: str,
443
+ name: str | None = None,
444
+ ref: str | None = None,
445
+ ) -> tuple[RepoInfo, list[Document], list[Any]]:
446
+ """Clone a repository, index, and chunk its contents.
447
+
448
+ Args:
449
+ url: Repository URL
450
+ name: Optional custom name
451
+ ref: Branch, tag, or commit to checkout
452
+
453
+ Returns:
454
+ Tuple of (RepoInfo, documents, chunks)
455
+ """
456
+ if self._chunker is None:
457
+ self._chunker = Chunker(ChunkerConfig())
458
+
459
+ repo_info, documents = self.clone_and_index(url, name=name, ref=ref)
460
+
461
+ # Chunk documents
462
+ all_chunks = []
463
+ for doc in documents:
464
+ chunks = self._chunker.chunk_document(doc)
465
+ all_chunks.extend(chunks)
466
+
467
+ return repo_info, documents, all_chunks
468
+
469
+ def cleanup(self, repo_path: Path | str) -> bool:
470
+ """Remove a cloned repository.
471
+
472
+ Args:
473
+ repo_path: Path to repository
474
+
475
+ Returns:
476
+ True if cleanup succeeded
477
+ """
478
+ repo_path = Path(repo_path).resolve()
479
+
480
+ if not repo_path.exists():
481
+ return True
482
+
483
+ # Safety check - must be under repos_dir
484
+ try:
485
+ repo_path.relative_to(self._repos_dir)
486
+ except ValueError:
487
+ return False
488
+
489
+ try:
490
+ shutil.rmtree(repo_path)
491
+ return True
492
+ except Exception:
493
+ return False
494
+
495
+ def list_repos(self) -> list[RepoInfo]:
496
+ """List all cloned repositories.
497
+
498
+ Returns:
499
+ List of RepoInfo for each cloned repository
500
+ """
501
+ repos = []
502
+
503
+ for path in self._repos_dir.iterdir():
504
+ if path.is_dir() and (path / ".git").exists():
505
+ # Get remote URL
506
+ stdout, _, code = self._run_git_command(
507
+ ["remote", "get-url", "origin"],
508
+ cwd=path,
509
+ timeout=10,
510
+ )
511
+ url = stdout.strip() if code == 0 else "unknown"
512
+
513
+ repos.append(
514
+ RepoInfo(
515
+ url=url,
516
+ name=path.name,
517
+ local_path=path,
518
+ branch=self._get_current_branch(path),
519
+ commit_hash=self._get_current_commit(path),
520
+ clone_time=datetime.fromtimestamp(path.stat().st_mtime, tz=UTC),
521
+ )
522
+ )
523
+
524
+ return repos
525
+
526
+ def get_repo_stats(self, repo_path: Path | str) -> dict[str, Any]:
527
+ """Get statistics about a repository.
528
+
529
+ Args:
530
+ repo_path: Path to repository
531
+
532
+ Returns:
533
+ Statistics dict
534
+ """
535
+ repo_path = Path(repo_path).resolve()
536
+
537
+ local_config = self._build_local_file_config()
538
+ indexer = LocalFileIndexer(config=local_config)
539
+
540
+ return indexer.get_stats(repo_path, recursive=True)
541
+
542
+
543
+ def get_git_indexer(
544
+ config: GitIndexerConfig | None = None,
545
+ chunker: Chunker | None = None,
546
+ ) -> GitIndexer:
547
+ """Factory function to get a GitIndexer instance.
548
+
549
+ Args:
550
+ config: Optional configuration
551
+ chunker: Optional chunker instance
552
+
553
+ Returns:
554
+ GitIndexer instance
555
+ """
556
+ return GitIndexer(config=config, chunker=chunker)