pysealer 0.4.1__cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.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,391 @@
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
+
25
+ app = typer.Typer(
26
+ name="pysealer",
27
+ help="Version control your Python functions and classes with cryptographic decorators",
28
+ no_args_is_help=True,
29
+ )
30
+
31
+
32
+ def _format_diff_output(func_name: str, diff_lines):
33
+ """Format and display git diff with color coding."""
34
+ if not diff_lines:
35
+ return
36
+
37
+ typer.echo(f" Function '{func_name}' was modified:")
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
+ # Handle folder path
196
+ if path.is_dir():
197
+ resolved_path = str(path.resolve())
198
+ all_results = check_decorators_in_folder(resolved_path)
199
+
200
+ total_decorated = 0
201
+ total_valid = 0
202
+ total_files = 0
203
+ files_with_issues = []
204
+
205
+ for file_path, results in all_results.items():
206
+ # Skip files with errors
207
+ if "error" in results:
208
+ typer.echo(typer.style(f"✗ {file_path}: {results['error']}", fg=typer.colors.RED))
209
+ files_with_issues.append(file_path)
210
+ continue
211
+
212
+ total_files += 1
213
+ decorated_count = sum(1 for r in results.values() if r["has_decorator"])
214
+ valid_count = sum(1 for r in results.values() if r["valid"])
215
+
216
+ total_decorated += decorated_count
217
+ total_valid += valid_count
218
+
219
+ # Track files with validation failures
220
+ if decorated_count > 0 and valid_count < decorated_count:
221
+ files_with_issues.append(file_path)
222
+
223
+ # Summary header
224
+ if total_decorated == 0:
225
+ python_files = list(path.rglob("*.py"))
226
+ if python_files:
227
+ typer.echo(typer.style(f"No pysealer decorators found in {len(python_files)} files:", fg=typer.colors.YELLOW, bold=True))
228
+ for file in python_files:
229
+ typer.echo(f" {typer.style('⊘', fg=typer.colors.YELLOW)} {file.resolve()}")
230
+ else:
231
+ typer.echo(typer.style(f"No Python files found in folder.", fg=typer.colors.YELLOW, bold=True))
232
+ elif total_valid == total_decorated:
233
+ file_word = "file" if total_files == 1 else "files"
234
+ typer.echo(typer.style(f"All decorators are valid in {total_files} {file_word}:", fg=typer.colors.BLUE, bold=True))
235
+ else:
236
+ failed_count = total_decorated - total_valid
237
+ failed_files = len(files_with_issues)
238
+ decorator_word = "decorator" if failed_count == 1 else "decorators"
239
+ file_word = "file" if failed_files == 1 else "files"
240
+ typer.echo(typer.style(f"{failed_count} {decorator_word} failed in {failed_files} {file_word}:", fg=typer.colors.BLUE, bold=True), err=True)
241
+
242
+ # File-by-file details
243
+ if total_decorated > 0:
244
+ for file_path, results in all_results.items():
245
+ if "error" in results:
246
+ continue
247
+
248
+ decorated_count = sum(1 for r in results.values() if r["has_decorator"])
249
+ valid_count = sum(1 for r in results.values() if r["valid"])
250
+
251
+ if decorated_count > 0:
252
+ if valid_count == decorated_count:
253
+ typer.echo(f" {typer.style('✓', fg=typer.colors.GREEN)} {file_path}")
254
+ else:
255
+ typer.echo(f" {typer.style('✗', fg=typer.colors.RED)} {file_path}")
256
+
257
+ # Show diff for each failed function
258
+ for func_name, result in results.items():
259
+ if result["has_decorator"] and not result["valid"]:
260
+ if result.get("diff"):
261
+ _format_diff_output(func_name, result["diff"])
262
+
263
+ # Exit with error if there were failures
264
+ if total_decorated > 0 and total_valid < total_decorated:
265
+ raise typer.Exit(code=1)
266
+
267
+ # Handle file path
268
+ else:
269
+
270
+ # Check all decorators in the file
271
+ resolved_path = str(path.resolve())
272
+ results = check_decorators(resolved_path)
273
+
274
+ # Return success if all decorated functions are valid
275
+ decorated_count = sum(1 for r in results.values() if r["has_decorator"])
276
+ valid_count = sum(1 for r in results.values() if r["valid"])
277
+
278
+ if decorated_count == 0:
279
+ typer.echo(typer.style(f"No pysealer decorators found in 1 file:", fg=typer.colors.YELLOW, bold=True))
280
+ typer.echo(f" {typer.style('⊘', fg=typer.colors.YELLOW)} {resolved_path}")
281
+ elif valid_count == decorated_count:
282
+ decorator_word = "decorator" if decorated_count == 1 else "decorators"
283
+ typer.echo(typer.style(f"All {decorator_word} are valid in 1 file:", fg=typer.colors.BLUE, bold=True))
284
+ typer.echo(f" {typer.style('✓', fg=typer.colors.GREEN)} {resolved_path}")
285
+ else:
286
+ failed = decorated_count - valid_count
287
+ decorator_word = "decorator" if decorated_count == 1 else "decorators"
288
+ typer.echo(typer.style(f"{failed}/{decorated_count} {decorator_word} failed in 1 file:", fg=typer.colors.BLUE, bold=True), err=True)
289
+ typer.echo(f" {typer.style('✗', fg=typer.colors.RED)} {resolved_path}")
290
+
291
+ # Show diff for each failed function
292
+ for func_name, result in results.items():
293
+ if result["has_decorator"] and not result["valid"]:
294
+ if result.get("diff"):
295
+ _format_diff_output(func_name, result["diff"])
296
+
297
+ raise typer.Exit(code=1)
298
+
299
+ except (FileNotFoundError, NotADirectoryError, ValueError) as e:
300
+ typer.echo(typer.style(f"Error: {e}", fg=typer.colors.RED, bold=True), err=True)
301
+ raise typer.Exit(code=1)
302
+
303
+
304
+ @app.command()
305
+ def remove(
306
+ file_path: Annotated[
307
+ str,
308
+ typer.Argument(help="Path to the Python file or folder to remove pysealer decorators from")
309
+ ]
310
+ ):
311
+ """Remove pysealer decorators from all functions and classes in a Python file or all Python files in a folder."""
312
+ path = Path(file_path)
313
+
314
+ # Validate path exists
315
+ if not path.exists():
316
+ typer.echo(typer.style(f"Error: Path '{path}' does not exist.", fg=typer.colors.RED, bold=True), err=True)
317
+ raise typer.Exit(code=1)
318
+
319
+ # Validate it's a Python file or directory
320
+ if path.is_file() and path.suffix != '.py':
321
+ typer.echo(typer.style(f"Error: File '{path}' is not a Python file.", fg=typer.colors.RED, bold=True), err=True)
322
+ raise typer.Exit(code=1)
323
+
324
+ try:
325
+ # Handle folder path
326
+ if path.is_dir():
327
+ resolved_path = str(path.resolve())
328
+ modified_files = remove_decorators_from_folder(resolved_path)
329
+
330
+ if modified_files:
331
+ file_word = "file" if len(modified_files) == 1 else "files"
332
+ typer.echo(typer.style(f"Successfully removed decorators from {len(modified_files)} {file_word}:", fg=typer.colors.BLUE, bold=True))
333
+ for file in modified_files:
334
+ typer.echo(f" {typer.style('✓', fg=typer.colors.GREEN)} {file}")
335
+ else:
336
+ # Find all Python files to show in the output
337
+ python_files = list(path.rglob("*.py"))
338
+ if python_files:
339
+ file_word = "file" if len(python_files) == 1 else "files"
340
+ typer.echo(typer.style(f"No pysealer decorators found in {len(python_files)} {file_word}:", fg=typer.colors.YELLOW, bold=True))
341
+ for file in python_files:
342
+ typer.echo(f" {typer.style('⊘', fg=typer.colors.YELLOW)} {file.resolve()}")
343
+ else:
344
+ typer.echo(typer.style(f"No Python files found in folder.", fg=typer.colors.YELLOW, bold=True))
345
+
346
+ # Handle file path
347
+ else:
348
+
349
+ resolved_path = str(path.resolve())
350
+ modified_code, found = remove_decorators(resolved_path)
351
+
352
+ with open(resolved_path, 'w') as f:
353
+ f.write(modified_code)
354
+
355
+ if found:
356
+ typer.echo(typer.style(f"Successfully removed decorators from 1 file:", fg=typer.colors.BLUE, bold=True))
357
+ typer.echo(f" {typer.style('✓', fg=typer.colors.GREEN)} {resolved_path}")
358
+ else:
359
+ typer.echo(typer.style(f"No pysealer decorators found in 1 file:", fg=typer.colors.YELLOW, bold=True))
360
+ typer.echo(f" {typer.style('⊘', fg=typer.colors.YELLOW)} {resolved_path}")
361
+
362
+ except (FileNotFoundError, NotADirectoryError, ValueError) as e:
363
+ typer.echo(typer.style(f"Error: {e}", fg=typer.colors.RED, bold=True), err=True)
364
+ raise typer.Exit(code=1)
365
+ except Exception as e:
366
+ typer.echo(typer.style(f"Unexpected error while removing decorators: {e}", fg=typer.colors.RED, bold=True), err=True)
367
+ raise typer.Exit(code=1)
368
+
369
+
370
+ def main():
371
+ """Main CLI entry point."""
372
+ app()
373
+
374
+
375
+ if __name__ == '__main__':
376
+ main()
377
+
378
+ '''
379
+ modify the check command to print out the specific reason why the decorator is invalid for each that fails. basically i need to get the diff of the previos to the current using git somehow and print the dif in a super readable and user friendly way.
380
+
381
+ 1. Color-code the diff lines
382
+ 2. Add context lines (show unchanged code around changes)
383
+ get rid of what comes after this /Users/aidandyga/Downloads/SeniorThesis/pysealer/examples/math_operations.py:
384
+ specifically1/5 decorators failed or 7 decorators valid. i dont need this showing up
385
+
386
+ ex.
387
+ 2 decorators failed in 2 files:
388
+ ✗ /Users/aidandyga/Downloads/SeniorThesis/pysealer/examples/math_operations.py: 1/5 decorators failed
389
+ ✓ /Users/aidandyga/Downloads/SeniorThesis/pysealer/examples/text_processing.py: 7 decorators valid
390
+ ✗ /Users/aidandyga/Downloads/SeniorThesis/pysealer/examples/fibonacci.py: 1/1 decorator failed
391
+ '''
@@ -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,210 @@
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 (subprocess.TimeoutExpired, subprocess.SubprocessError, ValueError, OSError):
51
+ return None
52
+
53
+
54
+ def extract_function_from_source(source_code: str, function_name: str) -> Optional[Tuple[str, int]]:
55
+ """
56
+ Extract a specific function or class from source code.
57
+
58
+ Args:
59
+ source_code: Python source code
60
+ function_name: Name of function/class to extract
61
+
62
+ Returns:
63
+ Tuple of (function_source, start_line) or None if not found
64
+ """
65
+ try:
66
+ tree = ast.parse(source_code)
67
+ lines = source_code.splitlines(keepends=True)
68
+
69
+ for node in ast.walk(tree):
70
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
71
+ if node.name == function_name:
72
+ # Get the source lines for this node
73
+ start_line = node.lineno
74
+ end_line = node.end_lineno if node.end_lineno else start_line
75
+
76
+ function_lines = lines[start_line - 1:end_line]
77
+ function_source = ''.join(function_lines)
78
+
79
+ return function_source, start_line
80
+
81
+ return None
82
+ except (SyntaxError, AttributeError):
83
+ return None
84
+
85
+
86
+ def generate_function_diff(
87
+ old_source: str,
88
+ new_source: str,
89
+ function_name: str,
90
+ old_start_line: int,
91
+ new_start_line: int,
92
+ context_lines: int = 2
93
+ ) -> List[Tuple[str, str, int]]:
94
+ """
95
+ Generate a unified diff for a specific function.
96
+
97
+ Args:
98
+ old_source: Old function source code
99
+ new_source: New function source code
100
+ function_name: Name of the function/class
101
+ old_start_line: Starting line number in old file
102
+ new_start_line: Starting line number in new file
103
+ context_lines: Number of context lines to show
104
+
105
+ Returns:
106
+ List of tuples: (diff_type, line_content, line_number)
107
+ where diff_type is ' ', '-', or '+'
108
+ """
109
+ old_lines = old_source.splitlines(keepends=False)
110
+ new_lines = new_source.splitlines(keepends=False)
111
+
112
+ # Generate unified diff
113
+ diff = list(difflib.unified_diff(
114
+ old_lines,
115
+ new_lines,
116
+ lineterm='',
117
+ n=context_lines
118
+ ))
119
+
120
+ # Skip the header lines (first 3 lines of unified diff)
121
+ if len(diff) > 3:
122
+ diff = diff[3:]
123
+
124
+ result = []
125
+ current_old_line = old_start_line
126
+ current_new_line = new_start_line
127
+
128
+ for line in diff:
129
+ if not line:
130
+ continue
131
+
132
+ prefix = line[0]
133
+ content = line[1:] if len(line) > 1 else ''
134
+
135
+ if prefix == '-':
136
+ result.append(('-', content, current_old_line))
137
+ current_old_line += 1
138
+ elif prefix == '+':
139
+ result.append(('+', content, current_new_line))
140
+ current_new_line += 1
141
+ elif prefix == ' ':
142
+ result.append((' ', content, current_new_line))
143
+ current_old_line += 1
144
+ current_new_line += 1
145
+ elif prefix == '@':
146
+ # Parse line numbers from @@ -old_start,old_count +new_start,new_count @@
147
+ try:
148
+ parts = line.split()
149
+ if len(parts) >= 3:
150
+ old_part = parts[1].lstrip('-')
151
+ new_part = parts[2].lstrip('+')
152
+
153
+ if ',' in old_part:
154
+ current_old_line = int(old_part.split(',')[0])
155
+ else:
156
+ current_old_line = int(old_part)
157
+
158
+ if ',' in new_part:
159
+ current_new_line = int(new_part.split(',')[0])
160
+ else:
161
+ current_new_line = int(new_part)
162
+ except (ValueError, IndexError):
163
+ pass
164
+
165
+ return result
166
+
167
+
168
+ def get_function_diff(
169
+ file_path: str,
170
+ function_name: str,
171
+ new_source: str,
172
+ new_start_line: int
173
+ ) -> Optional[List[Tuple[str, str, int]]]:
174
+ """
175
+ Get the diff for a specific function comparing current version to git HEAD.
176
+
177
+ Args:
178
+ file_path: Absolute path to the Python file
179
+ function_name: Name of the function/class
180
+ new_source: Current source code of the function
181
+ new_start_line: Starting line number of function in current file
182
+
183
+ Returns:
184
+ List of diff tuples or None if git history unavailable
185
+ """
186
+ # Get the file from git
187
+ old_file_content = get_file_from_git(file_path)
188
+
189
+ if not old_file_content:
190
+ return None
191
+
192
+ # Extract the old version of the function
193
+ old_function = extract_function_from_source(old_file_content, function_name)
194
+
195
+ if not old_function:
196
+ return None
197
+
198
+ old_source, old_start_line = old_function
199
+
200
+ # Generate the diff
201
+ diff = generate_function_diff(
202
+ old_source,
203
+ new_source,
204
+ function_name,
205
+ old_start_line,
206
+ new_start_line,
207
+ context_lines=2
208
+ )
209
+
210
+ return diff if diff else None