pysealer 0.2.0__cp313-cp313t-musllinux_1_2_x86_64.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,316 @@
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 version_callback(value: bool):
33
+ """Helper function to display version information."""
34
+ if value:
35
+ typer.echo(f"pysealer {__version__}")
36
+ raise typer.Exit()
37
+
38
+
39
+ @app.callback()
40
+ def version(
41
+ version: Annotated[
42
+ bool,
43
+ typer.Option("--version", help="Report the current version of pysealer installed.", callback=version_callback, is_eager=True)
44
+ ] = False
45
+ ):
46
+ """Report the current version of pysealer installed."""
47
+ pass
48
+
49
+
50
+ @app.command()
51
+ def init(
52
+ env_file: Annotated[
53
+ str,
54
+ typer.Argument(help="Path to the .env file")
55
+ ] = ".env",
56
+ github_token: Annotated[
57
+ str,
58
+ typer.Option("--github-token", help="GitHub personal access token for uploading public key to repository secrets")
59
+ ] = None,
60
+ skip_github: Annotated[
61
+ bool,
62
+ typer.Option("--skip-github", help="Skip GitHub secrets integration")
63
+ ] = False
64
+ ):
65
+ """Initialize pysealer with an .env file and optionally upload public key to GitHub."""
66
+ try:
67
+ env_path = Path(env_file)
68
+
69
+ # Generate and store keypair (will raise error if keys already exist)
70
+ public_key, private_key = setup_keypair(env_path)
71
+ typer.echo(typer.style("Successfully initialized pysealer!", fg=typer.colors.BLUE, bold=True))
72
+ typer.echo(f"🔑 Keypair generated and stored in {env_path}")
73
+ typer.echo("⚠️ Keep your .env file secure and add it to .gitignore!")
74
+
75
+ # GitHub secrets integration (optional)
76
+ if not skip_github:
77
+ typer.echo() # Blank line for readability
78
+ typer.echo("Attempting to upload public key to GitHub repository secrets...")
79
+
80
+ try:
81
+ from .github_secrets import setup_github_secrets
82
+
83
+ success, message = setup_github_secrets(public_key, github_token)
84
+
85
+ if success:
86
+ typer.echo(typer.style(f"✓ {message}", fg=typer.colors.GREEN))
87
+ else:
88
+ typer.echo(typer.style(f"⚠ Warning: {message}", fg=typer.colors.YELLOW))
89
+ typer.echo(" You can manually add the public key to GitHub secrets later.")
90
+ typer.echo(f" Secret name: PYSEALER_PUBLIC_KEY")
91
+ typer.echo(f" Public key: {public_key}")
92
+
93
+ except ImportError as e:
94
+ typer.echo(typer.style(f"⚠ Warning: GitHub integration dependencies not installed: {e}", fg=typer.colors.YELLOW))
95
+ typer.echo(" Install with: pip install PyGithub PyNaCl GitPython")
96
+ except Exception as e:
97
+ typer.echo(typer.style(f"⚠ Warning: Failed to upload to GitHub: {e}", fg=typer.colors.YELLOW))
98
+ typer.echo(" You can manually add the public key to GitHub secrets later.")
99
+
100
+ except Exception as e:
101
+ typer.echo(typer.style(f"Error during initialization: {e}", fg=typer.colors.RED, bold=True), err=True)
102
+ raise typer.Exit(code=1)
103
+
104
+ @app.command()
105
+ def lock(
106
+ file_path: Annotated[
107
+ str,
108
+ typer.Argument(help="Path to the Python file or folder to decorate")
109
+ ]
110
+ ):
111
+ """Add decorators to all functions and classes in a Python file or all Python files in a folder."""
112
+ path = Path(file_path)
113
+
114
+ # Validate path exists
115
+ if not path.exists():
116
+ typer.echo(typer.style(f"Error: Path '{path}' does not exist.", fg=typer.colors.RED, bold=True), err=True)
117
+ raise typer.Exit(code=1)
118
+
119
+ try:
120
+ # Handle folder path
121
+ if path.is_dir():
122
+ resolved_path = str(path.resolve())
123
+ decorated_files = add_decorators_to_folder(resolved_path)
124
+
125
+ typer.echo(typer.style(f"Successfully added decorators to {len(decorated_files)} files:", fg=typer.colors.BLUE, bold=True))
126
+ for file in decorated_files:
127
+ typer.echo(f" {typer.style('✓', fg=typer.colors.GREEN)} {file}")
128
+
129
+ # Handle file path
130
+ else:
131
+ # Validate it's a Python file
132
+ if not path.suffix == '.py':
133
+ typer.echo(typer.style(f"Error: File '{path}' is not a Python file.", fg=typer.colors.RED, bold=True), err=True)
134
+ raise typer.Exit(code=1)
135
+
136
+ # Add decorators to all functions and classes in the file
137
+ resolved_path = str(path.resolve())
138
+ modified_code = add_decorators(resolved_path)
139
+
140
+ # Write the modified code back to the file
141
+ with open(resolved_path, 'w') as f:
142
+ f.write(modified_code)
143
+
144
+ typer.echo(typer.style(f"Successfully added decorators to 1 file:", fg=typer.colors.BLUE, bold=True))
145
+ typer.echo(f" {typer.style('✓', fg=typer.colors.GREEN)} {resolved_path}")
146
+
147
+ except (RuntimeError, FileNotFoundError, NotADirectoryError, ValueError) as e:
148
+ typer.echo(typer.style(f"Error: {e}", fg=typer.colors.RED, bold=True), err=True)
149
+ raise typer.Exit(code=1)
150
+ except Exception as e:
151
+ typer.echo(typer.style(f"Unexpected error while locking file: {e}", fg=typer.colors.RED, bold=True), err=True)
152
+ raise typer.Exit(code=1)
153
+
154
+
155
+ @app.command()
156
+ def check(
157
+ file_path: Annotated[
158
+ str,
159
+ typer.Argument(help="Path to the Python file or folder to check")
160
+ ]
161
+ ):
162
+ """Check the integrity of decorators in a Python file or all Python files in a folder."""
163
+ path = Path(file_path)
164
+
165
+ # Validate path exists
166
+ if not path.exists():
167
+ typer.echo(typer.style(f"Error: Path '{path}' does not exist.", fg=typer.colors.RED, bold=True), err=True)
168
+ raise typer.Exit(code=1)
169
+
170
+ try:
171
+ # Handle folder path
172
+ if path.is_dir():
173
+ resolved_path = str(path.resolve())
174
+ all_results = check_decorators_in_folder(resolved_path)
175
+
176
+ total_decorated = 0
177
+ total_valid = 0
178
+ total_files = 0
179
+ files_with_issues = []
180
+
181
+ for file_path, results in all_results.items():
182
+ # Skip files with errors
183
+ if "error" in results:
184
+ typer.echo(typer.style(f"✗ {file_path}: {results['error']}", fg=typer.colors.RED))
185
+ files_with_issues.append(file_path)
186
+ continue
187
+
188
+ total_files += 1
189
+ decorated_count = sum(1 for r in results.values() if r["has_decorator"])
190
+ valid_count = sum(1 for r in results.values() if r["valid"])
191
+
192
+ total_decorated += decorated_count
193
+ total_valid += valid_count
194
+
195
+ # Track files with validation failures
196
+ if decorated_count > 0 and valid_count < decorated_count:
197
+ files_with_issues.append(file_path)
198
+
199
+ # Summary header
200
+ if total_decorated == 0:
201
+ typer.echo("⚠️ No pysealer decorators found in any files.")
202
+ elif total_valid == total_decorated:
203
+ typer.echo(typer.style(f"All decorators are valid in {total_files} files:", fg=typer.colors.BLUE, bold=True))
204
+ else:
205
+ typer.echo(typer.style(f"{total_decorated - total_valid}/{total_decorated} decorators failed verification across {total_files} files:", fg=typer.colors.BLUE, bold=True), err=True)
206
+
207
+ # File-by-file details
208
+ for file_path, results in all_results.items():
209
+ if "error" in results:
210
+ continue
211
+
212
+ decorated_count = sum(1 for r in results.values() if r["has_decorator"])
213
+ valid_count = sum(1 for r in results.values() if r["valid"])
214
+
215
+ if decorated_count > 0:
216
+ if valid_count == decorated_count:
217
+ typer.echo(f" {typer.style('✓', fg=typer.colors.GREEN)} {file_path}: {typer.style(f'{decorated_count} decorators valid', fg=typer.colors.GREEN)}")
218
+ else:
219
+ typer.echo(f" {typer.style('✗', fg=typer.colors.RED)} {file_path}: {typer.style(f'{decorated_count - valid_count}/{decorated_count} decorators failed', fg=typer.colors.RED)}")
220
+
221
+ # Exit with error if there were failures
222
+ if total_decorated > 0 and total_valid < total_decorated:
223
+ raise typer.Exit(code=1)
224
+
225
+ # Handle file path
226
+ else:
227
+ # Validate it's a Python file
228
+ if not path.suffix == '.py':
229
+ typer.echo(typer.style(f"Error: File '{path}' is not a Python file.", fg=typer.colors.RED, bold=True), err=True)
230
+ raise typer.Exit(code=1)
231
+
232
+ # Check all decorators in the file
233
+ resolved_path = str(path.resolve())
234
+ results = check_decorators(resolved_path)
235
+
236
+ # Return success if all decorated functions are valid
237
+ decorated_count = sum(1 for r in results.values() if r["has_decorator"])
238
+ valid_count = sum(1 for r in results.values() if r["valid"])
239
+
240
+ if decorated_count == 0:
241
+ typer.echo("⚠️ No pysealer decorators found in this file.")
242
+ elif valid_count == decorated_count:
243
+ typer.echo(typer.style("All decorators are valid in 1 file:", fg=typer.colors.BLUE, bold=True))
244
+ typer.echo(f"{typer.style('✓', fg=typer.colors.GREEN)} {resolved_path}: {typer.style(f'{decorated_count} decorators valid', fg=typer.colors.GREEN)}")
245
+ else:
246
+ typer.echo(typer.style(f"{decorated_count - valid_count}/{decorated_count} decorators failed verification across 1 file:", fg=typer.colors.BLUE, bold=True), err=True)
247
+ typer.echo(f" {typer.style('✗', fg=typer.colors.RED)} {resolved_path}: {typer.style(f'{decorated_count - valid_count}/{decorated_count} decorators failed', fg=typer.colors.RED)}")
248
+ raise typer.Exit(code=1)
249
+
250
+ except (FileNotFoundError, NotADirectoryError, ValueError) as e:
251
+ typer.echo(typer.style(f"Error: {e}", fg=typer.colors.RED, bold=True), err=True)
252
+ raise typer.Exit(code=1)
253
+
254
+
255
+ @app.command()
256
+ def remove(
257
+ file_path: Annotated[
258
+ str,
259
+ typer.Argument(help="Path to the Python file or folder to remove pysealer decorators from")
260
+ ]
261
+ ):
262
+ """Remove pysealer decorators from all functions and classes in a Python file or all Python files in a folder."""
263
+ path = Path(file_path)
264
+
265
+ # Validate path exists
266
+ if not path.exists():
267
+ typer.echo(typer.style(f"Error: Path '{path}' does not exist.", fg=typer.colors.RED, bold=True), err=True)
268
+ raise typer.Exit(code=1)
269
+
270
+ try:
271
+ # Handle folder path
272
+ if path.is_dir():
273
+ resolved_path = str(path.resolve())
274
+ modified_files = remove_decorators_from_folder(resolved_path)
275
+
276
+ if modified_files:
277
+ typer.echo(typer.style(f"Successfully removed decorators from {len(modified_files)} files:", fg=typer.colors.BLUE, bold=True))
278
+ for file in modified_files:
279
+ typer.echo(f" {typer.style('✓', fg=typer.colors.GREEN)} {file}")
280
+ else:
281
+ typer.echo("⚠️ No pysealer decorators found in any files.")
282
+
283
+ # Handle file path
284
+ else:
285
+ # Validate it's a Python file
286
+ if not path.suffix == '.py':
287
+ typer.echo(typer.style(f"Error: File '{path}' is not a Python file.", fg=typer.colors.RED, bold=True), err=True)
288
+ raise typer.Exit(code=1)
289
+
290
+ resolved_path = str(path.resolve())
291
+ modified_code, found = remove_decorators(resolved_path)
292
+
293
+ with open(resolved_path, 'w') as f:
294
+ f.write(modified_code)
295
+
296
+ if found:
297
+ typer.echo(typer.style(f"Successfully removed decorators from 1 file:", fg=typer.colors.BLUE, bold=True))
298
+ typer.echo(f" {typer.style('✓', fg=typer.colors.GREEN)} {resolved_path}")
299
+ else:
300
+ typer.echo(f"⚠️ No pysealer decorators found in {resolved_path}")
301
+
302
+ except (FileNotFoundError, NotADirectoryError, ValueError) as e:
303
+ typer.echo(typer.style(f"Error: {e}", fg=typer.colors.RED, bold=True), err=True)
304
+ raise typer.Exit(code=1)
305
+ except Exception as e:
306
+ typer.echo(typer.style(f"Unexpected error while removing decorators: {e}", fg=typer.colors.RED, bold=True), err=True)
307
+ raise typer.Exit(code=1)
308
+
309
+
310
+ def main():
311
+ """Main CLI entry point."""
312
+ app()
313
+
314
+
315
+ if __name__ == '__main__':
316
+ 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)
@@ -0,0 +1,170 @@
1
+ """
2
+ GitHub secrets integration for pysealer.
3
+
4
+ This module provides functionality to automatically upload the pysealer public key
5
+ to GitHub repository secrets when `pysealer init` is run.
6
+ """
7
+
8
+ import os
9
+ import re
10
+ from pathlib import Path
11
+ from typing import Optional, Tuple
12
+
13
+ import git
14
+ from github import Github, GithubException
15
+ from nacl import encoding, public
16
+
17
+
18
+ def get_repo_info() -> Tuple[str, str]:
19
+ """
20
+ Extract GitHub owner and repository name from git remote URL.
21
+
22
+ Supports both SSH and HTTPS formats:
23
+ - SSH: git@github.com:owner/repo.git
24
+ - HTTPS: https://github.com/owner/repo.git
25
+
26
+ Returns:
27
+ Tuple[str, str]: (owner, repo_name)
28
+
29
+ Raises:
30
+ ValueError: If not in a git repository or remote URL is not from GitHub
31
+ RuntimeError: If unable to parse the remote URL
32
+ """
33
+ try:
34
+ # Get the git repository from current directory
35
+ repo = git.Repo(search_parent_directories=True)
36
+
37
+ # Try to get the origin remote
38
+ if 'origin' not in repo.remotes:
39
+ raise ValueError("No 'origin' remote found in git repository")
40
+
41
+ remote_url = repo.remotes.origin.url
42
+
43
+ # Parse SSH format: git@github.com:owner/repo.git
44
+ ssh_pattern = r'git@github\.com:([^/]+)/(.+?)(?:\.git)?$'
45
+ ssh_match = re.match(ssh_pattern, remote_url)
46
+ if ssh_match:
47
+ owner, repo_name = ssh_match.groups()
48
+ return owner, repo_name
49
+
50
+ # Parse HTTPS format: https://github.com/owner/repo.git
51
+ https_pattern = r'https://github\.com/([^/]+)/(.+?)(?:\.git)?$'
52
+ https_match = re.match(https_pattern, remote_url)
53
+ if https_match:
54
+ owner, repo_name = https_match.groups()
55
+ return owner, repo_name
56
+
57
+ raise RuntimeError(f"Could not parse GitHub repository from remote URL: {remote_url}")
58
+
59
+ except git.InvalidGitRepositoryError:
60
+ raise ValueError("Not in a git repository. Please run this command from within a git repository.")
61
+ except git.GitCommandError as e:
62
+ raise RuntimeError(f"Git error: {e}")
63
+
64
+
65
+ def encrypt_secret(public_key: str, secret_value: str) -> str:
66
+ """
67
+ Encrypt a secret using GitHub's public key.
68
+
69
+ GitHub requires secrets to be encrypted using the repository's public key
70
+ before they can be uploaded via the API.
71
+
72
+ Args:
73
+ public_key: The repository's public key (base64 encoded)
74
+ secret_value: The secret value to encrypt
75
+
76
+ Returns:
77
+ str: Base64 encoded encrypted secret
78
+ """
79
+ # Convert the public key from base64
80
+ public_key_bytes = public_key.encode("utf-8")
81
+ public_key_obj = public.PublicKey(public_key_bytes, encoding.Base64Encoder())
82
+
83
+ # Encrypt the secret using sealed box
84
+ sealed_box = public.SealedBox(public_key_obj)
85
+ encrypted = sealed_box.encrypt(secret_value.encode("utf-8"))
86
+
87
+ # Return base64 encoded encrypted secret
88
+ return encoding.Base64Encoder().encode(encrypted).decode("utf-8")
89
+
90
+
91
+ def add_secret_to_github(token: str, owner: str, repo_name: str, secret_name: str, secret_value: str) -> None:
92
+ """
93
+ Add or update a secret in GitHub repository.
94
+
95
+ Args:
96
+ token: GitHub personal access token (needs 'repo' scope)
97
+ owner: GitHub repository owner (user or organization)
98
+ repo_name: Repository name
99
+ secret_name: Name of the secret to create/update
100
+ secret_value: Value of the secret (will be encrypted before upload)
101
+
102
+ Raises:
103
+ GithubException: If GitHub API request fails
104
+ Exception: For other errors during the process
105
+ """
106
+ try:
107
+ # Initialize GitHub client
108
+ g = Github(token)
109
+
110
+ # Get the repository
111
+ repo = g.get_repo(f"{owner}/{repo_name}")
112
+
113
+ # Get the repository's public key for encrypting secrets
114
+ public_key = repo.get_public_key()
115
+
116
+ # Encrypt the secret
117
+ encrypted_value = encrypt_secret(public_key.key, secret_value)
118
+
119
+ # Create or update the secret
120
+ repo.create_secret(secret_name, encrypted_value, public_key.key_id)
121
+
122
+ except GithubException as e:
123
+ if e.status == 401:
124
+ raise Exception("Authentication failed. Please check your GitHub token.")
125
+ elif e.status == 403:
126
+ raise Exception("Permission denied. Your token needs 'repo' scope to manage secrets.")
127
+ elif e.status == 404:
128
+ raise Exception(f"Repository '{owner}/{repo_name}' not found or you don't have access.")
129
+ else:
130
+ raise Exception(f"GitHub API error: {e.data.get('message', str(e))}")
131
+
132
+
133
+ def setup_github_secrets(public_key: str, github_token: Optional[str] = None) -> Tuple[bool, str]:
134
+ """
135
+ Main function to orchestrate GitHub secrets setup.
136
+
137
+ This function:
138
+ 1. Gets GitHub token from parameter or environment variable
139
+ 2. Extracts repository info from git remote
140
+ 3. Uploads the public key to GitHub secrets
141
+
142
+ Args:
143
+ public_key: The pysealer public key to upload
144
+ github_token: Optional GitHub token. If None, uses GITHUB_TOKEN env var
145
+
146
+ Returns:
147
+ Tuple[bool, str]: (success, message)
148
+ - success: True if secret was uploaded successfully
149
+ - message: Success or error message
150
+ """
151
+ # Get GitHub token
152
+ token = github_token or os.getenv("GITHUB_TOKEN")
153
+ if not token:
154
+ return False, "No GitHub token provided. Use --github-token or set GITHUB_TOKEN environment variable."
155
+
156
+ try:
157
+ # Get repository information
158
+ owner, repo_name = get_repo_info()
159
+
160
+ # Upload the secret
161
+ add_secret_to_github(token, owner, repo_name, "PYSEALER_PUBLIC_KEY", public_key)
162
+
163
+ return True, f"Successfully added PYSEALER_PUBLIC_KEY to {owner}/{repo_name}"
164
+
165
+ except ValueError as e:
166
+ return False, f"Repository detection failed: {e}"
167
+ except RuntimeError as e:
168
+ return False, f"Git error: {e}"
169
+ except Exception as e:
170
+ return False, f"Failed to upload secret: {e}"
@@ -0,0 +1,88 @@
1
+ """Remove cryptographic pysealer decorators from all functions and classes in a Python file."""
2
+
3
+ import ast
4
+ from typing import List, Tuple, Dict
5
+ from pathlib import Path
6
+
7
+ def remove_decorators(file_path: str) -> Tuple[str, bool]:
8
+ """
9
+ Parse a Python file, remove all @pysealer.* decorators from functions and classes, and return the modified code.
10
+
11
+ Args:
12
+ file_path: Path to the Python file to process
13
+ Returns:
14
+ Modified Python source code as a string
15
+ """
16
+ with open(file_path, 'r') as f:
17
+ content = f.read()
18
+
19
+ tree = ast.parse(content)
20
+ lines = content.split('\n')
21
+ lines_to_remove = set()
22
+
23
+ for node in ast.walk(tree):
24
+ if type(node).__name__ in ("FunctionDef", "AsyncFunctionDef", "ClassDef"):
25
+ if hasattr(node, 'decorator_list'):
26
+ for decorator in node.decorator_list:
27
+ is_pysealer_decorator = False
28
+ if isinstance(decorator, ast.Name):
29
+ if decorator.id.startswith("pysealer"):
30
+ is_pysealer_decorator = True
31
+ elif isinstance(decorator, ast.Attribute):
32
+ if isinstance(decorator.value, ast.Name) and decorator.value.id == "pysealer":
33
+ is_pysealer_decorator = True
34
+ elif isinstance(decorator, ast.Call):
35
+ func = decorator.func
36
+ if isinstance(func, ast.Attribute):
37
+ if isinstance(func.value, ast.Name) and func.value.id == "pysealer":
38
+ is_pysealer_decorator = True
39
+ elif isinstance(func, ast.Name) and func.id.startswith("pysealer"):
40
+ is_pysealer_decorator = True
41
+ if is_pysealer_decorator:
42
+ lines_to_remove.add(decorator.lineno - 1)
43
+
44
+ found = len(lines_to_remove) > 0
45
+ for line_idx in sorted(lines_to_remove, reverse=True):
46
+ del lines[line_idx]
47
+
48
+ modified_code = '\n'.join(lines)
49
+ return modified_code, found
50
+
51
+
52
+ def remove_decorators_from_folder(folder_path: str) -> List[str]:
53
+ """
54
+ Remove pysealer decorators from all Python files in a folder (recursively).
55
+
56
+ Args:
57
+ folder_path: Path to the folder to process
58
+ Returns:
59
+ List of file paths where decorators were removed
60
+ """
61
+ folder = Path(folder_path)
62
+
63
+ if not folder.is_dir():
64
+ raise NotADirectoryError(f"'{folder_path}' is not a directory")
65
+
66
+ # Find all Python files recursively
67
+ python_files = list(folder.rglob("*.py"))
68
+
69
+ if not python_files:
70
+ raise FileNotFoundError(f"No Python files found in '{folder_path}'")
71
+
72
+ files_modified = []
73
+
74
+ for py_file in python_files:
75
+ try:
76
+ file_path = str(py_file.resolve())
77
+ modified_code, found = remove_decorators(file_path)
78
+
79
+ if found:
80
+ # Write the modified code back to the file
81
+ with open(file_path, 'w') as f:
82
+ f.write(modified_code)
83
+ files_modified.append(file_path)
84
+ except Exception as e:
85
+ # Skip files that can't be processed
86
+ continue
87
+
88
+ return files_modified