portablepy 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.
Files changed (42) hide show
  1. portablepy-0.1.0/LICENSE +21 -0
  2. portablepy-0.1.0/MANIFEST.in +1 -0
  3. portablepy-0.1.0/PKG-INFO +176 -0
  4. portablepy-0.1.0/README.md +146 -0
  5. portablepy-0.1.0/pyproject.toml +58 -0
  6. portablepy-0.1.0/setup.cfg +4 -0
  7. portablepy-0.1.0/src/portablepy/__init__.py +3 -0
  8. portablepy-0.1.0/src/portablepy/__main__.py +3 -0
  9. portablepy-0.1.0/src/portablepy/builder.py +199 -0
  10. portablepy-0.1.0/src/portablepy/bytecode.py +29 -0
  11. portablepy-0.1.0/src/portablepy/cli.py +175 -0
  12. portablepy-0.1.0/src/portablepy/config.py +128 -0
  13. portablepy-0.1.0/src/portablepy/discovery.py +213 -0
  14. portablepy-0.1.0/src/portablepy/entrypoints.py +49 -0
  15. portablepy-0.1.0/src/portablepy/files.py +120 -0
  16. portablepy-0.1.0/src/portablepy/inspection.py +178 -0
  17. portablepy-0.1.0/src/portablepy/launcher.py +391 -0
  18. portablepy-0.1.0/src/portablepy/models.py +39 -0
  19. portablepy-0.1.0/src/portablepy/output.py +79 -0
  20. portablepy-0.1.0/src/portablepy/publishing.py +65 -0
  21. portablepy-0.1.0/src/portablepy/shortcuts.py +54 -0
  22. portablepy-0.1.0/src/portablepy/sources.py +151 -0
  23. portablepy-0.1.0/src/portablepy/verify.py +65 -0
  24. portablepy-0.1.0/src/portablepy/wheels.py +201 -0
  25. portablepy-0.1.0/src/portablepy.egg-info/PKG-INFO +176 -0
  26. portablepy-0.1.0/src/portablepy.egg-info/SOURCES.txt +40 -0
  27. portablepy-0.1.0/src/portablepy.egg-info/dependency_links.txt +1 -0
  28. portablepy-0.1.0/src/portablepy.egg-info/entry_points.txt +2 -0
  29. portablepy-0.1.0/src/portablepy.egg-info/requires.txt +9 -0
  30. portablepy-0.1.0/src/portablepy.egg-info/top_level.txt +1 -0
  31. portablepy-0.1.0/tests/conftest.py +40 -0
  32. portablepy-0.1.0/tests/test_bundles.py +294 -0
  33. portablepy-0.1.0/tests/test_cli.py +94 -0
  34. portablepy-0.1.0/tests/test_config.py +84 -0
  35. portablepy-0.1.0/tests/test_discovery.py +124 -0
  36. portablepy-0.1.0/tests/test_inspection.py +38 -0
  37. portablepy-0.1.0/tests/test_output.py +69 -0
  38. portablepy-0.1.0/tests/test_regressions.py +78 -0
  39. portablepy-0.1.0/tests/test_shortcuts.py +141 -0
  40. portablepy-0.1.0/tests/test_sources.py +61 -0
  41. portablepy-0.1.0/tests/test_updates.py +170 -0
  42. portablepy-0.1.0/tests/test_wheels.py +38 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Caprine Logic
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ recursive-include tests *.py
@@ -0,0 +1,176 @@
1
+ Metadata-Version: 2.4
2
+ Name: portablepy
3
+ Version: 0.1.0
4
+ Summary: Build portable Python application bundles with verified offline wheels
5
+ Author: depthbomb
6
+ License-Expression: MIT
7
+ Project-URL: Documentation, https://github.com/depthbomb/portablepy#readme
8
+ Project-URL: Source, https://github.com/depthbomb/portablepy
9
+ Project-URL: Issues, https://github.com/depthbomb/portablepy/issues
10
+ Project-URL: Releases, https://github.com/depthbomb/portablepy/releases
11
+ Keywords: python,portable,bundles,packaging,offline,wheels
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Environment :: Console
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Requires-Python: >=3.14
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Requires-Dist: argly<1,>=0.1
22
+ Provides-Extra: dev
23
+ Requires-Dist: build>=1.2; extra == "dev"
24
+ Requires-Dist: pytest>=8; extra == "dev"
25
+ Requires-Dist: pytest-cov>=6; extra == "dev"
26
+ Requires-Dist: ruff>=0.12; extra == "dev"
27
+ Requires-Dist: mypy>=1.18; extra == "dev"
28
+ Requires-Dist: twine>=6; extra == "dev"
29
+ Dynamic: license-file
30
+
31
+ # portablepy
32
+
33
+ Bundle a Python app into a ZIP or tar.gz with its dependencies and a launcher. Share the archive, extract it, and run it. The bundle sets up its own environment and installs dependencies offline.
34
+
35
+ Python itself isn't included. The person running the app needs a matching CPython version, operating system, and architecture.
36
+
37
+ ## Install
38
+
39
+ Requires CPython 3.14 or later. Install it in a virtual environment:
40
+
41
+ ```sh
42
+ python -m pip install portablepy
43
+ ```
44
+
45
+ ## Build an app
46
+
47
+ ```sh
48
+ portablepy build ./my-app --run "python -m my_app"
49
+ portablepy build ./script.py --run "python script.py" --output app.zip
50
+ portablepy build ./my_app-1.0-py3-none-any.whl --run "my-console-command"
51
+ ```
52
+
53
+ Build on the operating system and architecture your users have. portablepy uses the app's `.venv` when available, or its own Python interpreter otherwise. Use `--python PATH` to choose one.
54
+
55
+ Without `--output`, the filename includes the app name, platform, architecture, and Python version. Windows gets a ZIP; Linux and macOS get a tar.gz. Use `--replace` to replace an existing archive after the new build passes validation.
56
+
57
+ ## Run the bundle
58
+
59
+ Extract the whole archive somewhere writable, then double-click `run.cmd` on Windows or `run.command` on macOS. Linux bundles include `run.sh`; your file manager may need permission to run executable scripts.
60
+
61
+ You can also use a terminal:
62
+
63
+ ```sh
64
+ python run.py
65
+ python run.py --help
66
+ ```
67
+
68
+ Arguments go straight to your app. With `--compile all`, the launcher is `run.pyc`; use `python run.pyc` instead. The double-click launcher picks the right file automatically.
69
+
70
+ The first launch checks the bundled files and creates a private `.venv` using the included wheels. Later launches reuse it. Moving the bundle or updating it rebuilds the environment as needed. Python needs its standard `venv` and `ensurepip` modules.
71
+
72
+ If extraction removes executable permissions on macOS or Linux, run `chmod +x run.command` or `chmod +x run.sh`.
73
+
74
+ ## Dependencies and files
75
+
76
+ For packaged projects and wheels, portablepy reads the package's dependency metadata. For scripts, it follows local imports and looks up dependencies in the selected Python environment. Install your app's dependencies there before building.
77
+
78
+ A `requirements.txt` beside a script or in a loose app directory supplies explicit dependencies. You can also pass them yourself:
79
+
80
+ ```sh
81
+ portablepy build ./my-app --run "python main.py" --requirements requirements.txt
82
+ portablepy build ./my-app --run "python -m my_app" --extra cli
83
+ portablepy build ./script.py --run "python script.py" --requirement "requests>=2"
84
+ ```
85
+
86
+ Explicit requirements replace import-based dependency detection. Dynamic imports and plugins may need this. Use `--no-index --find-links ./wheels` to build from local packages only.
87
+
88
+ The bundle includes the selected app's local helpers and resources. Common cache, environment, build, and editor files are excluded, along with `.env` files. Add `--exclude` patterns to leave out other files.
89
+
90
+ To see what's included before building:
91
+
92
+ ```sh
93
+ portablepy inspect ./my-app --run "python -m my_app"
94
+ portablepy inspect ./my-app --run "python -m my_app" --resolve
95
+ ```
96
+
97
+ `inspect` reports files, dependencies, and estimated size as JSON. `--resolve` also downloads or builds the wheels for a fuller report. Building checks wheel integrity and tests installation in a clean offline environment; it doesn't start your app.
98
+
99
+ ## Keep writable data
100
+
101
+ Use `--include SOURCE=data/DESTINATION` for default settings, databases, or other files your app changes:
102
+
103
+ ```sh
104
+ portablepy build ./my-app --run "python -m my_app --config {data}/settings.json" --include "settings.json=data/settings.json"
105
+ ```
106
+
107
+ Files and directories both work. Defaults live in `seeds/` and are copied into `data/` only when missing. Extracting an update into the same bundle folder keeps existing data. When moving to a fresh folder, copy `data/` over before launching.
108
+
109
+ Launch commands support `{bundle}`, `{app}`, `{data}`, `{python}`, and `{bin}` placeholders. The app runs from its application folder, which is named after a content hash. Use `{app}` instead of hardcoding that folder name.
110
+
111
+ Commands are argument lists, so use forward slashes for paths and quote arguments containing spaces. Shell pipelines and activation commands aren't supported.
112
+
113
+ ## Save build settings
114
+
115
+ Put defaults and optional profiles in `pyproject.toml`:
116
+
117
+ ```toml
118
+ [tool.portablepy]
119
+ source = 'my-app'
120
+ run = 'python -m my_app --config {data}/settings.json'
121
+ include = ['defaults/settings.json=data/settings.json']
122
+
123
+ [tool.portablepy.profiles.release]
124
+ compile = 'all'
125
+ strip-source = true
126
+ ```
127
+
128
+ ```sh
129
+ portablepy build
130
+ portablepy build --profile release
131
+ portablepy inspect --profile release --resolve
132
+ ```
133
+
134
+ portablepy looks for the nearest `pyproject.toml`, starting from your source or current directory. Use `--config PATH` to choose one. Configured paths are relative to that file; the default output stays in your current directory.
135
+
136
+ Profiles override defaults, and command-line options override profiles. Lists replace earlier lists. Use `--keep-source`, `--use-index`, or `--no-replace` to turn off those profile settings.
137
+
138
+ ## Compile to bytecode
139
+
140
+ ```sh
141
+ portablepy build ./my-app --run "python -m my_app" --compile all --strip-source
142
+ ```
143
+
144
+ - `--compile app` compiles your application files.
145
+ - `--compile all` also compiles dependency wheels and creates `run.pyc`.
146
+ - `--strip-source` removes compiled `.py` files, including `run.py` with `all`. Sources stay by default.
147
+
148
+ Compilation keeps assertions and docstrings at optimization level 0. Native extensions, resources, and dependency licenses stay in the bundle. Some packages need their source files at runtime, so test your app before distributing a source-stripped build. Bytecode is specific to the Python version and doesn't hide your code securely.
149
+
150
+ ## Check a bundle
151
+
152
+ ```sh
153
+ portablepy verify app.zip
154
+ portablepy verify ./extracted-app
155
+ python run.py --portable-info
156
+ python run.py --portable-setup
157
+ python run.py --portable-verify
158
+ ```
159
+
160
+ Use `run.pyc` for bundles built with `--compile all`.
161
+
162
+ `--portable-info` shows bundle details without setting anything up. `--portable-setup` prepares the environment without starting the app. `--portable-verify` checks bundled files and leaves writable data alone.
163
+
164
+ Each archive includes checksums and gets a `.sha256` sidecar. These detect damaged or changed files; they don't prove who published the bundle.
165
+
166
+ ## Development
167
+
168
+ Create a `.venv` with `python -m venv .venv`. Activate it with `.venv\Scripts\Activate.ps1` in PowerShell or `source .venv/bin/activate` in a Unix shell, then run:
169
+
170
+ ```sh
171
+ python -m pip install -e ".[dev]"
172
+ python -m pytest --cov=portablepy --cov-branch
173
+ python -m ruff check .
174
+ python -m ruff format --check .
175
+ python -m mypy
176
+ ```
@@ -0,0 +1,146 @@
1
+ # portablepy
2
+
3
+ Bundle a Python app into a ZIP or tar.gz with its dependencies and a launcher. Share the archive, extract it, and run it. The bundle sets up its own environment and installs dependencies offline.
4
+
5
+ Python itself isn't included. The person running the app needs a matching CPython version, operating system, and architecture.
6
+
7
+ ## Install
8
+
9
+ Requires CPython 3.14 or later. Install it in a virtual environment:
10
+
11
+ ```sh
12
+ python -m pip install portablepy
13
+ ```
14
+
15
+ ## Build an app
16
+
17
+ ```sh
18
+ portablepy build ./my-app --run "python -m my_app"
19
+ portablepy build ./script.py --run "python script.py" --output app.zip
20
+ portablepy build ./my_app-1.0-py3-none-any.whl --run "my-console-command"
21
+ ```
22
+
23
+ Build on the operating system and architecture your users have. portablepy uses the app's `.venv` when available, or its own Python interpreter otherwise. Use `--python PATH` to choose one.
24
+
25
+ Without `--output`, the filename includes the app name, platform, architecture, and Python version. Windows gets a ZIP; Linux and macOS get a tar.gz. Use `--replace` to replace an existing archive after the new build passes validation.
26
+
27
+ ## Run the bundle
28
+
29
+ Extract the whole archive somewhere writable, then double-click `run.cmd` on Windows or `run.command` on macOS. Linux bundles include `run.sh`; your file manager may need permission to run executable scripts.
30
+
31
+ You can also use a terminal:
32
+
33
+ ```sh
34
+ python run.py
35
+ python run.py --help
36
+ ```
37
+
38
+ Arguments go straight to your app. With `--compile all`, the launcher is `run.pyc`; use `python run.pyc` instead. The double-click launcher picks the right file automatically.
39
+
40
+ The first launch checks the bundled files and creates a private `.venv` using the included wheels. Later launches reuse it. Moving the bundle or updating it rebuilds the environment as needed. Python needs its standard `venv` and `ensurepip` modules.
41
+
42
+ If extraction removes executable permissions on macOS or Linux, run `chmod +x run.command` or `chmod +x run.sh`.
43
+
44
+ ## Dependencies and files
45
+
46
+ For packaged projects and wheels, portablepy reads the package's dependency metadata. For scripts, it follows local imports and looks up dependencies in the selected Python environment. Install your app's dependencies there before building.
47
+
48
+ A `requirements.txt` beside a script or in a loose app directory supplies explicit dependencies. You can also pass them yourself:
49
+
50
+ ```sh
51
+ portablepy build ./my-app --run "python main.py" --requirements requirements.txt
52
+ portablepy build ./my-app --run "python -m my_app" --extra cli
53
+ portablepy build ./script.py --run "python script.py" --requirement "requests>=2"
54
+ ```
55
+
56
+ Explicit requirements replace import-based dependency detection. Dynamic imports and plugins may need this. Use `--no-index --find-links ./wheels` to build from local packages only.
57
+
58
+ The bundle includes the selected app's local helpers and resources. Common cache, environment, build, and editor files are excluded, along with `.env` files. Add `--exclude` patterns to leave out other files.
59
+
60
+ To see what's included before building:
61
+
62
+ ```sh
63
+ portablepy inspect ./my-app --run "python -m my_app"
64
+ portablepy inspect ./my-app --run "python -m my_app" --resolve
65
+ ```
66
+
67
+ `inspect` reports files, dependencies, and estimated size as JSON. `--resolve` also downloads or builds the wheels for a fuller report. Building checks wheel integrity and tests installation in a clean offline environment; it doesn't start your app.
68
+
69
+ ## Keep writable data
70
+
71
+ Use `--include SOURCE=data/DESTINATION` for default settings, databases, or other files your app changes:
72
+
73
+ ```sh
74
+ portablepy build ./my-app --run "python -m my_app --config {data}/settings.json" --include "settings.json=data/settings.json"
75
+ ```
76
+
77
+ Files and directories both work. Defaults live in `seeds/` and are copied into `data/` only when missing. Extracting an update into the same bundle folder keeps existing data. When moving to a fresh folder, copy `data/` over before launching.
78
+
79
+ Launch commands support `{bundle}`, `{app}`, `{data}`, `{python}`, and `{bin}` placeholders. The app runs from its application folder, which is named after a content hash. Use `{app}` instead of hardcoding that folder name.
80
+
81
+ Commands are argument lists, so use forward slashes for paths and quote arguments containing spaces. Shell pipelines and activation commands aren't supported.
82
+
83
+ ## Save build settings
84
+
85
+ Put defaults and optional profiles in `pyproject.toml`:
86
+
87
+ ```toml
88
+ [tool.portablepy]
89
+ source = 'my-app'
90
+ run = 'python -m my_app --config {data}/settings.json'
91
+ include = ['defaults/settings.json=data/settings.json']
92
+
93
+ [tool.portablepy.profiles.release]
94
+ compile = 'all'
95
+ strip-source = true
96
+ ```
97
+
98
+ ```sh
99
+ portablepy build
100
+ portablepy build --profile release
101
+ portablepy inspect --profile release --resolve
102
+ ```
103
+
104
+ portablepy looks for the nearest `pyproject.toml`, starting from your source or current directory. Use `--config PATH` to choose one. Configured paths are relative to that file; the default output stays in your current directory.
105
+
106
+ Profiles override defaults, and command-line options override profiles. Lists replace earlier lists. Use `--keep-source`, `--use-index`, or `--no-replace` to turn off those profile settings.
107
+
108
+ ## Compile to bytecode
109
+
110
+ ```sh
111
+ portablepy build ./my-app --run "python -m my_app" --compile all --strip-source
112
+ ```
113
+
114
+ - `--compile app` compiles your application files.
115
+ - `--compile all` also compiles dependency wheels and creates `run.pyc`.
116
+ - `--strip-source` removes compiled `.py` files, including `run.py` with `all`. Sources stay by default.
117
+
118
+ Compilation keeps assertions and docstrings at optimization level 0. Native extensions, resources, and dependency licenses stay in the bundle. Some packages need their source files at runtime, so test your app before distributing a source-stripped build. Bytecode is specific to the Python version and doesn't hide your code securely.
119
+
120
+ ## Check a bundle
121
+
122
+ ```sh
123
+ portablepy verify app.zip
124
+ portablepy verify ./extracted-app
125
+ python run.py --portable-info
126
+ python run.py --portable-setup
127
+ python run.py --portable-verify
128
+ ```
129
+
130
+ Use `run.pyc` for bundles built with `--compile all`.
131
+
132
+ `--portable-info` shows bundle details without setting anything up. `--portable-setup` prepares the environment without starting the app. `--portable-verify` checks bundled files and leaves writable data alone.
133
+
134
+ Each archive includes checksums and gets a `.sha256` sidecar. These detect damaged or changed files; they don't prove who published the bundle.
135
+
136
+ ## Development
137
+
138
+ Create a `.venv` with `python -m venv .venv`. Activate it with `.venv\Scripts\Activate.ps1` in PowerShell or `source .venv/bin/activate` in a Unix shell, then run:
139
+
140
+ ```sh
141
+ python -m pip install -e ".[dev]"
142
+ python -m pytest --cov=portablepy --cov-branch
143
+ python -m ruff check .
144
+ python -m ruff format --check .
145
+ python -m mypy
146
+ ```
@@ -0,0 +1,58 @@
1
+ [build-system]
2
+ requires = ['setuptools>=77']
3
+ build-backend = 'setuptools.build_meta'
4
+
5
+ [project]
6
+ name = 'portablepy'
7
+ version = '0.1.0'
8
+ description = 'Build portable Python application bundles with verified offline wheels'
9
+ readme = 'README.md'
10
+ requires-python = '>=3.14'
11
+ license = 'MIT'
12
+ license-files = ['LICENSE']
13
+ dependencies = ['argly>=0.1,<1']
14
+ authors = [{ name = 'depthbomb' }]
15
+ keywords = ['python', 'portable', 'bundles', 'packaging', 'offline', 'wheels']
16
+ classifiers = [
17
+ 'Development Status :: 3 - Alpha',
18
+ 'Intended Audience :: Developers',
19
+ 'Environment :: Console',
20
+ 'Operating System :: OS Independent',
21
+ 'Programming Language :: Python :: 3',
22
+ 'Programming Language :: Python :: 3.14',
23
+ ]
24
+
25
+ [project.urls]
26
+ Documentation = 'https://github.com/depthbomb/portablepy#readme'
27
+ Source = 'https://github.com/depthbomb/portablepy'
28
+ Issues = 'https://github.com/depthbomb/portablepy/issues'
29
+ Releases = 'https://github.com/depthbomb/portablepy/releases'
30
+
31
+ [project.scripts]
32
+ portablepy = 'portablepy.cli:main'
33
+
34
+ [project.optional-dependencies]
35
+ dev = ['build>=1.2', 'pytest>=8', 'pytest-cov>=6', 'ruff>=0.12', 'mypy>=1.18', 'twine>=6']
36
+
37
+ [tool.setuptools.packages.find]
38
+ where = ['src']
39
+
40
+ [tool.pytest.ini_options]
41
+ testpaths = ['tests']
42
+ addopts = '-ra --strict-config --strict-markers'
43
+
44
+ [tool.ruff]
45
+ target-version = 'py314'
46
+ line-length = 100
47
+
48
+ [tool.ruff.lint]
49
+ select = ['E4', 'E7', 'E9', 'F', 'B']
50
+
51
+ [tool.ruff.format]
52
+ quote-style = 'single'
53
+
54
+ [tool.mypy]
55
+ python_version = '3.14'
56
+ files = ['src/portablepy']
57
+ check_untyped_defs = true
58
+ warn_unused_ignores = true
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """Portable application bundles with verified wheels and an offline launcher."""
2
+
3
+ __version__ = '0.1.0'
@@ -0,0 +1,3 @@
1
+ from portablepy.cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,199 @@
1
+ """Assemble, validate, and archive a portable application."""
2
+
3
+ from json import dumps
4
+ from pathlib import Path
5
+ from re import fullmatch
6
+ from subprocess import run
7
+ from tarfile import open as open_tar
8
+ from importlib.resources import files
9
+ from tempfile import TemporaryDirectory
10
+ from portablepy.discovery import discover
11
+ from zipfile import ZipFile, ZIP_DEFLATED
12
+ from portablepy.models import BuildOptions
13
+ from portablepy.bytecode import compile_tree
14
+ from portablepy.shortcuts import write_shortcut
15
+ from portablepy.publishing import publish_archive
16
+ from portablepy.files import copy_sources, include_data
17
+ from portablepy.output import default_output, output_excludes, validate_output
18
+ from portablepy.launcher import MANIFEST, file_hash, contents_hash, SCHEMA_VERSION
19
+ from portablepy.wheels import collect_wheels, repack_bytecode, wheel_inventory, write_requirements
20
+
21
+ RUNTIME_FIELDS = (
22
+ 'implementation',
23
+ 'version',
24
+ 'platform',
25
+ 'machine',
26
+ 'bits',
27
+ 'free_threaded',
28
+ 'cache_tag',
29
+ 'magic',
30
+ )
31
+ INSTRUCTIONS = """Portable Python application
32
+
33
+ Extract this entire folder somewhere writable. Python itself is not included.
34
+ Run: python run.py
35
+ Extra arguments are forwarded to the application: python run.py --help
36
+
37
+ You can also use the included console launcher: run.cmd on Windows,
38
+ run.command on macOS, or run.sh on Linux. Double-click it to start; Linux
39
+ file managers may require enabling executable scripts or choosing Run.
40
+ If extraction removed executable permissions, run chmod +x run.command
41
+ or chmod +x run.sh. Arguments supplied in a terminal are forwarded.
42
+ Failed launches wait for a key/Enter when started without arguments
43
+ (on Unix, only with an interactive terminal).
44
+
45
+ The first launch installs the bundled wheels into a private .venv without
46
+ network access. Use the matching CPython version and platform in bundle.json.
47
+ Your Python installation needs the standard venv and ensurepip modules.
48
+
49
+ Writable files live in data/. Defaults ship in seeds/ and are copied only when
50
+ missing. Extract updates into the same folder to keep your data/.
51
+ Moving the folder or changing the bundle rebuilds only the private environment.
52
+ The application directory is named with a SHA-256 hash of its files and paths.
53
+ The launcher finds it automatically; its name is recorded in bundle.json.
54
+
55
+ python run.py --portable-info Show bundle metadata without setup
56
+ python run.py --portable-setup Set up without starting the application
57
+ python run.py --portable-verify Verify immutable files without starting it
58
+
59
+ Wheel versions and SHA-256 hashes are pinned in requirements.txt. Checksums
60
+ detect corruption; they are not publisher signatures. Licenses for dependencies
61
+ are retained in their wheels. Bytecode is version-specific, not encryption.
62
+ """
63
+
64
+
65
+ def _python_command(command):
66
+ if not command:
67
+ raise ValueError('Provide the application command with --run')
68
+ first = command[0]
69
+ if any(argument in ('&&', '||', '|', '>', '<', ';') for argument in command):
70
+ raise ValueError('Commands are argument lists, not shell scripts')
71
+ if first == '{python}' or fullmatch(r'python(?:3(?:\.\d+)?)?(?:\.exe)?', first):
72
+ return True
73
+ if Path(first).name != first or '/' in first or '\\' in first:
74
+ raise ValueError('Use python, {python}, or an installed console command as the executable')
75
+ return False
76
+
77
+
78
+ def build_bundle(options: BuildOptions) -> Path:
79
+ if options.compile_mode not in ('none', 'app', 'all'):
80
+ raise ValueError('--compile must be none, app, or all')
81
+ if options.strip_source and options.compile_mode == 'none':
82
+ raise ValueError('--strip-source requires --compile app or --compile all')
83
+ python_command = _python_command(options.command)
84
+ output = options.output.expanduser().absolute() if options.output is not None else None
85
+ if output is not None:
86
+ validate_output(output, replace=options.replace)
87
+ discovery = discover(options)
88
+ if output is None:
89
+ output = default_output(discovery, options.command)
90
+ validate_output(output, replace=options.replace)
91
+ if discovery.unresolved:
92
+ raise ValueError(
93
+ 'Unresolved or ambiguous imports: '
94
+ + ', '.join(discovery.unresolved)
95
+ + '. Install them in the selected environment, or declare dependencies with --requirement/--requirements.'
96
+ )
97
+ print(f'Using {discovery.python}; dependency source: {discovery.mode}', flush=True)
98
+ name = output.name.removesuffix('.tar.gz').removesuffix('.zip')
99
+ if not fullmatch(r'[A-Za-z0-9][A-Za-z0-9._-]*', name):
100
+ raise ValueError('Archive name must use letters, numbers, dots, underscores, or hyphens')
101
+ with TemporaryDirectory(prefix='portablepy-build-') as temporary:
102
+ work = Path(temporary)
103
+ bundle = work / name
104
+ app = bundle / 'app'
105
+ app.mkdir(parents=True)
106
+ (bundle / 'data').mkdir()
107
+ source_copy = work / 'source'
108
+ if discovery.mode == 'project':
109
+ copy_sources(
110
+ discovery.source,
111
+ source_copy,
112
+ (*options.excludes, *output_excludes(discovery.source, output)),
113
+ )
114
+ else:
115
+ source_copy.mkdir()
116
+ copy_sources(discovery.source, app, paths=discovery.application_files)
117
+ wheels = bundle / 'wheels'
118
+ collect_wheels(discovery, options, wheels, source_copy)
119
+ if options.compile_mode != 'none':
120
+ compile_tree(app, discovery.python, strip=options.strip_source)
121
+ if options.compile_mode == 'all':
122
+ for wheel in sorted(wheels.glob('*.whl')):
123
+ repack_bytecode(
124
+ wheel, discovery.python, discovery.runtime, strip=options.strip_source
125
+ )
126
+ app_directory = contents_hash(
127
+ {
128
+ path.relative_to(app).as_posix(): file_hash(path)
129
+ for path in app.rglob('*')
130
+ if path.is_file()
131
+ }
132
+ )
133
+ renamed = bundle / app_directory
134
+ if not app.resolve().is_relative_to(
135
+ bundle.resolve()
136
+ ) or not renamed.resolve().is_relative_to(bundle.resolve()):
137
+ raise ValueError('Application directory must stay inside the bundle')
138
+ app.rename(renamed)
139
+ count = write_requirements(wheels, bundle / 'requirements.txt')
140
+ base = discovery.source if discovery.source.is_dir() else discovery.source.parent
141
+ seeds = include_data(options.includes, base, bundle)
142
+ (bundle / 'run.py').write_bytes(files('portablepy').joinpath('launcher.py').read_bytes())
143
+ compiled_launcher = options.compile_mode == 'all'
144
+ launcher = 'run.pyc' if compiled_launcher else 'run.py'
145
+ if compiled_launcher:
146
+ compile_tree(bundle / 'run.py', discovery.python, strip=options.strip_source)
147
+ shortcut = write_shortcut(bundle, discovery.runtime, compiled=compiled_launcher)
148
+ (bundle / 'README.txt').write_text(
149
+ INSTRUCTIONS.replace('run.py', launcher), encoding='utf-8'
150
+ )
151
+ checksums = {
152
+ path.relative_to(bundle).as_posix(): file_hash(path)
153
+ for path in sorted(bundle.rglob('*'))
154
+ if path.is_file()
155
+ }
156
+ manifest = {
157
+ 'schema': SCHEMA_VERSION,
158
+ 'name': name,
159
+ 'app_directory': app_directory,
160
+ 'runtime': {key: discovery.runtime[key] for key in RUNTIME_FIELDS},
161
+ 'command': list(options.command),
162
+ 'python_command': python_command,
163
+ 'prefer_installed': discovery.mode in ('project', 'wheel'),
164
+ 'compile': options.compile_mode,
165
+ 'strip_source': options.strip_source,
166
+ 'files': checksums,
167
+ 'seed_files': seeds,
168
+ 'dependencies': wheel_inventory(wheels),
169
+ 'profile': options.profile,
170
+ }
171
+ manifest['build_id'] = contents_hash(manifest)
172
+ (bundle / MANIFEST).write_text(dumps(manifest, indent=2) + '\n', encoding='utf-8')
173
+ (bundle / f'{MANIFEST}.sha256').write_text(
174
+ file_hash(bundle / MANIFEST) + '\n', encoding='utf-8'
175
+ )
176
+ print(f'Validating {count} wheels in an offline environment...', flush=True)
177
+ run([str(discovery.python), '-I', str(bundle / launcher), '--portable-setup'], check=True)
178
+ members = [*checksums, MANIFEST, f'{MANIFEST}.sha256']
179
+ output.parent.mkdir(parents=True, exist_ok=True)
180
+ # Publish only after validation; exclude the generated environment entirely.
181
+ staged = work / output.name
182
+ if output.name.endswith('.zip'):
183
+ with ZipFile(staged, 'w', compression=ZIP_DEFLATED) as archive:
184
+ for relative in sorted(members):
185
+ archive.write(bundle / relative, f'{name}/{relative}')
186
+ if relative == shortcut and shortcut != 'run.cmd':
187
+ entry = archive.getinfo(f'{name}/{relative}')
188
+ entry.create_system = 3
189
+ entry.external_attr = 0o100755 << 16
190
+ else:
191
+ with open_tar(staged, 'w:gz') as archive:
192
+ for relative in sorted(members):
193
+ member = archive.gettarinfo(bundle / relative, arcname=f'{name}/{relative}')
194
+ if relative == shortcut and shortcut != 'run.cmd':
195
+ member.mode = 0o755
196
+ with (bundle / relative).open('rb') as stream:
197
+ archive.addfile(member, stream)
198
+ publish_archive(staged, output, replace=options.replace)
199
+ return output
@@ -0,0 +1,29 @@
1
+ """Compile application and wheel sources with the selected interpreter."""
2
+
3
+ from pathlib import Path
4
+ from subprocess import run
5
+
6
+ COMPILE = """
7
+ from sys import argv
8
+ from pathlib import Path
9
+ from py_compile import compile, PycInvalidationMode
10
+ from importlib.util import cache_from_source
11
+ root, strip = Path(argv[1]), argv[2] == 'strip'
12
+ single = root.is_file()
13
+ for path in [root] if single else sorted(root.rglob('*.py')):
14
+ relative = Path(path.name) if single else path.relative_to(root)
15
+ if '__pycache__' in relative.parts or any(part.endswith('.dist-info') for part in relative.parts):
16
+ continue
17
+ if any(part.endswith('.data') for part in relative.parts) and 'scripts' in relative.parts:
18
+ continue
19
+ output = str(path.with_suffix('.pyc')) if strip or single else cache_from_source(str(path))
20
+ compile(str(path), cfile=output, dfile=relative.as_posix(), doraise=True,
21
+ invalidation_mode=PycInvalidationMode.CHECKED_HASH)
22
+ if strip:
23
+ path.unlink()
24
+ """
25
+
26
+
27
+ def compile_tree(root: Path, python: Path, *, strip=False):
28
+ """Compile a directory, or a single launcher to an adjacent executable .pyc."""
29
+ run([str(python), '-I', '-c', COMPILE, str(root), 'strip' if strip else 'keep'], check=True)