yamlgraph 0.3.9__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 (185) hide show
  1. examples/__init__.py +1 -0
  2. examples/codegen/__init__.py +5 -0
  3. examples/codegen/models/__init__.py +13 -0
  4. examples/codegen/models/schemas.py +76 -0
  5. examples/codegen/tests/__init__.py +1 -0
  6. examples/codegen/tests/test_ai_helpers.py +235 -0
  7. examples/codegen/tests/test_ast_analysis.py +174 -0
  8. examples/codegen/tests/test_code_analysis.py +134 -0
  9. examples/codegen/tests/test_code_context.py +301 -0
  10. examples/codegen/tests/test_code_nav.py +89 -0
  11. examples/codegen/tests/test_dependency_tools.py +119 -0
  12. examples/codegen/tests/test_example_tools.py +185 -0
  13. examples/codegen/tests/test_git_tools.py +112 -0
  14. examples/codegen/tests/test_impl_agent_schemas.py +193 -0
  15. examples/codegen/tests/test_impl_agent_v4_graph.py +94 -0
  16. examples/codegen/tests/test_jedi_analysis.py +226 -0
  17. examples/codegen/tests/test_meta_tools.py +250 -0
  18. examples/codegen/tests/test_plan_discovery_prompt.py +98 -0
  19. examples/codegen/tests/test_syntax_tools.py +85 -0
  20. examples/codegen/tests/test_synthesize_prompt.py +94 -0
  21. examples/codegen/tests/test_template_tools.py +244 -0
  22. examples/codegen/tools/__init__.py +80 -0
  23. examples/codegen/tools/ai_helpers.py +420 -0
  24. examples/codegen/tools/ast_analysis.py +92 -0
  25. examples/codegen/tools/code_context.py +180 -0
  26. examples/codegen/tools/code_nav.py +52 -0
  27. examples/codegen/tools/dependency_tools.py +120 -0
  28. examples/codegen/tools/example_tools.py +188 -0
  29. examples/codegen/tools/git_tools.py +151 -0
  30. examples/codegen/tools/impl_executor.py +614 -0
  31. examples/codegen/tools/jedi_analysis.py +311 -0
  32. examples/codegen/tools/meta_tools.py +202 -0
  33. examples/codegen/tools/syntax_tools.py +26 -0
  34. examples/codegen/tools/template_tools.py +356 -0
  35. examples/fastapi_interview.py +167 -0
  36. examples/npc/api/__init__.py +1 -0
  37. examples/npc/api/app.py +100 -0
  38. examples/npc/api/routes/__init__.py +5 -0
  39. examples/npc/api/routes/encounter.py +182 -0
  40. examples/npc/api/session.py +330 -0
  41. examples/npc/demo.py +387 -0
  42. examples/npc/nodes/__init__.py +5 -0
  43. examples/npc/nodes/image_node.py +92 -0
  44. examples/npc/run_encounter.py +230 -0
  45. examples/shared/__init__.py +0 -0
  46. examples/shared/replicate_tool.py +238 -0
  47. examples/storyboard/__init__.py +1 -0
  48. examples/storyboard/generate_videos.py +335 -0
  49. examples/storyboard/nodes/__init__.py +12 -0
  50. examples/storyboard/nodes/animated_character_node.py +248 -0
  51. examples/storyboard/nodes/animated_image_node.py +138 -0
  52. examples/storyboard/nodes/character_node.py +162 -0
  53. examples/storyboard/nodes/image_node.py +118 -0
  54. examples/storyboard/nodes/replicate_tool.py +49 -0
  55. examples/storyboard/retry_images.py +118 -0
  56. scripts/demo_async_executor.py +212 -0
  57. scripts/demo_interview_e2e.py +200 -0
  58. scripts/demo_streaming.py +140 -0
  59. scripts/run_interview_demo.py +94 -0
  60. scripts/test_interrupt_fix.py +26 -0
  61. tests/__init__.py +1 -0
  62. tests/conftest.py +178 -0
  63. tests/integration/__init__.py +1 -0
  64. tests/integration/test_animated_storyboard.py +63 -0
  65. tests/integration/test_cli_commands.py +242 -0
  66. tests/integration/test_colocated_prompts.py +139 -0
  67. tests/integration/test_map_demo.py +50 -0
  68. tests/integration/test_memory_demo.py +283 -0
  69. tests/integration/test_npc_api/__init__.py +1 -0
  70. tests/integration/test_npc_api/test_routes.py +357 -0
  71. tests/integration/test_npc_api/test_session.py +216 -0
  72. tests/integration/test_pipeline_flow.py +105 -0
  73. tests/integration/test_providers.py +163 -0
  74. tests/integration/test_resume.py +75 -0
  75. tests/integration/test_subgraph_integration.py +295 -0
  76. tests/integration/test_subgraph_interrupt.py +106 -0
  77. tests/unit/__init__.py +1 -0
  78. tests/unit/test_agent_nodes.py +355 -0
  79. tests/unit/test_async_executor.py +346 -0
  80. tests/unit/test_checkpointer.py +212 -0
  81. tests/unit/test_checkpointer_factory.py +212 -0
  82. tests/unit/test_cli.py +121 -0
  83. tests/unit/test_cli_package.py +81 -0
  84. tests/unit/test_compile_graph_map.py +132 -0
  85. tests/unit/test_conditions_routing.py +253 -0
  86. tests/unit/test_config.py +93 -0
  87. tests/unit/test_conversation_memory.py +276 -0
  88. tests/unit/test_database.py +145 -0
  89. tests/unit/test_deprecation.py +104 -0
  90. tests/unit/test_executor.py +172 -0
  91. tests/unit/test_executor_async.py +179 -0
  92. tests/unit/test_export.py +149 -0
  93. tests/unit/test_expressions.py +178 -0
  94. tests/unit/test_feature_brainstorm.py +194 -0
  95. tests/unit/test_format_prompt.py +145 -0
  96. tests/unit/test_generic_report.py +200 -0
  97. tests/unit/test_graph_commands.py +327 -0
  98. tests/unit/test_graph_linter.py +627 -0
  99. tests/unit/test_graph_loader.py +357 -0
  100. tests/unit/test_graph_schema.py +193 -0
  101. tests/unit/test_inline_schema.py +151 -0
  102. tests/unit/test_interrupt_node.py +182 -0
  103. tests/unit/test_issues.py +164 -0
  104. tests/unit/test_jinja2_prompts.py +85 -0
  105. tests/unit/test_json_extract.py +134 -0
  106. tests/unit/test_langsmith.py +600 -0
  107. tests/unit/test_langsmith_tools.py +204 -0
  108. tests/unit/test_llm_factory.py +109 -0
  109. tests/unit/test_llm_factory_async.py +118 -0
  110. tests/unit/test_loops.py +403 -0
  111. tests/unit/test_map_node.py +144 -0
  112. tests/unit/test_no_backward_compat.py +56 -0
  113. tests/unit/test_node_factory.py +348 -0
  114. tests/unit/test_passthrough_node.py +126 -0
  115. tests/unit/test_prompts.py +324 -0
  116. tests/unit/test_python_nodes.py +198 -0
  117. tests/unit/test_reliability.py +298 -0
  118. tests/unit/test_result_export.py +234 -0
  119. tests/unit/test_router.py +296 -0
  120. tests/unit/test_sanitize.py +99 -0
  121. tests/unit/test_schema_loader.py +295 -0
  122. tests/unit/test_shell_tools.py +229 -0
  123. tests/unit/test_state_builder.py +331 -0
  124. tests/unit/test_state_builder_map.py +104 -0
  125. tests/unit/test_state_config.py +197 -0
  126. tests/unit/test_streaming.py +307 -0
  127. tests/unit/test_subgraph.py +596 -0
  128. tests/unit/test_template.py +190 -0
  129. tests/unit/test_tool_call_integration.py +164 -0
  130. tests/unit/test_tool_call_node.py +178 -0
  131. tests/unit/test_tool_nodes.py +129 -0
  132. tests/unit/test_websearch.py +234 -0
  133. yamlgraph/__init__.py +35 -0
  134. yamlgraph/builder.py +110 -0
  135. yamlgraph/cli/__init__.py +159 -0
  136. yamlgraph/cli/__main__.py +6 -0
  137. yamlgraph/cli/commands.py +231 -0
  138. yamlgraph/cli/deprecation.py +92 -0
  139. yamlgraph/cli/graph_commands.py +541 -0
  140. yamlgraph/cli/validators.py +37 -0
  141. yamlgraph/config.py +67 -0
  142. yamlgraph/constants.py +70 -0
  143. yamlgraph/error_handlers.py +227 -0
  144. yamlgraph/executor.py +290 -0
  145. yamlgraph/executor_async.py +288 -0
  146. yamlgraph/graph_loader.py +451 -0
  147. yamlgraph/map_compiler.py +150 -0
  148. yamlgraph/models/__init__.py +36 -0
  149. yamlgraph/models/graph_schema.py +181 -0
  150. yamlgraph/models/schemas.py +124 -0
  151. yamlgraph/models/state_builder.py +236 -0
  152. yamlgraph/node_factory.py +768 -0
  153. yamlgraph/routing.py +87 -0
  154. yamlgraph/schema_loader.py +240 -0
  155. yamlgraph/storage/__init__.py +20 -0
  156. yamlgraph/storage/checkpointer.py +72 -0
  157. yamlgraph/storage/checkpointer_factory.py +123 -0
  158. yamlgraph/storage/database.py +320 -0
  159. yamlgraph/storage/export.py +269 -0
  160. yamlgraph/tools/__init__.py +1 -0
  161. yamlgraph/tools/agent.py +320 -0
  162. yamlgraph/tools/graph_linter.py +388 -0
  163. yamlgraph/tools/langsmith_tools.py +125 -0
  164. yamlgraph/tools/nodes.py +126 -0
  165. yamlgraph/tools/python_tool.py +179 -0
  166. yamlgraph/tools/shell.py +205 -0
  167. yamlgraph/tools/websearch.py +242 -0
  168. yamlgraph/utils/__init__.py +48 -0
  169. yamlgraph/utils/conditions.py +157 -0
  170. yamlgraph/utils/expressions.py +245 -0
  171. yamlgraph/utils/json_extract.py +104 -0
  172. yamlgraph/utils/langsmith.py +416 -0
  173. yamlgraph/utils/llm_factory.py +118 -0
  174. yamlgraph/utils/llm_factory_async.py +105 -0
  175. yamlgraph/utils/logging.py +104 -0
  176. yamlgraph/utils/prompts.py +171 -0
  177. yamlgraph/utils/sanitize.py +98 -0
  178. yamlgraph/utils/template.py +102 -0
  179. yamlgraph/utils/validators.py +181 -0
  180. yamlgraph-0.3.9.dist-info/METADATA +1105 -0
  181. yamlgraph-0.3.9.dist-info/RECORD +185 -0
  182. yamlgraph-0.3.9.dist-info/WHEEL +5 -0
  183. yamlgraph-0.3.9.dist-info/entry_points.txt +2 -0
  184. yamlgraph-0.3.9.dist-info/licenses/LICENSE +33 -0
  185. yamlgraph-0.3.9.dist-info/top_level.txt +4 -0
@@ -0,0 +1,52 @@
1
+ """Code navigation tools for finding relevant files.
2
+
3
+ Provides package-level module discovery and search capabilities.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+ from pathlib import Path
10
+
11
+ from examples.codegen.tools.ast_analysis import get_module_structure
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ def list_package_modules(package_path: str) -> list[dict]:
17
+ """List all Python modules in a package with high-level summaries.
18
+
19
+ Args:
20
+ package_path: Path to package directory
21
+
22
+ Returns:
23
+ List of module summaries with file, docstring, classes, functions.
24
+ Returns empty list if path doesn't exist or has no Python files.
25
+ """
26
+ path = Path(package_path)
27
+ if not path.exists():
28
+ return []
29
+
30
+ results = []
31
+ for py_file in sorted(path.rglob("*.py")):
32
+ # Skip __pycache__ directories
33
+ if "__pycache__" in str(py_file):
34
+ continue
35
+
36
+ structure = get_module_structure(str(py_file))
37
+
38
+ # Skip files with errors (syntax errors, etc.)
39
+ if "error" in structure:
40
+ logger.warning(f"Skipping {py_file}: {structure['error']}")
41
+ continue
42
+
43
+ results.append(
44
+ {
45
+ "file": str(py_file),
46
+ "docstring": structure.get("docstring"),
47
+ "classes": [c["name"] for c in structure.get("classes", [])],
48
+ "functions": [f["name"] for f in structure.get("functions", [])],
49
+ }
50
+ )
51
+
52
+ return results
@@ -0,0 +1,120 @@
1
+ """Dependency analysis tools for implementation agent.
2
+
3
+ Provides import analysis and reverse dependency tracking.
4
+ """
5
+
6
+ import ast
7
+ import subprocess
8
+ from pathlib import Path
9
+
10
+
11
+ def get_imports(file_path: str) -> dict:
12
+ """Extract all imports from a Python file.
13
+
14
+ Args:
15
+ file_path: Path to the Python file
16
+
17
+ Returns:
18
+ dict with 'imports' list, each containing:
19
+ - module: The imported module name
20
+ - names: List of imported names (None for 'import X')
21
+ - alias: Alias if 'as' was used (optional)
22
+ or dict with error key if failed
23
+ """
24
+ path = Path(file_path)
25
+ if not path.exists():
26
+ return {"error": f"File not found: {file_path}"}
27
+
28
+ try:
29
+ source = path.read_text()
30
+ tree = ast.parse(source)
31
+ except SyntaxError as e:
32
+ return {"error": f"Syntax error in {file_path}: {e}"}
33
+
34
+ imports = []
35
+
36
+ for node in ast.walk(tree):
37
+ if isinstance(node, ast.Import):
38
+ # import X, Y, Z
39
+ for alias in node.names:
40
+ imports.append(
41
+ {
42
+ "module": alias.name,
43
+ "names": None,
44
+ "alias": alias.asname,
45
+ }
46
+ )
47
+ elif isinstance(node, ast.ImportFrom):
48
+ # from X import Y, Z
49
+ module = node.module or ""
50
+ names = []
51
+ for alias in node.names:
52
+ name_info = {"name": alias.name}
53
+ if alias.asname:
54
+ name_info["alias"] = alias.asname
55
+ names.append(name_info)
56
+ imports.append(
57
+ {
58
+ "module": module,
59
+ "names": names,
60
+ "level": node.level, # Relative import level (0 = absolute)
61
+ }
62
+ )
63
+
64
+ return {"imports": imports}
65
+
66
+
67
+ def get_dependents(module_path: str, project_path: str) -> dict:
68
+ """Find all files that import a given module.
69
+
70
+ Args:
71
+ module_path: Module path to search for (e.g., 'yamlgraph.executor')
72
+ project_path: Root path of the project to search in
73
+
74
+ Returns:
75
+ dict with 'dependents' list of file paths that import the module
76
+ or dict with error key if failed
77
+ """
78
+ path = Path(project_path)
79
+ if not path.exists():
80
+ return {"error": f"Project path not found: {project_path}"}
81
+
82
+ # Convert module path to patterns for grep
83
+ # e.g., 'yamlgraph.executor' -> search for 'from yamlgraph.executor' or 'import yamlgraph.executor'
84
+ full_module = module_path
85
+
86
+ # Build grep patterns
87
+ patterns = [
88
+ f"from {full_module} import",
89
+ f"from {full_module}\\b",
90
+ f"import {full_module}\\b",
91
+ ]
92
+
93
+ # For single-word modules like 'logging', also search simpler patterns
94
+ if "." not in module_path:
95
+ patterns = [
96
+ f"import {module_path}\\b",
97
+ f"from {module_path} import",
98
+ ]
99
+
100
+ dependents = set()
101
+
102
+ for pattern in patterns:
103
+ try:
104
+ result = subprocess.run(
105
+ ["grep", "-rl", "-E", pattern, "--include=*.py", str(path)],
106
+ capture_output=True,
107
+ text=True,
108
+ timeout=30,
109
+ )
110
+ # grep returns 1 if no matches (not an error)
111
+ if result.returncode == 0 and result.stdout.strip():
112
+ for file_path in result.stdout.strip().split("\n"):
113
+ if file_path.strip():
114
+ dependents.add(file_path.strip())
115
+ except subprocess.TimeoutExpired:
116
+ return {"error": "Search timed out"}
117
+ except Exception as e:
118
+ return {"error": str(e)}
119
+
120
+ return {"dependents": sorted(dependents)}
@@ -0,0 +1,188 @@
1
+ """Example discovery tools for implementation agent.
2
+
3
+ Find usage examples and error handling patterns across the codebase.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import ast
9
+ from pathlib import Path
10
+
11
+
12
+ def find_example(
13
+ symbol_name: str,
14
+ project_path: str,
15
+ max_examples: int = 3,
16
+ ) -> dict:
17
+ """Find real usage examples of a function/class.
18
+
19
+ Prioritizes:
20
+ 1. Test files (most complete examples)
21
+ 2. Main modules (production usage)
22
+ 3. Examples folder
23
+
24
+ Args:
25
+ symbol_name: Name of the symbol to find examples for
26
+ project_path: Root path to search
27
+ max_examples: Maximum examples to return
28
+
29
+ Returns:
30
+ dict with 'examples' list of {file, line, snippet}
31
+ or dict with 'error' key if failed
32
+ """
33
+ project = Path(project_path)
34
+ if not project.exists():
35
+ return {"error": f"Project path not found: {project_path}"}
36
+
37
+ examples = []
38
+ test_examples = []
39
+ other_examples = []
40
+
41
+ # Search all Python files
42
+ for py_file in project.rglob("*.py"):
43
+ try:
44
+ source = py_file.read_text()
45
+ except (OSError, UnicodeDecodeError):
46
+ continue
47
+
48
+ # Skip if symbol is defined here (we want usages, not definitions)
49
+ if _is_definition(source, symbol_name):
50
+ continue
51
+
52
+ # Find all occurrences
53
+ lines = source.splitlines()
54
+ for i, line in enumerate(lines, 1):
55
+ if symbol_name in line and _is_usage(line, symbol_name):
56
+ # Extract context (3 lines before and after)
57
+ start = max(0, i - 3)
58
+ end = min(len(lines), i + 3)
59
+ snippet = "\n".join(lines[start:end])
60
+
61
+ example = {
62
+ "file": str(py_file),
63
+ "line": i,
64
+ "snippet": snippet,
65
+ }
66
+
67
+ # Prioritize test files
68
+ if "test" in py_file.name.lower():
69
+ test_examples.append(example)
70
+ else:
71
+ other_examples.append(example)
72
+
73
+ # Combine: tests first, then others
74
+ examples = test_examples + other_examples
75
+
76
+ return {"examples": examples[:max_examples]}
77
+
78
+
79
+ def _is_definition(source: str, symbol_name: str) -> bool:
80
+ """Check if this file defines the symbol."""
81
+ try:
82
+ tree = ast.parse(source)
83
+ except SyntaxError:
84
+ return False
85
+
86
+ for node in ast.walk(tree):
87
+ if (
88
+ isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef)
89
+ and node.name == symbol_name
90
+ ):
91
+ return True
92
+
93
+ return False
94
+
95
+
96
+ def _is_usage(line: str, symbol_name: str) -> bool:
97
+ """Check if line contains actual usage (not import or def)."""
98
+ stripped = line.strip()
99
+
100
+ # Skip definitions
101
+ if stripped.startswith("def ") and f"def {symbol_name}" in stripped:
102
+ return False
103
+ if stripped.startswith("class ") and f"class {symbol_name}" in stripped:
104
+ return False
105
+
106
+ # Skip import-only lines
107
+ if stripped.startswith("from ") and stripped.endswith(f"import {symbol_name}"):
108
+ return False
109
+
110
+ # Must be a call or reference
111
+ return f"{symbol_name}(" in line or f"{symbol_name}." in line
112
+
113
+
114
+ def find_error_handling(project_path: str) -> dict:
115
+ """Analyze error handling patterns in a project.
116
+
117
+ Args:
118
+ project_path: Root path to analyze
119
+
120
+ Returns:
121
+ dict with:
122
+ - exceptions: List of exception types used
123
+ - patterns: List of pattern names found
124
+ - logging: Dict with logging info
125
+ or dict with 'error' key if failed
126
+ """
127
+ project = Path(project_path)
128
+ if not project.exists():
129
+ return {"error": f"Project path not found: {project_path}"}
130
+
131
+ exceptions: set[str] = set()
132
+ patterns: list[str] = []
133
+ uses_logging = False
134
+ uses_dict_error = False
135
+
136
+ for py_file in project.rglob("*.py"):
137
+ try:
138
+ source = py_file.read_text()
139
+ except (OSError, UnicodeDecodeError):
140
+ continue
141
+
142
+ try:
143
+ tree = ast.parse(source)
144
+ except SyntaxError:
145
+ continue
146
+
147
+ # Find exception types
148
+ for node in ast.walk(tree):
149
+ if isinstance(node, ast.ExceptHandler) and node.type:
150
+ exc_types = _extract_exception_types(node.type)
151
+ exceptions.update(exc_types)
152
+
153
+ # Check for dict error pattern
154
+ if 'return {"error"' in source or "return {'error'" in source:
155
+ uses_dict_error = True
156
+
157
+ # Check for logging
158
+ if "logger.error" in source or "logging.error" in source:
159
+ uses_logging = True
160
+
161
+ # Build patterns list
162
+ if uses_dict_error:
163
+ patterns.append("dict_error")
164
+ if exceptions:
165
+ patterns.append("try_except")
166
+
167
+ return {
168
+ "exceptions": sorted(exceptions),
169
+ "patterns": patterns,
170
+ "logging": {"uses_logging": uses_logging},
171
+ }
172
+
173
+
174
+ def _extract_exception_types(node: ast.expr) -> list[str]:
175
+ """Extract exception type names from except clause."""
176
+ types = []
177
+
178
+ if isinstance(node, ast.Name):
179
+ types.append(node.id)
180
+ elif isinstance(node, ast.Tuple):
181
+ for elt in node.elts:
182
+ if isinstance(elt, ast.Name):
183
+ types.append(elt.id)
184
+ elif isinstance(node, ast.Attribute):
185
+ # e.g., requests.exceptions.Timeout
186
+ types.append(node.attr)
187
+
188
+ return types
@@ -0,0 +1,151 @@
1
+ """Git analysis tools for implementation agent.
2
+
3
+ Provides git blame and git log information for code context.
4
+ """
5
+
6
+ import subprocess
7
+ from pathlib import Path
8
+
9
+
10
+ def git_blame(file_path: str, line: int) -> dict:
11
+ """Get blame info for a specific line.
12
+
13
+ Args:
14
+ file_path: Path to the file (relative or absolute)
15
+ line: Line number (1-indexed)
16
+
17
+ Returns:
18
+ dict with author, date, commit, summary, line_content
19
+ or dict with error key if failed
20
+ """
21
+ path = Path(file_path)
22
+ if not path.exists():
23
+ return {"error": f"File not found: {file_path}"}
24
+
25
+ try:
26
+ # Use porcelain format for machine-readable output
27
+ result = subprocess.run(
28
+ ["git", "blame", "-L", f"{line},{line}", "--porcelain", str(path)],
29
+ capture_output=True,
30
+ text=True,
31
+ timeout=10,
32
+ )
33
+
34
+ if result.returncode != 0:
35
+ return {
36
+ "error": result.stderr.strip()
37
+ or f"git blame failed for {file_path}:{line}"
38
+ }
39
+
40
+ # Parse porcelain output
41
+ lines = result.stdout.strip().split("\n")
42
+ if not lines or len(lines) < 2:
43
+ return {"error": f"Invalid line number: {line}"}
44
+
45
+ # First line is commit hash and original line number
46
+ first_line = lines[0].split()
47
+ if not first_line:
48
+ return {"error": "Failed to parse git blame output"}
49
+
50
+ commit = first_line[0]
51
+
52
+ # Parse the rest of the porcelain output
53
+ author = ""
54
+ date = ""
55
+ summary = ""
56
+ line_content = ""
57
+
58
+ for output_line in lines[1:]:
59
+ if output_line.startswith("author "):
60
+ author = output_line[7:]
61
+ elif output_line.startswith("author-time "):
62
+ # Convert Unix timestamp to readable format
63
+ import datetime
64
+
65
+ timestamp = int(output_line[12:])
66
+ date = datetime.datetime.fromtimestamp(timestamp).strftime(
67
+ "%Y-%m-%d %H:%M"
68
+ )
69
+ elif output_line.startswith("summary "):
70
+ summary = output_line[8:]
71
+ elif output_line.startswith("\t"):
72
+ # Actual line content starts with tab
73
+ line_content = output_line[1:]
74
+
75
+ return {
76
+ "commit": commit[:8], # Short hash
77
+ "author": author,
78
+ "date": date,
79
+ "summary": summary,
80
+ "line_content": line_content,
81
+ }
82
+
83
+ except subprocess.TimeoutExpired:
84
+ return {"error": "git blame timed out"}
85
+ except Exception as e:
86
+ return {"error": str(e)}
87
+
88
+
89
+ def git_log(file_path: str, n: int = 5) -> dict:
90
+ """Get recent commits for a file.
91
+
92
+ Args:
93
+ file_path: Path to the file (relative or absolute)
94
+ n: Maximum number of commits to return (default 5)
95
+
96
+ Returns:
97
+ dict with commits list, each containing hash, author, date, message
98
+ or dict with error key if failed
99
+ """
100
+ path = Path(file_path)
101
+ if not path.exists():
102
+ return {"error": f"File not found: {file_path}"}
103
+
104
+ try:
105
+ # Use format for clean parsing
106
+ result = subprocess.run(
107
+ [
108
+ "git",
109
+ "log",
110
+ f"-{n}",
111
+ "--format=%H|%an|%ai|%s",
112
+ "--follow",
113
+ "--",
114
+ str(path),
115
+ ],
116
+ capture_output=True,
117
+ text=True,
118
+ timeout=10,
119
+ )
120
+
121
+ if result.returncode != 0:
122
+ error = result.stderr.strip()
123
+ if "does not have any commits" in error or not result.stdout.strip():
124
+ return {"error": f"No git history for {file_path}"}
125
+ return {"error": error or f"git log failed for {file_path}"}
126
+
127
+ output = result.stdout.strip()
128
+ if not output:
129
+ return {"error": f"No git history for {file_path}"}
130
+
131
+ commits = []
132
+ for line in output.split("\n"):
133
+ if not line.strip():
134
+ continue
135
+ parts = line.split("|", 3)
136
+ if len(parts) >= 4:
137
+ commits.append(
138
+ {
139
+ "hash": parts[0][:8], # Short hash
140
+ "author": parts[1],
141
+ "date": parts[2][:10], # Just the date part
142
+ "message": parts[3],
143
+ }
144
+ )
145
+
146
+ return {"commits": commits}
147
+
148
+ except subprocess.TimeoutExpired:
149
+ return {"error": "git log timed out"}
150
+ except Exception as e:
151
+ return {"error": str(e)}