pytriper 0.1.0__tar.gz

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.
@@ -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,26 @@
1
+ # PyTriper
2
+
3
+ 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.
4
+
5
+ ## Install for development
6
+
7
+ ```powershell
8
+ python -m pip install -e .
9
+ ```
10
+
11
+ ## Build
12
+
13
+ ```powershell
14
+ pytriper C:\path\to\project
15
+ pytriper C:\path\to\project --obfuscation minify
16
+ pytriper C:\path\to\project --obfuscation encode --entry src\main.py --output C:\build\app.py
17
+ pytriper C:\path\to\project --obfuscation mixed
18
+ ```
19
+
20
+ 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.
21
+
22
+ 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.
23
+
24
+ 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.
25
+
26
+ 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.
pytriper-0.1.0/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,16 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "pytriper"
7
+ version = "0.1.0"
8
+ description = "Bundle and obfuscate Python projects into one .py file"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+
12
+ [project.scripts]
13
+ pytriper = "main:main"
14
+
15
+ [tool.setuptools]
16
+ py-modules = ["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,9 @@
1
+ README.md
2
+ main.py
3
+ pyproject.toml
4
+ pytriper.egg-info/PKG-INFO
5
+ pytriper.egg-info/SOURCES.txt
6
+ pytriper.egg-info/dependency_links.txt
7
+ pytriper.egg-info/entry_points.txt
8
+ pytriper.egg-info/top_level.txt
9
+ tests/test_main.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pytriper = main:main
@@ -0,0 +1 @@
1
+ main
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,62 @@
1
+ import subprocess
2
+ import sys
3
+ import tempfile
4
+ import unittest
5
+ from pathlib import Path
6
+
7
+ from main import BuildError, build_python, collect_project_files, find_entry_point, make_parser
8
+
9
+
10
+ class PyTriperTests(unittest.TestCase):
11
+ def test_obfuscation_modes_run_project_and_include_local_module(self) -> None:
12
+ with tempfile.TemporaryDirectory() as temporary_dir:
13
+ root = Path(temporary_dir)
14
+ project = root / "sample"
15
+ project.mkdir()
16
+ (project / "helper.py").write_text("MESSAGE = 'bundled'\n", encoding="utf-8")
17
+ (project / "main.py").write_text(
18
+ "from helper import MESSAGE\nprint(MESSAGE)\n", encoding="utf-8"
19
+ )
20
+ for mode in ("none", "minify", "encode", "mixed"):
21
+ with self.subTest(mode=mode):
22
+ output = root / f"sample-{mode}.py"
23
+ build_python(project, project / "main.py", output, mode)
24
+ result = subprocess.run(
25
+ [sys.executable, str(output)],
26
+ capture_output=True,
27
+ text=True,
28
+ check=False,
29
+ )
30
+ self.assertEqual(result.returncode, 0, result.stderr)
31
+ self.assertEqual(result.stdout.strip(), "bundled")
32
+
33
+ def test_executable_format_is_not_available(self) -> None:
34
+ with self.assertRaises(SystemExit):
35
+ make_parser().parse_args(["project", "--format", "exe"])
36
+
37
+ def test_entry_point_must_be_inside_project(self) -> None:
38
+ with tempfile.TemporaryDirectory() as temporary_dir:
39
+ root = Path(temporary_dir)
40
+ project = root / "sample"
41
+ project.mkdir()
42
+ outside_entry = root / "main.py"
43
+ outside_entry.write_text("pass\n", encoding="utf-8")
44
+
45
+ with self.assertRaises(BuildError):
46
+ find_entry_point(project, "../main.py")
47
+
48
+ def test_existing_output_is_not_collected_into_project(self) -> None:
49
+ with tempfile.TemporaryDirectory() as temporary_dir:
50
+ project = Path(temporary_dir)
51
+ source = project / "main.py"
52
+ output = project / "bundle.py"
53
+ source.write_text("pass\n", encoding="utf-8")
54
+ output.write_text("old bundle\n", encoding="utf-8")
55
+
56
+ files = collect_project_files(project, exclude=output)
57
+
58
+ self.assertEqual(files, [source])
59
+
60
+
61
+ if __name__ == "__main__":
62
+ unittest.main()