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,646 @@
1
+ """API documentation parsing for OpenAPI and GraphQL schemas.
2
+
3
+ Extracts structured information from API specifications:
4
+ - Endpoints, methods, parameters
5
+ - Types, schemas, models
6
+ - Descriptions and examples
7
+
8
+ Supports:
9
+ - OpenAPI 3.x (JSON/YAML)
10
+ - GraphQL SDL schemas
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import re
17
+ from dataclasses import dataclass, field
18
+ from enum import StrEnum
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ from docugraph.core.models import Document
23
+
24
+
25
+ class APIDocType(StrEnum):
26
+ """Type of API documentation."""
27
+
28
+ OPENAPI = "openapi"
29
+ GRAPHQL = "graphql"
30
+ UNKNOWN = "unknown"
31
+
32
+
33
+ @dataclass
34
+ class APIParameter:
35
+ """An API parameter (query, path, header, body)."""
36
+
37
+ name: str
38
+ location: str # query, path, header, body
39
+ param_type: str # string, integer, boolean, etc.
40
+ required: bool = False
41
+ description: str | None = None
42
+ default: Any = None
43
+ example: Any = None
44
+
45
+
46
+ @dataclass
47
+ class APIEndpoint:
48
+ """An API endpoint with its metadata."""
49
+
50
+ path: str
51
+ method: str # GET, POST, PUT, DELETE, etc.
52
+ summary: str | None = None
53
+ description: str | None = None
54
+ parameters: list[APIParameter] = field(default_factory=list)
55
+ request_body: dict[str, Any] | None = None
56
+ responses: dict[str, Any] = field(default_factory=dict)
57
+ tags: list[str] = field(default_factory=list)
58
+ deprecated: bool = False
59
+
60
+
61
+ @dataclass
62
+ class APISchema:
63
+ """A data schema/model from the API spec."""
64
+
65
+ name: str
66
+ schema_type: str # object, array, string, etc.
67
+ description: str | None = None
68
+ properties: dict[str, Any] = field(default_factory=dict)
69
+ required_fields: list[str] = field(default_factory=list)
70
+ example: Any = None
71
+
72
+
73
+ @dataclass
74
+ class GraphQLType:
75
+ """A GraphQL type definition."""
76
+
77
+ name: str
78
+ kind: str # type, input, interface, enum, union, scalar
79
+ description: str | None = None
80
+ fields: list[dict[str, Any]] = field(default_factory=list)
81
+ values: list[str] | None = None # For enums
82
+ implements: list[str] = field(default_factory=list)
83
+
84
+
85
+ @dataclass
86
+ class ParsedAPISpec:
87
+ """Parsed API specification."""
88
+
89
+ doc_type: APIDocType
90
+ title: str
91
+ version: str | None = None
92
+ description: str | None = None
93
+ base_url: str | None = None
94
+ endpoints: list[APIEndpoint] = field(default_factory=list)
95
+ schemas: list[APISchema] = field(default_factory=list)
96
+ graphql_types: list[GraphQLType] = field(default_factory=list)
97
+ raw_spec: dict[str, Any] | str = field(default_factory=dict)
98
+
99
+
100
+ @dataclass
101
+ class APIDocConfig:
102
+ """Configuration for API documentation parsing."""
103
+
104
+ include_examples: bool = True
105
+ include_deprecated: bool = False
106
+ generate_markdown: bool = True
107
+ max_description_length: int = 2000
108
+
109
+
110
+ class APIDocParser:
111
+ """Parser for API documentation (OpenAPI, GraphQL).
112
+
113
+ Extracts structured information and converts to indexable documents.
114
+ """
115
+
116
+ def __init__(self, config: APIDocConfig | None = None) -> None:
117
+ """Initialize the API doc parser.
118
+
119
+ Args:
120
+ config: Parser configuration
121
+ """
122
+ self._config = config or APIDocConfig()
123
+
124
+ def detect_type(self, content: str) -> APIDocType:
125
+ """Detect the type of API documentation.
126
+
127
+ Args:
128
+ content: Raw content string
129
+
130
+ Returns:
131
+ Detected API documentation type
132
+ """
133
+ # Check for OpenAPI markers
134
+ if '"openapi"' in content or "openapi:" in content:
135
+ return APIDocType.OPENAPI
136
+ if '"swagger"' in content or "swagger:" in content:
137
+ return APIDocType.OPENAPI
138
+
139
+ # Check for GraphQL SDL markers
140
+ if re.search(r"^\s*(type|schema|query|mutation|subscription)\s+\w+", content, re.MULTILINE):
141
+ return APIDocType.GRAPHQL
142
+
143
+ return APIDocType.UNKNOWN
144
+
145
+ def parse(self, content: str, source_path: str | None = None) -> ParsedAPISpec:
146
+ """Parse API documentation content.
147
+
148
+ Args:
149
+ content: Raw content (JSON, YAML, or GraphQL SDL)
150
+ source_path: Optional source file path
151
+
152
+ Returns:
153
+ Parsed API specification
154
+ """
155
+ doc_type = self.detect_type(content)
156
+
157
+ if doc_type == APIDocType.OPENAPI:
158
+ return self._parse_openapi(content, source_path)
159
+ elif doc_type == APIDocType.GRAPHQL:
160
+ return self._parse_graphql(content, source_path)
161
+ else:
162
+ return ParsedAPISpec(
163
+ doc_type=APIDocType.UNKNOWN,
164
+ title="Unknown API",
165
+ raw_spec=content,
166
+ )
167
+
168
+ def parse_file(self, file_path: str | Path) -> ParsedAPISpec:
169
+ """Parse API documentation from a file.
170
+
171
+ Args:
172
+ file_path: Path to the API spec file
173
+
174
+ Returns:
175
+ Parsed API specification
176
+ """
177
+ path = Path(file_path)
178
+ content = path.read_text()
179
+ return self.parse(content, str(path))
180
+
181
+ def _parse_openapi(self, content: str, source_path: str | None = None) -> ParsedAPISpec:
182
+ """Parse OpenAPI specification.
183
+
184
+ Args:
185
+ content: JSON or YAML content
186
+ source_path: Source file path
187
+
188
+ Returns:
189
+ Parsed OpenAPI spec
190
+ """
191
+ # Try JSON first, then YAML
192
+ try:
193
+ spec = json.loads(content)
194
+ except json.JSONDecodeError:
195
+ try:
196
+ import yaml
197
+
198
+ spec = yaml.safe_load(content)
199
+ except Exception:
200
+ # Fallback: try to extract basic info with regex
201
+ return self._parse_openapi_fallback(content, source_path)
202
+
203
+ # Extract info
204
+ info = spec.get("info", {})
205
+ servers = spec.get("servers", [])
206
+ base_url = servers[0].get("url") if servers else None
207
+
208
+ # Parse endpoints
209
+ endpoints = []
210
+ paths = spec.get("paths", {})
211
+ for path, methods in paths.items():
212
+ for method, details in methods.items():
213
+ if method.upper() not in (
214
+ "GET",
215
+ "POST",
216
+ "PUT",
217
+ "DELETE",
218
+ "PATCH",
219
+ "HEAD",
220
+ "OPTIONS",
221
+ ):
222
+ continue
223
+
224
+ if details.get("deprecated", False) and not self._config.include_deprecated:
225
+ continue
226
+
227
+ # Parse parameters
228
+ params = []
229
+ for p in details.get("parameters", []):
230
+ params.append(
231
+ APIParameter(
232
+ name=p.get("name", ""),
233
+ location=p.get("in", "query"),
234
+ param_type=p.get("schema", {}).get("type", "string"),
235
+ required=p.get("required", False),
236
+ description=p.get("description"),
237
+ example=p.get("example") if self._config.include_examples else None,
238
+ )
239
+ )
240
+
241
+ endpoints.append(
242
+ APIEndpoint(
243
+ path=path,
244
+ method=method.upper(),
245
+ summary=details.get("summary"),
246
+ description=self._truncate(details.get("description")),
247
+ parameters=params,
248
+ request_body=details.get("requestBody"),
249
+ responses=details.get("responses", {}),
250
+ tags=details.get("tags", []),
251
+ deprecated=details.get("deprecated", False),
252
+ )
253
+ )
254
+
255
+ # Parse schemas
256
+ schemas = []
257
+ components = spec.get("components", {})
258
+ for name, schema_def in components.get("schemas", {}).items():
259
+ schemas.append(
260
+ APISchema(
261
+ name=name,
262
+ schema_type=schema_def.get("type", "object"),
263
+ description=self._truncate(schema_def.get("description")),
264
+ properties=schema_def.get("properties", {}),
265
+ required_fields=schema_def.get("required", []),
266
+ example=schema_def.get("example") if self._config.include_examples else None,
267
+ )
268
+ )
269
+
270
+ return ParsedAPISpec(
271
+ doc_type=APIDocType.OPENAPI,
272
+ title=info.get("title", "API"),
273
+ version=info.get("version"),
274
+ description=self._truncate(info.get("description")),
275
+ base_url=base_url,
276
+ endpoints=endpoints,
277
+ schemas=schemas,
278
+ raw_spec=spec,
279
+ )
280
+
281
+ def _parse_openapi_fallback(self, content: str, _source_path: str | None) -> ParsedAPISpec:
282
+ """Fallback OpenAPI parsing using regex.
283
+
284
+ Args:
285
+ content: Raw content
286
+ source_path: Source file path
287
+
288
+ Returns:
289
+ Basic parsed spec
290
+ """
291
+ # Extract title
292
+ title_match = re.search(r'"title"\s*:\s*"([^"]+)"', content)
293
+ title = title_match.group(1) if title_match else "API"
294
+
295
+ # Extract version
296
+ version_match = re.search(r'"version"\s*:\s*"([^"]+)"', content)
297
+ version = version_match.group(1) if version_match else None
298
+
299
+ # Extract paths (basic)
300
+ endpoints = []
301
+ path_pattern = r'"(/[^"]+)"\s*:\s*\{'
302
+ for match in re.finditer(path_pattern, content):
303
+ path = match.group(1)
304
+ # Look for methods after this path
305
+ for method in ["get", "post", "put", "delete", "patch"]:
306
+ if f'"{method}"' in content[match.start() : match.start() + 500]:
307
+ endpoints.append(
308
+ APIEndpoint(
309
+ path=path,
310
+ method=method.upper(),
311
+ )
312
+ )
313
+
314
+ return ParsedAPISpec(
315
+ doc_type=APIDocType.OPENAPI,
316
+ title=title,
317
+ version=version,
318
+ endpoints=endpoints,
319
+ raw_spec=content,
320
+ )
321
+
322
+ def _parse_graphql(self, content: str, _source_path: str | None = None) -> ParsedAPISpec:
323
+ """Parse GraphQL SDL schema.
324
+
325
+ Args:
326
+ content: GraphQL SDL content
327
+ source_path: Source file path
328
+
329
+ Returns:
330
+ Parsed GraphQL spec
331
+ """
332
+ types: list[GraphQLType] = []
333
+
334
+ # Parse type definitions
335
+ type_pattern = r'(?:"""([^"]*)"""\s*)?(type|input|interface|enum|union|scalar)\s+(\w+)(?:\s+implements\s+([\w\s&]+))?(?:\s*\{([^}]*)\})?'
336
+
337
+ for match in re.finditer(type_pattern, content, re.DOTALL):
338
+ description = match.group(1)
339
+ kind = match.group(2)
340
+ name = match.group(3)
341
+ implements = match.group(4)
342
+ body = match.group(5)
343
+
344
+ gql_type = GraphQLType(
345
+ name=name,
346
+ kind=kind,
347
+ description=self._truncate(description.strip()) if description else None,
348
+ implements=implements.split("&") if implements else [],
349
+ )
350
+
351
+ if kind == "enum" and body:
352
+ # Parse enum values
353
+ values = re.findall(r"(\w+)", body)
354
+ gql_type.values = values
355
+ elif body:
356
+ # Parse fields
357
+ field_pattern = r'(?:"""([^"]*)"""\s*)?(\w+)(?:\([^)]*\))?\s*:\s*([^\n!]+!?)'
358
+ for field_match in re.finditer(field_pattern, body):
359
+ gql_type.fields.append(
360
+ {
361
+ "description": field_match.group(1).strip()
362
+ if field_match.group(1)
363
+ else None,
364
+ "name": field_match.group(2),
365
+ "type": field_match.group(3).strip(),
366
+ }
367
+ )
368
+
369
+ types.append(gql_type)
370
+
371
+ # Extract schema title from comments or first type
372
+ title = "GraphQL API"
373
+ title_match = re.search(r"#\s*(.+)", content)
374
+ if title_match:
375
+ title = title_match.group(1).strip()
376
+
377
+ return ParsedAPISpec(
378
+ doc_type=APIDocType.GRAPHQL,
379
+ title=title,
380
+ graphql_types=types,
381
+ raw_spec=content,
382
+ )
383
+
384
+ def _truncate(self, text: str | None) -> str | None:
385
+ """Truncate text to max length.
386
+
387
+ Args:
388
+ text: Text to truncate
389
+
390
+ Returns:
391
+ Truncated text
392
+ """
393
+ if not text:
394
+ return text
395
+ if len(text) <= self._config.max_description_length:
396
+ return text
397
+ return text[: self._config.max_description_length - 3] + "..."
398
+
399
+ def to_documents(self, spec: ParsedAPISpec, source_path: str | None = None) -> list[Document]:
400
+ """Convert parsed spec to indexable documents.
401
+
402
+ Args:
403
+ spec: Parsed API specification
404
+ source_path: Source file path
405
+
406
+ Returns:
407
+ List of Document objects for indexing
408
+ """
409
+ documents: list[Document] = []
410
+ base_metadata = {
411
+ "doc_type": spec.doc_type.value,
412
+ "api_title": spec.title,
413
+ "api_version": spec.version,
414
+ "source_path": source_path,
415
+ }
416
+
417
+ if spec.doc_type == APIDocType.OPENAPI:
418
+ # Create document for each endpoint
419
+ for endpoint in spec.endpoints:
420
+ content = self._endpoint_to_markdown(endpoint, spec)
421
+ documents.append(
422
+ Document(
423
+ content=content,
424
+ content_type="api_endpoint",
425
+ source_path=source_path,
426
+ metadata={
427
+ **base_metadata,
428
+ "endpoint_path": endpoint.path,
429
+ "endpoint_method": endpoint.method,
430
+ "tags": endpoint.tags,
431
+ },
432
+ )
433
+ )
434
+
435
+ # Create document for each schema
436
+ for schema in spec.schemas:
437
+ content = self._schema_to_markdown(schema)
438
+ documents.append(
439
+ Document(
440
+ content=content,
441
+ content_type="api_schema",
442
+ source_path=source_path,
443
+ metadata={
444
+ **base_metadata,
445
+ "schema_name": schema.name,
446
+ "schema_type": schema.schema_type,
447
+ },
448
+ )
449
+ )
450
+
451
+ elif spec.doc_type == APIDocType.GRAPHQL:
452
+ # Create document for each GraphQL type
453
+ for gql_type in spec.graphql_types:
454
+ content = self._graphql_type_to_markdown(gql_type)
455
+ documents.append(
456
+ Document(
457
+ content=content,
458
+ content_type="graphql_type",
459
+ source_path=source_path,
460
+ metadata={
461
+ **base_metadata,
462
+ "type_name": gql_type.name,
463
+ "type_kind": gql_type.kind,
464
+ },
465
+ )
466
+ )
467
+
468
+ # Add overview document
469
+ if spec.description or spec.endpoints or spec.graphql_types:
470
+ overview = self._spec_to_overview(spec)
471
+ documents.insert(
472
+ 0,
473
+ Document(
474
+ content=overview,
475
+ content_type="api_overview",
476
+ source_path=source_path,
477
+ metadata={
478
+ **base_metadata,
479
+ "is_overview": True,
480
+ },
481
+ ),
482
+ )
483
+
484
+ return documents
485
+
486
+ def _endpoint_to_markdown(self, endpoint: APIEndpoint, _spec: ParsedAPISpec) -> str:
487
+ """Convert endpoint to markdown.
488
+
489
+ Args:
490
+ endpoint: API endpoint
491
+ spec: Full spec for context
492
+
493
+ Returns:
494
+ Markdown representation
495
+ """
496
+ parts = [f"## {endpoint.method} {endpoint.path}"]
497
+
498
+ if endpoint.summary:
499
+ parts.append(f"\n{endpoint.summary}")
500
+
501
+ if endpoint.description:
502
+ parts.append(f"\n{endpoint.description}")
503
+
504
+ if endpoint.tags:
505
+ parts.append(f"\n**Tags:** {', '.join(endpoint.tags)}")
506
+
507
+ if endpoint.deprecated:
508
+ parts.append("\n**DEPRECATED**")
509
+
510
+ if endpoint.parameters:
511
+ parts.append("\n### Parameters")
512
+ for param in endpoint.parameters:
513
+ required = " (required)" if param.required else ""
514
+ desc = f" - {param.description}" if param.description else ""
515
+ parts.append(
516
+ f"- `{param.name}` ({param.location}, {param.param_type}){required}{desc}"
517
+ )
518
+
519
+ if endpoint.request_body:
520
+ parts.append("\n### Request Body")
521
+ content = endpoint.request_body.get("content", {})
522
+ for media_type, details in content.items():
523
+ parts.append(f"**Content-Type:** {media_type}")
524
+ if "schema" in details:
525
+ schema_ref = details["schema"].get("$ref", "")
526
+ if schema_ref:
527
+ schema_name = schema_ref.split("/")[-1]
528
+ parts.append(f"**Schema:** {schema_name}")
529
+
530
+ if endpoint.responses:
531
+ parts.append("\n### Responses")
532
+ for status, details in endpoint.responses.items():
533
+ desc = details.get("description", "") if isinstance(details, dict) else ""
534
+ parts.append(f"- **{status}**: {desc}")
535
+
536
+ return "\n".join(parts)
537
+
538
+ def _schema_to_markdown(self, schema: APISchema) -> str:
539
+ """Convert schema to markdown.
540
+
541
+ Args:
542
+ schema: API schema
543
+
544
+ Returns:
545
+ Markdown representation
546
+ """
547
+ parts = [f"## Schema: {schema.name}"]
548
+
549
+ if schema.description:
550
+ parts.append(f"\n{schema.description}")
551
+
552
+ parts.append(f"\n**Type:** {schema.schema_type}")
553
+
554
+ if schema.properties:
555
+ parts.append("\n### Properties")
556
+ for prop_name, prop_details in schema.properties.items():
557
+ required = " (required)" if prop_name in schema.required_fields else ""
558
+ prop_type = (
559
+ prop_details.get("type", "any") if isinstance(prop_details, dict) else "any"
560
+ )
561
+ desc = prop_details.get("description", "") if isinstance(prop_details, dict) else ""
562
+ parts.append(f"- `{prop_name}` ({prop_type}){required}: {desc}")
563
+
564
+ if schema.example and self._config.include_examples:
565
+ parts.append("\n### Example")
566
+ parts.append(f"```json\n{json.dumps(schema.example, indent=2)}\n```")
567
+
568
+ return "\n".join(parts)
569
+
570
+ def _graphql_type_to_markdown(self, gql_type: GraphQLType) -> str:
571
+ """Convert GraphQL type to markdown.
572
+
573
+ Args:
574
+ gql_type: GraphQL type
575
+
576
+ Returns:
577
+ Markdown representation
578
+ """
579
+ parts = [f"## {gql_type.kind.title()}: {gql_type.name}"]
580
+
581
+ if gql_type.description:
582
+ parts.append(f"\n{gql_type.description}")
583
+
584
+ if gql_type.implements:
585
+ parts.append(f"\n**Implements:** {', '.join(gql_type.implements)}")
586
+
587
+ if gql_type.kind == "enum" and gql_type.values:
588
+ parts.append("\n### Values")
589
+ for value in gql_type.values:
590
+ parts.append(f"- `{value}`")
591
+ elif gql_type.fields:
592
+ parts.append("\n### Fields")
593
+ for field in gql_type.fields:
594
+ desc = f" - {field['description']}" if field.get("description") else ""
595
+ parts.append(f"- `{field['name']}`: {field['type']}{desc}")
596
+
597
+ return "\n".join(parts)
598
+
599
+ def _spec_to_overview(self, spec: ParsedAPISpec) -> str:
600
+ """Create overview document from spec.
601
+
602
+ Args:
603
+ spec: Parsed API spec
604
+
605
+ Returns:
606
+ Overview markdown
607
+ """
608
+ parts = [f"# {spec.title}"]
609
+
610
+ if spec.version:
611
+ parts.append(f"\n**Version:** {spec.version}")
612
+
613
+ if spec.description:
614
+ parts.append(f"\n{spec.description}")
615
+
616
+ if spec.base_url:
617
+ parts.append(f"\n**Base URL:** {spec.base_url}")
618
+
619
+ if spec.endpoints:
620
+ parts.append("\n## Endpoints")
621
+ for endpoint in spec.endpoints[:20]: # Limit for overview
622
+ parts.append(f"- `{endpoint.method} {endpoint.path}`: {endpoint.summary or ''}")
623
+
624
+ if spec.schemas:
625
+ parts.append("\n## Schemas")
626
+ for schema in spec.schemas[:20]:
627
+ parts.append(f"- `{schema.name}` ({schema.schema_type})")
628
+
629
+ if spec.graphql_types:
630
+ parts.append("\n## Types")
631
+ for gql_type in spec.graphql_types[:20]:
632
+ parts.append(f"- `{gql_type.name}` ({gql_type.kind})")
633
+
634
+ return "\n".join(parts)
635
+
636
+
637
+ def get_api_doc_parser(config: APIDocConfig | None = None) -> APIDocParser:
638
+ """Factory function to get an APIDocParser instance.
639
+
640
+ Args:
641
+ config: Optional parser configuration
642
+
643
+ Returns:
644
+ APIDocParser instance
645
+ """
646
+ return APIDocParser(config=config)