pytriper 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
main.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import ast
|
|
5
|
+
import base64
|
|
6
|
+
import io
|
|
7
|
+
import re
|
|
8
|
+
import sys
|
|
9
|
+
import tempfile
|
|
10
|
+
import tokenize
|
|
11
|
+
import zipfile
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
IGNORED_DIRECTORIES = {
|
|
16
|
+
".git",
|
|
17
|
+
".hg",
|
|
18
|
+
".svn",
|
|
19
|
+
".venv",
|
|
20
|
+
"venv",
|
|
21
|
+
"env",
|
|
22
|
+
"__pycache__",
|
|
23
|
+
".pytest_cache",
|
|
24
|
+
".mypy_cache",
|
|
25
|
+
".ruff_cache",
|
|
26
|
+
"build",
|
|
27
|
+
"dist",
|
|
28
|
+
}
|
|
29
|
+
ENTRY_POINT_CANDIDATES = ("main.py", "app.py", "run.py", "__main__.py")
|
|
30
|
+
OBFUSCATION_MODES = ("none", "minify", "encode", "mixed")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class BuildError(Exception):
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def find_entry_point(project_dir: Path, entry: str | None) -> Path:
|
|
38
|
+
if entry:
|
|
39
|
+
candidate = (project_dir / entry).resolve()
|
|
40
|
+
if not candidate.is_relative_to(project_dir.resolve()):
|
|
41
|
+
raise BuildError("Точка входа должна находиться внутри папки проекта.")
|
|
42
|
+
if candidate.is_file() and candidate.suffix == ".py":
|
|
43
|
+
return candidate
|
|
44
|
+
raise BuildError(f"Файл точки входа не найден: {entry}")
|
|
45
|
+
|
|
46
|
+
for name in ENTRY_POINT_CANDIDATES:
|
|
47
|
+
candidate = project_dir / name
|
|
48
|
+
if candidate.is_file():
|
|
49
|
+
return candidate.resolve()
|
|
50
|
+
|
|
51
|
+
raise BuildError(
|
|
52
|
+
"Не найдена точка входа. Добавьте main.py или укажите файл через --entry."
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def collect_project_files(project_dir: Path, exclude: Path | None = None) -> list[Path]:
|
|
57
|
+
excluded_path = exclude.resolve() if exclude else None
|
|
58
|
+
files = []
|
|
59
|
+
for path in sorted(project_dir.rglob("*")):
|
|
60
|
+
relative = path.relative_to(project_dir)
|
|
61
|
+
if any(part in IGNORED_DIRECTORIES for part in relative.parts):
|
|
62
|
+
continue
|
|
63
|
+
if excluded_path and path.resolve() == excluded_path:
|
|
64
|
+
continue
|
|
65
|
+
if path.is_file() and not path.is_symlink():
|
|
66
|
+
files.append(path)
|
|
67
|
+
return files
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def obfuscate_python(path: Path, mode: str) -> bytes:
|
|
71
|
+
try:
|
|
72
|
+
with tokenize.open(str(path)) as source_file:
|
|
73
|
+
source = source_file.read()
|
|
74
|
+
except (OSError, SyntaxError, UnicodeError) as error:
|
|
75
|
+
raise BuildError(f"Не удалось прочитать {path}: {error}") from error
|
|
76
|
+
|
|
77
|
+
if mode in ("minify", "mixed"):
|
|
78
|
+
try:
|
|
79
|
+
source = ast.unparse(ast.parse(source, filename=str(path)))
|
|
80
|
+
except SyntaxError as error:
|
|
81
|
+
raise BuildError(f"Синтаксическая ошибка в {path}, строка {error.lineno}.") from error
|
|
82
|
+
|
|
83
|
+
if mode in ("encode", "mixed"):
|
|
84
|
+
encoded = base64.b85encode(source.encode("utf-8")).decode("ascii")
|
|
85
|
+
source = (
|
|
86
|
+
"exec(compile(__import__('base64').b85decode("
|
|
87
|
+
f"{encoded!r}), __file__, 'exec'))\n"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
return source.encode("utf-8")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def build_python(
|
|
94
|
+
project_dir: Path, entry_point: Path, output: Path, obfuscation: str = "none"
|
|
95
|
+
) -> None:
|
|
96
|
+
archive_buffer = io.BytesIO()
|
|
97
|
+
with zipfile.ZipFile(archive_buffer, "w", zipfile.ZIP_DEFLATED) as archive:
|
|
98
|
+
for path in collect_project_files(project_dir, exclude=output):
|
|
99
|
+
archive_path = path.relative_to(project_dir).as_posix()
|
|
100
|
+
if path.suffix == ".py" and obfuscation != "none":
|
|
101
|
+
archive.writestr(archive_path, obfuscate_python(path, obfuscation))
|
|
102
|
+
else:
|
|
103
|
+
archive.write(path, archive_path)
|
|
104
|
+
|
|
105
|
+
encoded = base64.b85encode(archive_buffer.getvalue()).decode("ascii")
|
|
106
|
+
payload_lines = "\n".join(
|
|
107
|
+
f" {encoded[index:index + 100]!r}"
|
|
108
|
+
for index in range(0, len(encoded), 100)
|
|
109
|
+
)
|
|
110
|
+
entry_relative = entry_point.relative_to(project_dir).as_posix()
|
|
111
|
+
launcher = f'''import base64
|
|
112
|
+
import io
|
|
113
|
+
import os
|
|
114
|
+
import runpy
|
|
115
|
+
import sys
|
|
116
|
+
import tempfile
|
|
117
|
+
import zipfile
|
|
118
|
+
|
|
119
|
+
_PAYLOAD = (
|
|
120
|
+
{payload_lines}
|
|
121
|
+
)
|
|
122
|
+
_ENTRY_POINT = {entry_relative!r}
|
|
123
|
+
|
|
124
|
+
with tempfile.TemporaryDirectory(prefix="pytriper-") as _directory:
|
|
125
|
+
with zipfile.ZipFile(io.BytesIO(base64.b85decode(_PAYLOAD))) as _archive:
|
|
126
|
+
_archive.extractall(_directory)
|
|
127
|
+
_entry = os.path.join(_directory, _ENTRY_POINT)
|
|
128
|
+
sys.path.insert(0, _directory)
|
|
129
|
+
sys.argv[0] = _entry
|
|
130
|
+
runpy.run_path(_entry, run_name="__main__")
|
|
131
|
+
'''
|
|
132
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
133
|
+
output.write_text(launcher, encoding="utf-8")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def make_parser() -> argparse.ArgumentParser:
|
|
137
|
+
parser = argparse.ArgumentParser(
|
|
138
|
+
prog="pytriper",
|
|
139
|
+
description="Собирает Python-проект в один .py-файл с выбранной обфускацией.",
|
|
140
|
+
)
|
|
141
|
+
parser.add_argument("project", type=Path, help="Путь к папке проекта")
|
|
142
|
+
parser.add_argument(
|
|
143
|
+
"--obfuscation",
|
|
144
|
+
choices=OBFUSCATION_MODES,
|
|
145
|
+
default="none",
|
|
146
|
+
help="Режим: none, minify, encode или mixed",
|
|
147
|
+
)
|
|
148
|
+
parser.add_argument("--entry", help="Точка входа, например src/main.py")
|
|
149
|
+
parser.add_argument("--output", type=Path, help="Путь к выходному файлу")
|
|
150
|
+
return parser
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def main(argv: list[str] | None = None) -> int:
|
|
154
|
+
args = make_parser().parse_args(argv)
|
|
155
|
+
project_dir = args.project.expanduser().resolve()
|
|
156
|
+
if not project_dir.is_dir():
|
|
157
|
+
print(f"Ошибка: папка проекта не найдена: {project_dir}", file=sys.stderr)
|
|
158
|
+
return 2
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
entry_point = find_entry_point(project_dir, args.entry)
|
|
162
|
+
default_name = re.sub(r"[^A-Za-z0-9_.-]+", "_", project_dir.name) or "app"
|
|
163
|
+
output = (
|
|
164
|
+
args.output.expanduser().resolve()
|
|
165
|
+
if args.output
|
|
166
|
+
else project_dir.parent / f"{default_name}.py"
|
|
167
|
+
)
|
|
168
|
+
if output.suffix.lower() != ".py":
|
|
169
|
+
output = output.with_suffix(".py")
|
|
170
|
+
|
|
171
|
+
build_python(project_dir, entry_point, output, args.obfuscation)
|
|
172
|
+
except (BuildError, OSError) as error:
|
|
173
|
+
print(f"Ошибка: {error}", file=sys.stderr)
|
|
174
|
+
return 1
|
|
175
|
+
|
|
176
|
+
print(f"Готово: {output}")
|
|
177
|
+
return 0
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
if __name__ == "__main__":
|
|
181
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pytriper
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Bundle and obfuscate Python projects into one .py file
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
|
|
8
|
+
# PyTriper
|
|
9
|
+
|
|
10
|
+
PyTriper bundles a Python project into one `.py` launcher and can obfuscate its Python modules. It does not build `.exe` files and has no runtime dependencies.
|
|
11
|
+
|
|
12
|
+
## Install for development
|
|
13
|
+
|
|
14
|
+
```powershell
|
|
15
|
+
python -m pip install -e .
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Build
|
|
19
|
+
|
|
20
|
+
```powershell
|
|
21
|
+
pytriper C:\path\to\project
|
|
22
|
+
pytriper C:\path\to\project --obfuscation minify
|
|
23
|
+
pytriper C:\path\to\project --obfuscation encode --entry src\main.py --output C:\build\app.py
|
|
24
|
+
pytriper C:\path\to\project --obfuscation mixed
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The entry point is detected from `main.py`, `app.py`, `run.py`, or `__main__.py` in the project root. Use `--entry` for another path. The default output is created next to the project folder.
|
|
28
|
+
|
|
29
|
+
Obfuscation modes are `none` (default), `minify` (remove comments and normalize Python syntax), `encode` (wrap Python source in a Base85 loader), and `mixed` (minify, then encode). Only `.py` files are transformed; other project files are bundled as-is.
|
|
30
|
+
|
|
31
|
+
The output extracts project files to a temporary directory when launched. It still requires a compatible Python installation and any third-party dependencies used by the project. Virtual environments and common build/cache directories are omitted.
|
|
32
|
+
|
|
33
|
+
Obfuscation is not encryption and does not prevent determined users from recovering the source. Do not package secrets such as `.env` files or private keys.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
main.py,sha256=Uf01VGa2GDoc33NHkT8RyX036Qt4Qi-zyGnz3aE2FQk,5612
|
|
2
|
+
pytriper-0.1.0.dist-info/METADATA,sha256=gVlJ1sxbNAnJl57glgF7myryFpkpBSybG7909DpPoo0,1563
|
|
3
|
+
pytriper-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
4
|
+
pytriper-0.1.0.dist-info/entry_points.txt,sha256=JNsNCAH2CHB9waFvRLB0KCBLwfgBngMKPfjnMtlSio0,39
|
|
5
|
+
pytriper-0.1.0.dist-info/top_level.txt,sha256=ZAMgPdWghn6xTRBO6Kc3ML1y3ZrZLnjZlqbboKXc_AE,5
|
|
6
|
+
pytriper-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
main
|