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/__init__.py +24 -0
- pysealer/_pysealer.cpython-313t-x86_64-linux-musl.so +0 -0
- pysealer/add_decorators.py +215 -0
- pysealer/check_decorators.py +179 -0
- pysealer/cli.py +316 -0
- pysealer/dummy_decorators.py +83 -0
- pysealer/github_secrets.py +170 -0
- pysealer/remove_decorators.py +88 -0
- pysealer/setup.py +137 -0
- pysealer-0.2.0.dist-info/METADATA +171 -0
- pysealer-0.2.0.dist-info/RECORD +15 -0
- pysealer-0.2.0.dist-info/WHEEL +4 -0
- pysealer-0.2.0.dist-info/entry_points.txt +2 -0
- pysealer-0.2.0.dist-info/licenses/LICENSE +21 -0
- pysealer.libs/libgcc_s-6d2d9dc8.so.1 +0 -0
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
|