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.
- portablepy-0.1.0/LICENSE +21 -0
- portablepy-0.1.0/MANIFEST.in +1 -0
- portablepy-0.1.0/PKG-INFO +176 -0
- portablepy-0.1.0/README.md +146 -0
- portablepy-0.1.0/pyproject.toml +58 -0
- portablepy-0.1.0/setup.cfg +4 -0
- portablepy-0.1.0/src/portablepy/__init__.py +3 -0
- portablepy-0.1.0/src/portablepy/__main__.py +3 -0
- portablepy-0.1.0/src/portablepy/builder.py +199 -0
- portablepy-0.1.0/src/portablepy/bytecode.py +29 -0
- portablepy-0.1.0/src/portablepy/cli.py +175 -0
- portablepy-0.1.0/src/portablepy/config.py +128 -0
- portablepy-0.1.0/src/portablepy/discovery.py +213 -0
- portablepy-0.1.0/src/portablepy/entrypoints.py +49 -0
- portablepy-0.1.0/src/portablepy/files.py +120 -0
- portablepy-0.1.0/src/portablepy/inspection.py +178 -0
- portablepy-0.1.0/src/portablepy/launcher.py +391 -0
- portablepy-0.1.0/src/portablepy/models.py +39 -0
- portablepy-0.1.0/src/portablepy/output.py +79 -0
- portablepy-0.1.0/src/portablepy/publishing.py +65 -0
- portablepy-0.1.0/src/portablepy/shortcuts.py +54 -0
- portablepy-0.1.0/src/portablepy/sources.py +151 -0
- portablepy-0.1.0/src/portablepy/verify.py +65 -0
- portablepy-0.1.0/src/portablepy/wheels.py +201 -0
- portablepy-0.1.0/src/portablepy.egg-info/PKG-INFO +176 -0
- portablepy-0.1.0/src/portablepy.egg-info/SOURCES.txt +40 -0
- portablepy-0.1.0/src/portablepy.egg-info/dependency_links.txt +1 -0
- portablepy-0.1.0/src/portablepy.egg-info/entry_points.txt +2 -0
- portablepy-0.1.0/src/portablepy.egg-info/requires.txt +9 -0
- portablepy-0.1.0/src/portablepy.egg-info/top_level.txt +1 -0
- portablepy-0.1.0/tests/conftest.py +40 -0
- portablepy-0.1.0/tests/test_bundles.py +294 -0
- portablepy-0.1.0/tests/test_cli.py +94 -0
- portablepy-0.1.0/tests/test_config.py +84 -0
- portablepy-0.1.0/tests/test_discovery.py +124 -0
- portablepy-0.1.0/tests/test_inspection.py +38 -0
- portablepy-0.1.0/tests/test_output.py +69 -0
- portablepy-0.1.0/tests/test_regressions.py +78 -0
- portablepy-0.1.0/tests/test_shortcuts.py +141 -0
- portablepy-0.1.0/tests/test_sources.py +61 -0
- portablepy-0.1.0/tests/test_updates.py +170 -0
- portablepy-0.1.0/tests/test_wheels.py +38 -0
portablepy-0.1.0/LICENSE
ADDED
|
@@ -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,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)
|