pysealer 0.6.0__cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.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.
pysealer/cli.py ADDED
@@ -0,0 +1,367 @@
1
+ """
2
+ Command-line interface for pysealer.
3
+
4
+ Commands:
5
+ - init: Initialize pysealer with a new keypair and .env file.
6
+ - lock: Add pysealer decorators to all functions and classes in a Python file.
7
+ - check: Check the integrity and validity of pysealer decorators in a Python file.
8
+ - remove: Remove all pysealer decorators from a Python file.
9
+
10
+ Use `pysealer --help` to see available options and command details.
11
+ Use `pysealer --version` to see the current version of pysealer installed.
12
+ """
13
+
14
+ from pathlib import Path
15
+
16
+ import typer
17
+ from typing_extensions import Annotated
18
+
19
+ from . import __version__
20
+ from .setup import setup_keypair
21
+ from .add_decorators import add_decorators, add_decorators_to_folder
22
+ from .check_decorators import check_decorators, check_decorators_in_folder
23
+ from .remove_decorators import remove_decorators, remove_decorators_from_folder
24
+ from .git_diff import is_git_available
25
+
26
+ app = typer.Typer(
27
+ name="pysealer",
28
+ help="Version control your Python functions and classes with cryptographic decorators",
29
+ no_args_is_help=True,
30
+ )
31
+
32
+
33
+ def _format_diff_output(func_name: str, diff_lines):
34
+ """Format and display git diff with color coding."""
35
+ if not diff_lines:
36
+ return
37
+ typer.echo(typer.style(f" Function '{func_name}' was modified:", fg=typer.colors.RED, bold=True))
38
+
39
+ for diff_type, content, line_num in diff_lines:
40
+ # Format line with appropriate styling
41
+ if diff_type == '-':
42
+ # Deletions in red
43
+ line_str = f" {line_num:<4}{typer.style('-', fg=typer.colors.RED)}{typer.style(content, fg=typer.colors.RED)}"
44
+ elif diff_type == '+':
45
+ # Additions in green
46
+ line_str = f" {line_num:<4}{typer.style('+', fg=typer.colors.GREEN)}{typer.style(content, fg=typer.colors.GREEN)}"
47
+ else:
48
+ # Context lines in dim/default color
49
+ line_str = f" {line_num:<4} {content}"
50
+
51
+ typer.echo(line_str)
52
+
53
+
54
+ def version_callback(value: bool):
55
+ """Helper function to display version information."""
56
+ if value:
57
+ typer.echo(f"pysealer {__version__}")
58
+ raise typer.Exit()
59
+
60
+
61
+ @app.callback()
62
+ def version(
63
+ version: Annotated[
64
+ bool,
65
+ typer.Option("--version", help="Report the current version of pysealer installed.", callback=version_callback, is_eager=True)
66
+ ] = False
67
+ ):
68
+ """Report the current version of pysealer installed."""
69
+ pass
70
+
71
+
72
+ @app.command()
73
+ def init(
74
+ env_file: Annotated[
75
+ str,
76
+ typer.Argument(help="Path to the .env file")
77
+ ] = ".env",
78
+ github_token: Annotated[
79
+ str,
80
+ typer.Option("--github-token", help="GitHub personal access token for uploading public key to repository secrets.")
81
+ ] = None
82
+ ):
83
+ """Initialize pysealer with an .env file and optionally upload public key to GitHub."""
84
+ try:
85
+ env_path = Path(env_file)
86
+
87
+ # Generate and store keypair (will raise error if keys already exist)
88
+ public_key, private_key = setup_keypair(env_path)
89
+ typer.echo(typer.style("Successfully initialized pysealer!", fg=typer.colors.BLUE, bold=True))
90
+ typer.echo(f"🔑 Keypair generated and stored in {env_path}")
91
+ typer.echo("🔍 Keep your .env file secure and add it to .gitignore")
92
+
93
+ # GitHub secrets integration (optional, only if token provided)
94
+ if github_token:
95
+ typer.echo(typer.style("Attempting to upload public key to GitHub repository secrets...", fg=typer.colors.BLUE, bold=True))
96
+ try:
97
+ from .github_secrets import setup_github_secrets
98
+
99
+ success, message = setup_github_secrets(public_key, github_token)
100
+
101
+ if success:
102
+ typer.echo(typer.style(f"✓ {message}", fg=typer.colors.GREEN))
103
+ else:
104
+ typer.echo(typer.style(f"⚠️ Warning: {message}", fg=typer.colors.YELLOW))
105
+ typer.echo(" You can manually add the PYSEALER_PUBLIC_KEY to GitHub secrets later.")
106
+
107
+ except ImportError as e:
108
+ typer.echo(typer.style(f"⚠️ Warning: GitHub integration dependencies not installed: {e}", fg=typer.colors.YELLOW))
109
+ except Exception as e:
110
+ typer.echo(typer.style(f"⚠️ Warning: Failed to upload to GitHub: {e}", fg=typer.colors.YELLOW))
111
+ typer.echo(" You can manually add the PYSEALER_PUBLIC_KEY to GitHub secrets later.")
112
+
113
+ except Exception as e:
114
+ typer.echo(typer.style(f"Error during initialization: {e}", fg=typer.colors.RED, bold=True), err=True)
115
+ raise typer.Exit(code=1)
116
+
117
+ @app.command()
118
+ def lock(
119
+ file_path: Annotated[
120
+ str,
121
+ typer.Argument(help="Path to the Python file or folder to decorate")
122
+ ]
123
+ ):
124
+ """Add decorators to all functions and classes in a Python file or all Python files in a folder."""
125
+ path = Path(file_path)
126
+
127
+ # Validate path exists
128
+ if not path.exists():
129
+ typer.echo(typer.style(f"Error: Path '{path}' does not exist.", fg=typer.colors.RED, bold=True), err=True)
130
+ raise typer.Exit(code=1)
131
+
132
+ # Validate it's a Python file or directory
133
+ if path.is_file() and path.suffix != '.py':
134
+ typer.echo(typer.style(f"Error: File '{path}' is not a Python file.", fg=typer.colors.RED, bold=True), err=True)
135
+ raise typer.Exit(code=1)
136
+
137
+ try:
138
+ # Handle folder path
139
+ if path.is_dir():
140
+ resolved_path = str(path.resolve())
141
+ decorated_files = add_decorators_to_folder(resolved_path)
142
+
143
+ file_word = "file" if len(decorated_files) == 1 else "files"
144
+ typer.echo(typer.style(f"Successfully added decorators to {len(decorated_files)} {file_word}:", fg=typer.colors.BLUE, bold=True))
145
+ for file in decorated_files:
146
+ typer.echo(f" {typer.style('✓', fg=typer.colors.GREEN)} {file}")
147
+
148
+ # Handle file path
149
+ else:
150
+
151
+ # Add decorators to all functions and classes in the file
152
+ resolved_path = str(path.resolve())
153
+ modified_code, has_changes = add_decorators(resolved_path)
154
+
155
+ if has_changes:
156
+ # Write the modified code back to the file
157
+ with open(resolved_path, 'w') as f:
158
+ f.write(modified_code)
159
+
160
+ typer.echo(typer.style(f"Successfully added decorators to 1 file:", fg=typer.colors.BLUE, bold=True))
161
+ typer.echo(f" {typer.style('✓', fg=typer.colors.GREEN)} {resolved_path}")
162
+ else:
163
+ typer.echo(typer.style(f"No functions or classes found in file:", fg=typer.colors.YELLOW, bold=True))
164
+ typer.echo(f" {typer.style('⊘', fg=typer.colors.YELLOW)} {resolved_path}")
165
+
166
+ except (RuntimeError, FileNotFoundError, NotADirectoryError, ValueError) as e:
167
+ typer.echo(typer.style(f"Error: {e}", fg=typer.colors.RED, bold=True), err=True)
168
+ raise typer.Exit(code=1)
169
+ except Exception as e:
170
+ typer.echo(typer.style(f"Unexpected error while locking file: {e}", fg=typer.colors.RED, bold=True), err=True)
171
+ raise typer.Exit(code=1)
172
+
173
+
174
+ @app.command()
175
+ def check(
176
+ file_path: Annotated[
177
+ str,
178
+ typer.Argument(help="Path to the Python file or folder to check")
179
+ ]
180
+ ):
181
+ """Check the integrity of decorators in a Python file or all Python files in a folder."""
182
+ path = Path(file_path)
183
+
184
+ # Validate path exists
185
+ if not path.exists():
186
+ typer.echo(typer.style(f"Error: Path '{path}' does not exist.", fg=typer.colors.RED, bold=True), err=True)
187
+ raise typer.Exit(code=1)
188
+
189
+ # Validate it's a Python file or directory
190
+ if path.is_file() and path.suffix != '.py':
191
+ typer.echo(typer.style(f"Error: File '{path}' is not a Python file.", fg=typer.colors.RED, bold=True), err=True)
192
+ raise typer.Exit(code=1)
193
+
194
+ try:
195
+ # Check if git is available for diff output
196
+ git_available = is_git_available()
197
+ if not git_available:
198
+ typer.echo(typer.style("Note: Git not available - diff output will not be shown for invalid signatures.", fg=typer.colors.YELLOW))
199
+ typer.echo()
200
+
201
+ # Handle folder path
202
+ if path.is_dir():
203
+ resolved_path = str(path.resolve())
204
+ all_results = check_decorators_in_folder(resolved_path)
205
+
206
+ total_decorated = 0
207
+ total_valid = 0
208
+ files_with_decorators = []
209
+ files_with_issues = []
210
+
211
+ for file_path, results in all_results.items():
212
+ # Skip files with errors
213
+ if "error" in results:
214
+ typer.echo(typer.style(f"✗ {file_path}: {results['error']}", fg=typer.colors.RED))
215
+ files_with_issues.append(file_path)
216
+ continue
217
+
218
+ decorated_count = sum(1 for r in results.values() if r["has_decorator"])
219
+
220
+ # Only count files that have decorators
221
+ if decorated_count > 0:
222
+ valid_count = sum(1 for r in results.values() if r["valid"])
223
+ files_with_decorators.append(file_path)
224
+ total_decorated += decorated_count
225
+ total_valid += valid_count
226
+
227
+ # Track files with validation failures
228
+ if valid_count < decorated_count:
229
+ files_with_issues.append(file_path)
230
+
231
+ # Summary header
232
+ if total_decorated == 0:
233
+ typer.echo(typer.style(f"No pysealer decorators found in folder.", fg=typer.colors.YELLOW, bold=True))
234
+ elif total_valid == total_decorated:
235
+ file_word = "file" if len(files_with_decorators) == 1 else "files"
236
+ typer.echo(typer.style(f"All decorators are valid in {len(files_with_decorators)} {file_word}:", fg=typer.colors.BLUE, bold=True))
237
+ else:
238
+ failed_count = total_decorated - total_valid
239
+ failed_files = len(files_with_issues)
240
+ decorator_word = "decorator" if failed_count == 1 else "decorators"
241
+ file_word = "file" if failed_files == 1 else "files"
242
+ typer.echo(typer.style(f"{failed_count} {decorator_word} failed in {failed_files} {file_word}:", fg=typer.colors.BLUE, bold=True), err=True)
243
+
244
+ # File-by-file details - only show files with decorators
245
+ if total_decorated > 0:
246
+ for file_path in files_with_decorators:
247
+ results = all_results[file_path]
248
+ if "error" in results:
249
+ continue
250
+
251
+ decorated_count = sum(1 for r in results.values() if r["has_decorator"])
252
+ valid_count = sum(1 for r in results.values() if r["valid"])
253
+
254
+ if valid_count == decorated_count:
255
+ typer.echo(f" {typer.style('✓', fg=typer.colors.GREEN)} {file_path}")
256
+ else:
257
+ typer.echo(f" {typer.style('✗', fg=typer.colors.RED)} {file_path}")
258
+
259
+ # Show diff for each failed function
260
+ for func_name, result in results.items():
261
+ if result["has_decorator"] and not result["valid"]:
262
+ if result.get("diff"):
263
+ _format_diff_output(func_name, result["diff"])
264
+
265
+ # Exit with error if there were failures
266
+ if total_decorated > 0 and total_valid < total_decorated:
267
+ raise typer.Exit(code=1)
268
+
269
+ # Handle file path
270
+ else:
271
+
272
+ # Check all decorators in the file
273
+ resolved_path = str(path.resolve())
274
+ results = check_decorators(resolved_path)
275
+
276
+ # Return success if all decorated functions are valid
277
+ decorated_count = sum(1 for r in results.values() if r["has_decorator"])
278
+ valid_count = sum(1 for r in results.values() if r["valid"])
279
+
280
+ if decorated_count == 0:
281
+ typer.echo(typer.style(f"No pysealer decorators found in 1 file:", fg=typer.colors.YELLOW, bold=True))
282
+ typer.echo(f" {typer.style('⊘', fg=typer.colors.YELLOW)} {resolved_path}")
283
+ elif valid_count == decorated_count:
284
+ decorator_word = "decorator" if decorated_count == 1 else "decorators"
285
+ typer.echo(typer.style(f"All {decorator_word} are valid in 1 file:", fg=typer.colors.BLUE, bold=True))
286
+ typer.echo(f" {typer.style('✓', fg=typer.colors.GREEN)} {resolved_path}")
287
+ else:
288
+ failed = decorated_count - valid_count
289
+ decorator_word = "decorator" if decorated_count == 1 else "decorators"
290
+ typer.echo(typer.style(f"{failed}/{decorated_count} {decorator_word} failed in 1 file:", fg=typer.colors.BLUE, bold=True), err=True)
291
+ typer.echo(f" {typer.style('✗', fg=typer.colors.RED)} {resolved_path}")
292
+
293
+ # Show diff for each failed function
294
+ for func_name, result in results.items():
295
+ if result["has_decorator"] and not result["valid"]:
296
+ if result.get("diff"):
297
+ _format_diff_output(func_name, result["diff"])
298
+
299
+ raise typer.Exit(code=1)
300
+
301
+ except (FileNotFoundError, NotADirectoryError, ValueError) as e:
302
+ typer.echo(typer.style(f"Error: {e}", fg=typer.colors.RED, bold=True), err=True)
303
+ raise typer.Exit(code=1)
304
+
305
+
306
+ @app.command()
307
+ def remove(
308
+ file_path: Annotated[
309
+ str,
310
+ typer.Argument(help="Path to the Python file or folder to remove pysealer decorators from")
311
+ ]
312
+ ):
313
+ """Remove pysealer decorators from all functions and classes in a Python file or all Python files in a folder."""
314
+ path = Path(file_path)
315
+
316
+ # Validate path exists
317
+ if not path.exists():
318
+ typer.echo(typer.style(f"Error: Path '{path}' does not exist.", fg=typer.colors.RED, bold=True), err=True)
319
+ raise typer.Exit(code=1)
320
+
321
+ # Validate it's a Python file or directory
322
+ if path.is_file() and path.suffix != '.py':
323
+ typer.echo(typer.style(f"Error: File '{path}' is not a Python file.", fg=typer.colors.RED, bold=True), err=True)
324
+ raise typer.Exit(code=1)
325
+
326
+ try:
327
+ # Handle folder path
328
+ if path.is_dir():
329
+ resolved_path = str(path.resolve())
330
+ modified_files = remove_decorators_from_folder(resolved_path)
331
+
332
+ file_word = "file" if len(modified_files) == 1 else "files"
333
+ typer.echo(typer.style(f"Successfully removed decorators from {len(modified_files)} {file_word}:", fg=typer.colors.BLUE, bold=True))
334
+ for file in modified_files:
335
+ typer.echo(f" {typer.style('✓', fg=typer.colors.GREEN)} {file}")
336
+
337
+ # Handle file path
338
+ else:
339
+
340
+ resolved_path = str(path.resolve())
341
+ modified_code, found = remove_decorators(resolved_path)
342
+
343
+ with open(resolved_path, 'w') as f:
344
+ f.write(modified_code)
345
+
346
+ if found:
347
+ typer.echo(typer.style(f"Successfully removed decorators from 1 file:", fg=typer.colors.BLUE, bold=True))
348
+ typer.echo(f" {typer.style('✓', fg=typer.colors.GREEN)} {resolved_path}")
349
+ else:
350
+ typer.echo(typer.style(f"No pysealer decorators found in 1 file:", fg=typer.colors.YELLOW, bold=True))
351
+ typer.echo(f" {typer.style('⊘', fg=typer.colors.YELLOW)} {resolved_path}")
352
+
353
+ except (FileNotFoundError, NotADirectoryError, ValueError) as e:
354
+ typer.echo(typer.style(f"Error: {e}", fg=typer.colors.RED, bold=True), err=True)
355
+ raise typer.Exit(code=1)
356
+ except Exception as e:
357
+ typer.echo(typer.style(f"Unexpected error while removing decorators: {e}", fg=typer.colors.RED, bold=True), err=True)
358
+ raise typer.Exit(code=1)
359
+
360
+
361
+ def main():
362
+ """Main CLI entry point."""
363
+ app()
364
+
365
+
366
+ if __name__ == '__main__':
367
+ main()
@@ -0,0 +1,83 @@
1
+ """Defines dummy decorators for all decorators found in the target file."""
2
+
3
+ import os
4
+ import ast
5
+ import inspect
6
+
7
+
8
+ def _dummy_decorator(func=None, *args, **kwargs):
9
+ """
10
+ A no-op (dummy) decorator that can be used in place of any decorator.
11
+
12
+ Handles both @deco and @deco(...) usages. If used as @deco, it returns the function unchanged.
13
+ If used as @deco(...), it returns a wrapper that returns the function unchanged.
14
+
15
+ Args:
16
+ func (callable, optional): The function to decorate, or None if called with arguments.
17
+ *args: Positional arguments (ignored).
18
+ **kwargs: Keyword arguments (ignored).
19
+
20
+ Returns:
21
+ callable: The original function, unchanged.
22
+ """
23
+ if callable(func) and not args and not kwargs:
24
+ return func
25
+ def wrapper(f):
26
+ return f
27
+ return wrapper
28
+
29
+ def _discover_decorators(file_path):
30
+ """
31
+ Yield all decorator names used in the given Python file.
32
+
33
+ This function parses the specified Python file and walks its AST to find all decorator names
34
+ used on functions, async functions, and classes. It handles decorators used as @deco, @deco(...),
35
+ and @obj.deco or @obj.deco(...).
36
+
37
+ Args:
38
+ file_path (str): Path to the Python file to scan.
39
+
40
+ Yields:
41
+ str: The name of each decorator found.
42
+ """
43
+ if not os.path.exists(file_path):
44
+ return
45
+ with open(file_path, "r") as f:
46
+ src = f.read()
47
+ try:
48
+ tree = ast.parse(src)
49
+ except Exception:
50
+ return
51
+ for node in ast.walk(tree):
52
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
53
+ for deco in node.decorator_list:
54
+ # Handles @deco or @deco(...)
55
+ if isinstance(deco, ast.Name):
56
+ yield deco.id
57
+ elif isinstance(deco, ast.Attribute):
58
+ yield deco.attr
59
+ elif isinstance(deco.func, ast.Name):
60
+ yield deco.func.id
61
+ elif isinstance(deco.func, ast.Attribute):
62
+ yield deco.func.attr
63
+
64
+ def _get_caller_file():
65
+ """
66
+ Find the filename of the module that imported this module at the top level.
67
+
68
+ Returns:
69
+ str or None: The filename of the caller module, or None if not found.
70
+ """
71
+ stack = inspect.stack()
72
+ for frame in stack:
73
+ if frame.function == "<module>" and frame.filename != __file__:
74
+ return frame.filename
75
+
76
+ # Main logic: define dummy decorators for all found in the caller file
77
+ _CALLER_FILE = _get_caller_file()
78
+ if _CALLER_FILE:
79
+ _seen = set()
80
+ for deco_name in _discover_decorators(_CALLER_FILE):
81
+ if deco_name and deco_name not in globals() and deco_name not in _seen:
82
+ globals()[deco_name] = _dummy_decorator
83
+ _seen.add(deco_name)
pysealer/git_diff.py ADDED
@@ -0,0 +1,228 @@
1
+ """Git-based diff functionality for comparing function/class changes."""
2
+
3
+ import ast
4
+ import subprocess
5
+ from pathlib import Path
6
+ from typing import Optional, Tuple, List
7
+ import difflib
8
+
9
+
10
+ def get_file_from_git(file_path: str, ref: str = "HEAD") -> Optional[str]:
11
+ """
12
+ Retrieve file content from a specific git reference.
13
+
14
+ Args:
15
+ file_path: Absolute path to the file
16
+ ref: Git reference (default: HEAD)
17
+
18
+ Returns:
19
+ File content as string, or None if not in git or error occurs
20
+ """
21
+ try:
22
+ # Get relative path from git root
23
+ result = subprocess.run(
24
+ ["git", "rev-parse", "--show-toplevel"],
25
+ cwd=Path(file_path).parent,
26
+ capture_output=True,
27
+ text=True,
28
+ timeout=5
29
+ )
30
+
31
+ if result.returncode != 0:
32
+ return None
33
+
34
+ git_root = result.stdout.strip()
35
+ relative_path = Path(file_path).relative_to(git_root)
36
+
37
+ # Get file content from git
38
+ result = subprocess.run(
39
+ ["git", "show", f"{ref}:{relative_path}"],
40
+ cwd=git_root,
41
+ capture_output=True,
42
+ text=True,
43
+ timeout=5
44
+ )
45
+
46
+ if result.returncode == 0:
47
+ return result.stdout
48
+ return None
49
+
50
+ except FileNotFoundError:
51
+ # Git command not found
52
+ return None
53
+ except (subprocess.TimeoutExpired, subprocess.SubprocessError, ValueError, OSError):
54
+ return None
55
+
56
+
57
+ def extract_function_from_source(source_code: str, function_name: str) -> Optional[Tuple[str, int]]:
58
+ """
59
+ Extract a specific function or class from source code.
60
+
61
+ Args:
62
+ source_code: Python source code
63
+ function_name: Name of function/class to extract
64
+
65
+ Returns:
66
+ Tuple of (function_source, start_line) or None if not found
67
+ """
68
+ try:
69
+ tree = ast.parse(source_code)
70
+ lines = source_code.splitlines(keepends=True)
71
+
72
+ for node in ast.walk(tree):
73
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
74
+ if node.name == function_name:
75
+ # Get the source lines for this node
76
+ start_line = node.lineno
77
+ end_line = node.end_lineno if node.end_lineno else start_line
78
+
79
+ function_lines = lines[start_line - 1:end_line]
80
+ function_source = ''.join(function_lines)
81
+
82
+ return function_source, start_line
83
+
84
+ return None
85
+ except (SyntaxError, AttributeError):
86
+ return None
87
+
88
+
89
+ def generate_function_diff(
90
+ old_source: str,
91
+ new_source: str,
92
+ function_name: str,
93
+ old_start_line: int,
94
+ new_start_line: int,
95
+ context_lines: int = 2
96
+ ) -> List[Tuple[str, str, int]]:
97
+ """
98
+ Generate a unified diff for a specific function.
99
+
100
+ Args:
101
+ old_source: Old function source code
102
+ new_source: New function source code
103
+ function_name: Name of the function/class
104
+ old_start_line: Starting line number in old file
105
+ new_start_line: Starting line number in new file
106
+ context_lines: Number of context lines to show
107
+
108
+ Returns:
109
+ List of tuples: (diff_type, line_content, line_number)
110
+ where diff_type is ' ', '-', or '+'
111
+ """
112
+ old_lines = old_source.splitlines(keepends=False)
113
+ new_lines = new_source.splitlines(keepends=False)
114
+
115
+ # Generate unified diff
116
+ diff = list(difflib.unified_diff(
117
+ old_lines,
118
+ new_lines,
119
+ lineterm='',
120
+ n=context_lines
121
+ ))
122
+
123
+ # Skip the header lines (first 3 lines of unified diff)
124
+ if len(diff) > 3:
125
+ diff = diff[3:]
126
+
127
+ result = []
128
+ current_old_line = old_start_line
129
+ current_new_line = new_start_line
130
+
131
+ for line in diff:
132
+ if not line:
133
+ continue
134
+
135
+ prefix = line[0]
136
+ content = line[1:] if len(line) > 1 else ''
137
+
138
+ if prefix == '-':
139
+ result.append(('-', content, current_old_line))
140
+ current_old_line += 1
141
+ elif prefix == '+':
142
+ result.append(('+', content, current_new_line))
143
+ current_new_line += 1
144
+ elif prefix == ' ':
145
+ result.append((' ', content, current_new_line))
146
+ current_old_line += 1
147
+ current_new_line += 1
148
+ elif prefix == '@':
149
+ # Parse line numbers from @@ -old_start,old_count +new_start,new_count @@
150
+ try:
151
+ parts = line.split()
152
+ if len(parts) >= 3:
153
+ old_part = parts[1].lstrip('-')
154
+ new_part = parts[2].lstrip('+')
155
+
156
+ if ',' in old_part:
157
+ current_old_line = int(old_part.split(',')[0])
158
+ else:
159
+ current_old_line = int(old_part)
160
+
161
+ if ',' in new_part:
162
+ current_new_line = int(new_part.split(',')[0])
163
+ else:
164
+ current_new_line = int(new_part)
165
+ except (ValueError, IndexError):
166
+ pass
167
+
168
+ return result
169
+
170
+
171
+ def is_git_available() -> bool:
172
+ """
173
+ Check if the current directory is in a git repository.
174
+
175
+ Returns:
176
+ True if .git directory exists in current or parent directories, False otherwise
177
+ """
178
+ current = Path.cwd()
179
+ # Check current directory and all parent directories
180
+ for parent in [current] + list(current.parents):
181
+ if (parent / ".git").exists():
182
+ return True
183
+ return False
184
+
185
+
186
+ def get_function_diff(
187
+ file_path: str,
188
+ function_name: str,
189
+ new_source: str,
190
+ new_start_line: int
191
+ ) -> Optional[List[Tuple[str, str, int]]]:
192
+ """
193
+ Get the diff for a specific function comparing current version to git HEAD.
194
+
195
+ Args:
196
+ file_path: Absolute path to the Python file
197
+ function_name: Name of the function/class
198
+ new_source: Current source code of the function
199
+ new_start_line: Starting line number of function in current file
200
+
201
+ Returns:
202
+ List of diff tuples or None if git history unavailable
203
+ """
204
+ # Get the file from git
205
+ old_file_content = get_file_from_git(file_path)
206
+
207
+ if not old_file_content:
208
+ return None
209
+
210
+ # Extract the old version of the function
211
+ old_function = extract_function_from_source(old_file_content, function_name)
212
+
213
+ if not old_function:
214
+ return None
215
+
216
+ old_source, old_start_line = old_function
217
+
218
+ # Generate the diff
219
+ diff = generate_function_diff(
220
+ old_source,
221
+ new_source,
222
+ function_name,
223
+ old_start_line,
224
+ new_start_line,
225
+ context_lines=2
226
+ )
227
+
228
+ return diff if diff else None