portablepy 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.
- portablepy/__init__.py +3 -0
- portablepy/__main__.py +3 -0
- portablepy/builder.py +199 -0
- portablepy/bytecode.py +29 -0
- portablepy/cli.py +175 -0
- portablepy/config.py +128 -0
- portablepy/discovery.py +213 -0
- portablepy/entrypoints.py +49 -0
- portablepy/files.py +120 -0
- portablepy/inspection.py +178 -0
- portablepy/launcher.py +391 -0
- portablepy/models.py +39 -0
- portablepy/output.py +79 -0
- portablepy/publishing.py +65 -0
- portablepy/shortcuts.py +54 -0
- portablepy/sources.py +151 -0
- portablepy/verify.py +65 -0
- portablepy/wheels.py +201 -0
- portablepy-0.1.0.dist-info/METADATA +176 -0
- portablepy-0.1.0.dist-info/RECORD +24 -0
- portablepy-0.1.0.dist-info/WHEEL +5 -0
- portablepy-0.1.0.dist-info/entry_points.txt +2 -0
- portablepy-0.1.0.dist-info/licenses/LICENSE +21 -0
- portablepy-0.1.0.dist-info/top_level.txt +1 -0
portablepy/__init__.py
ADDED
portablepy/__main__.py
ADDED
portablepy/builder.py
ADDED
|
@@ -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
|
portablepy/bytecode.py
ADDED
|
@@ -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)
|
portablepy/cli.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Argly commands for building and checking bundles."""
|
|
2
|
+
|
|
3
|
+
from json import dumps
|
|
4
|
+
from sys import stderr
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from tarfile import TarError
|
|
7
|
+
from zipfile import BadZipFile
|
|
8
|
+
from subprocess import CalledProcessError
|
|
9
|
+
from portablepy.builder import build_bundle
|
|
10
|
+
from portablepy.verify import verify_bundle
|
|
11
|
+
from typing import Any, Optional, Annotated
|
|
12
|
+
from portablepy.config import resolve_options
|
|
13
|
+
from portablepy.inspection import inspection_report
|
|
14
|
+
from argly import App, Flag, Option, command, Argument
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
EMPTY_OPTIONS: Any = () # Argly accepts tuples as defaults and passes fresh lists to handlers.
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@command('build', summary='Build a verified portable application archive.')
|
|
21
|
+
def build(
|
|
22
|
+
source: Annotated[Optional[Path], Argument()] = None,
|
|
23
|
+
*,
|
|
24
|
+
run: Annotated[
|
|
25
|
+
Optional[str], Option('--command', help='Application command, such as python -m my_app.')
|
|
26
|
+
] = None,
|
|
27
|
+
profile: Annotated[
|
|
28
|
+
Optional[str], Option(help='Named build profile from pyproject.toml.')
|
|
29
|
+
] = None,
|
|
30
|
+
config: Annotated[
|
|
31
|
+
Optional[Path], Option(help='Explicit pyproject.toml configuration file.')
|
|
32
|
+
] = None,
|
|
33
|
+
output: Annotated[
|
|
34
|
+
Optional[Path],
|
|
35
|
+
Option(
|
|
36
|
+
'-o',
|
|
37
|
+
help='Archive path; defaults to a platform-specific name in the current directory.',
|
|
38
|
+
),
|
|
39
|
+
] = None,
|
|
40
|
+
python: Annotated[
|
|
41
|
+
Optional[Path], Option(help='Target interpreter; defaults to the project .venv.')
|
|
42
|
+
] = None,
|
|
43
|
+
requirement: Annotated[
|
|
44
|
+
list[str], Option(help='Additional package requirement.')
|
|
45
|
+
] = EMPTY_OPTIONS,
|
|
46
|
+
requirements: Annotated[list[Path], Option(help='Requirements file.')] = EMPTY_OPTIONS,
|
|
47
|
+
extra: Annotated[list[str], Option(help='Packaged application extra.')] = EMPTY_OPTIONS,
|
|
48
|
+
include: Annotated[
|
|
49
|
+
list[str], Option(help='Seed file or directory: SOURCE=data/DESTINATION.')
|
|
50
|
+
] = EMPTY_OPTIONS,
|
|
51
|
+
exclude: Annotated[list[str], Option(help='Source exclusion glob.')] = EMPTY_OPTIONS,
|
|
52
|
+
find_links: Annotated[
|
|
53
|
+
list[str], Option(help='Local wheel directory or package listing URL.')
|
|
54
|
+
] = EMPTY_OPTIONS,
|
|
55
|
+
no_index: Annotated[bool, Flag(help='Resolve from local packages only.')] = False,
|
|
56
|
+
compile_mode: Annotated[
|
|
57
|
+
Optional[str],
|
|
58
|
+
Option(
|
|
59
|
+
'--compile',
|
|
60
|
+
choices=('none', 'app', 'all'),
|
|
61
|
+
help='Bytecode scope; all includes wheels and launcher.',
|
|
62
|
+
),
|
|
63
|
+
] = None,
|
|
64
|
+
strip_source: Annotated[
|
|
65
|
+
bool, Flag(help='Remove compiled .py files; requires --compile.')
|
|
66
|
+
] = False,
|
|
67
|
+
keep_source: Annotated[bool, Flag(help='Keep sources, overriding a profile.')] = False,
|
|
68
|
+
use_index: Annotated[bool, Flag(help='Allow index access, overriding a profile.')] = False,
|
|
69
|
+
replace: Annotated[
|
|
70
|
+
bool, Flag(help='Replace an archive only after the new build passes validation.')
|
|
71
|
+
] = False,
|
|
72
|
+
no_replace: Annotated[bool, Flag(help='Refuse replacement, overriding a profile.')] = False,
|
|
73
|
+
) -> int:
|
|
74
|
+
for enabled, disabled, label in (
|
|
75
|
+
(strip_source, keep_source, 'strip-source/keep-source'),
|
|
76
|
+
(no_index, use_index, 'no-index/use-index'),
|
|
77
|
+
(replace, no_replace, 'replace/no-replace'),
|
|
78
|
+
):
|
|
79
|
+
if enabled and disabled:
|
|
80
|
+
raise ValueError(f'Choose only one of --{label.replace("/", " and --")}')
|
|
81
|
+
options = resolve_options(
|
|
82
|
+
source,
|
|
83
|
+
profile=profile,
|
|
84
|
+
config=config,
|
|
85
|
+
**{
|
|
86
|
+
'run': run,
|
|
87
|
+
'output': output,
|
|
88
|
+
'python': python,
|
|
89
|
+
'requirement': requirement or None,
|
|
90
|
+
'requirements': requirements or None,
|
|
91
|
+
'extra': extra or None,
|
|
92
|
+
'include': include or None,
|
|
93
|
+
'exclude': exclude or None,
|
|
94
|
+
'find-links': find_links or None,
|
|
95
|
+
'compile': compile_mode,
|
|
96
|
+
'no-index': False if use_index else True if no_index else None,
|
|
97
|
+
'strip-source': False if keep_source else True if strip_source else None,
|
|
98
|
+
'replace': False if no_replace else True if replace else None,
|
|
99
|
+
},
|
|
100
|
+
)
|
|
101
|
+
path = build_bundle(options)
|
|
102
|
+
print(f'Created {path} ({path.stat().st_size:,} bytes)')
|
|
103
|
+
return 0
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@command('inspect', summary='Explain included files, dependencies, and estimated bundle size.')
|
|
107
|
+
def inspect(
|
|
108
|
+
source: Annotated[Optional[Path], Argument()] = None,
|
|
109
|
+
*,
|
|
110
|
+
python: Annotated[Optional[Path], Option()] = None,
|
|
111
|
+
run: Annotated[Optional[str], Option(help='Use the same launch command as the build.')] = None,
|
|
112
|
+
output: Annotated[Optional[Path], Option('-o', help='Planned archive path.')] = None,
|
|
113
|
+
profile: Annotated[Optional[str], Option(help='Named build profile.')] = None,
|
|
114
|
+
config: Annotated[
|
|
115
|
+
Optional[Path], Option(help='Explicit pyproject.toml configuration file.')
|
|
116
|
+
] = None,
|
|
117
|
+
requirement: Annotated[list[str], Option()] = EMPTY_OPTIONS,
|
|
118
|
+
requirements: Annotated[list[Path], Option()] = EMPTY_OPTIONS,
|
|
119
|
+
extra: Annotated[list[str], Option()] = EMPTY_OPTIONS,
|
|
120
|
+
include: Annotated[list[str], Option()] = EMPTY_OPTIONS,
|
|
121
|
+
exclude: Annotated[list[str], Option()] = EMPTY_OPTIONS,
|
|
122
|
+
find_links: Annotated[list[str], Option()] = EMPTY_OPTIONS,
|
|
123
|
+
no_index: Annotated[bool, Flag()] = False,
|
|
124
|
+
use_index: Annotated[bool, Flag()] = False,
|
|
125
|
+
compile_mode: Annotated[
|
|
126
|
+
Optional[str], Option('--compile', choices=('none', 'app', 'all'))
|
|
127
|
+
] = None,
|
|
128
|
+
strip_source: Annotated[bool, Flag()] = False,
|
|
129
|
+
keep_source: Annotated[bool, Flag()] = False,
|
|
130
|
+
resolve: Annotated[
|
|
131
|
+
bool,
|
|
132
|
+
Flag(help='Resolve/build wheels for exact dependency details and a fuller size estimate.'),
|
|
133
|
+
] = False,
|
|
134
|
+
) -> int:
|
|
135
|
+
if (no_index and use_index) or (strip_source and keep_source):
|
|
136
|
+
raise ValueError('Choose only one of each opposing flag pair')
|
|
137
|
+
options = resolve_options(
|
|
138
|
+
source,
|
|
139
|
+
profile=profile,
|
|
140
|
+
config=config,
|
|
141
|
+
**{
|
|
142
|
+
'python': python,
|
|
143
|
+
'run': run,
|
|
144
|
+
'output': output,
|
|
145
|
+
'requirement': requirement or None,
|
|
146
|
+
'requirements': requirements or None,
|
|
147
|
+
'extra': extra or None,
|
|
148
|
+
'include': include or None,
|
|
149
|
+
'exclude': exclude or None,
|
|
150
|
+
'find-links': find_links or None,
|
|
151
|
+
'compile': compile_mode,
|
|
152
|
+
'no-index': False if use_index else True if no_index else None,
|
|
153
|
+
'strip-source': False if keep_source else True if strip_source else None,
|
|
154
|
+
},
|
|
155
|
+
)
|
|
156
|
+
report = inspection_report(options, resolve=resolve)
|
|
157
|
+
print(dumps(report, indent=2))
|
|
158
|
+
return 1 if report['unresolved_imports'] else 0
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@command('verify', summary='Check an archive or extracted bundle without running the application.')
|
|
162
|
+
def verify(bundle: Annotated[Path, Argument()]) -> int:
|
|
163
|
+
manifest = verify_bundle(bundle)
|
|
164
|
+
print(f'Checksums passed: {manifest["name"]}')
|
|
165
|
+
return 0
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def main() -> int:
|
|
169
|
+
try:
|
|
170
|
+
return App.discover('portablepy', 'portablepy.cli').run()
|
|
171
|
+
except KeyboardInterrupt:
|
|
172
|
+
return 130
|
|
173
|
+
except (OSError, ValueError, KeyError, TarError, BadZipFile, CalledProcessError) as error:
|
|
174
|
+
print(f'portablepy: {error}', file=stderr)
|
|
175
|
+
return 1
|
portablepy/config.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Read build defaults and profiles without changing the caller's working directory."""
|
|
2
|
+
|
|
3
|
+
from shlex import split
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from tomllib import loads
|
|
6
|
+
from typing import Optional
|
|
7
|
+
from portablepy.models import BuildOptions
|
|
8
|
+
|
|
9
|
+
LIST_KEYS = {'requirement', 'requirements', 'extra', 'include', 'exclude', 'find-links'}
|
|
10
|
+
BOOL_KEYS = {'no-index', 'strip-source', 'replace'}
|
|
11
|
+
TEXT_KEYS = {'source', 'run', 'output', 'python', 'compile'}
|
|
12
|
+
CONFIG_KEYS = LIST_KEYS | BOOL_KEYS | TEXT_KEYS
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _validate(settings, label):
|
|
16
|
+
if not isinstance(settings, dict):
|
|
17
|
+
raise ValueError(f'{label} must be a TOML table')
|
|
18
|
+
unknown = settings.keys() - CONFIG_KEYS
|
|
19
|
+
if unknown:
|
|
20
|
+
raise ValueError(f'Unknown {label} settings: {", ".join(sorted(unknown))}')
|
|
21
|
+
for key, value in settings.items():
|
|
22
|
+
if key in LIST_KEYS:
|
|
23
|
+
valid = isinstance(value, list) and all(isinstance(item, str) for item in value)
|
|
24
|
+
elif key in BOOL_KEYS:
|
|
25
|
+
valid = type(value) is bool
|
|
26
|
+
else:
|
|
27
|
+
valid = isinstance(value, str) and bool(value)
|
|
28
|
+
if not valid:
|
|
29
|
+
raise ValueError(f'Invalid {label}.{key}')
|
|
30
|
+
if settings.get('compile', 'none') not in ('none', 'app', 'all'):
|
|
31
|
+
raise ValueError(f'{label}.compile must be none, app, or all')
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _config_file(source, explicit):
|
|
35
|
+
if explicit is not None:
|
|
36
|
+
path = explicit.expanduser().resolve()
|
|
37
|
+
if not path.is_file():
|
|
38
|
+
raise ValueError(f'Configuration file does not exist: {path}')
|
|
39
|
+
return path
|
|
40
|
+
start = source.expanduser().resolve() if source is not None else Path.cwd()
|
|
41
|
+
if start.is_file():
|
|
42
|
+
start = start.parent
|
|
43
|
+
for folder in (start, *start.parents):
|
|
44
|
+
path = folder / 'pyproject.toml'
|
|
45
|
+
if path.is_file():
|
|
46
|
+
return path
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def resolve_options(
|
|
51
|
+
source: Optional[Path] = None,
|
|
52
|
+
*,
|
|
53
|
+
profile: Optional[str] = None,
|
|
54
|
+
config: Optional[Path] = None,
|
|
55
|
+
**overrides,
|
|
56
|
+
) -> BuildOptions:
|
|
57
|
+
path = _config_file(source, config)
|
|
58
|
+
settings = {}
|
|
59
|
+
if path is not None:
|
|
60
|
+
tool = loads(path.read_text(encoding='utf-8')).get('tool', {})
|
|
61
|
+
if not isinstance(tool, dict):
|
|
62
|
+
raise ValueError('tool must be a TOML table')
|
|
63
|
+
settings = tool.get('portablepy', {})
|
|
64
|
+
if not isinstance(settings, dict):
|
|
65
|
+
raise ValueError('tool.portablepy must be a TOML table')
|
|
66
|
+
settings = dict(settings)
|
|
67
|
+
profiles = settings.pop('profiles', {})
|
|
68
|
+
if not isinstance(profiles, dict):
|
|
69
|
+
raise ValueError('tool.portablepy.profiles must be a TOML table')
|
|
70
|
+
_validate(settings, 'tool.portablepy')
|
|
71
|
+
for name, values in profiles.items():
|
|
72
|
+
_validate(values, f'tool.portablepy.profiles.{name}')
|
|
73
|
+
configured = bool(settings or profiles)
|
|
74
|
+
if profile is not None:
|
|
75
|
+
if profile not in profiles:
|
|
76
|
+
available = ', '.join(sorted(profiles)) or '(none)'
|
|
77
|
+
raise ValueError(f'Unknown profile {profile!r}; available profiles: {available}')
|
|
78
|
+
settings.update(profiles[profile])
|
|
79
|
+
base = path.parent if path is not None else Path.cwd()
|
|
80
|
+
for key in ('source', 'output', 'python'):
|
|
81
|
+
if key in settings:
|
|
82
|
+
settings[key] = (base / Path(settings[key]).expanduser()).absolute()
|
|
83
|
+
if 'requirement' in settings:
|
|
84
|
+
settings['requirement'] = [
|
|
85
|
+
str((base / Path(item).expanduser()).absolute())
|
|
86
|
+
if '://' not in item and (base / Path(item).expanduser()).exists()
|
|
87
|
+
else item
|
|
88
|
+
for item in settings['requirement']
|
|
89
|
+
]
|
|
90
|
+
if 'requirements' in settings:
|
|
91
|
+
settings['requirements'] = [
|
|
92
|
+
(base / Path(item).expanduser()).resolve() for item in settings['requirements']
|
|
93
|
+
]
|
|
94
|
+
if 'include' in settings:
|
|
95
|
+
includes = []
|
|
96
|
+
for item in settings['include']:
|
|
97
|
+
filename, separator, destination = item.partition('=')
|
|
98
|
+
if not separator:
|
|
99
|
+
raise ValueError('Configured include uses SOURCE=data/DESTINATION')
|
|
100
|
+
includes.append(str((base / Path(filename).expanduser()).resolve()) + '=' + destination)
|
|
101
|
+
settings['include'] = includes
|
|
102
|
+
if 'find-links' in settings:
|
|
103
|
+
settings['find-links'] = [
|
|
104
|
+
item if '://' in item else str((base / Path(item).expanduser()).resolve())
|
|
105
|
+
for item in settings['find-links']
|
|
106
|
+
]
|
|
107
|
+
settings.update({key: value for key, value in overrides.items() if value is not None})
|
|
108
|
+
selected_source = (
|
|
109
|
+
source if source is not None else settings.get('source', base if configured else Path.cwd())
|
|
110
|
+
)
|
|
111
|
+
return BuildOptions(
|
|
112
|
+
source=Path(selected_source),
|
|
113
|
+
command=tuple(split(settings.get('run', ''))),
|
|
114
|
+
output=settings.get('output'),
|
|
115
|
+
python=settings.get('python'),
|
|
116
|
+
requirements=tuple(settings.get('requirement', ())),
|
|
117
|
+
requirement_files=tuple(Path(item) for item in settings.get('requirements', ())),
|
|
118
|
+
extras=tuple(settings.get('extra', ())),
|
|
119
|
+
includes=tuple(settings.get('include', ())),
|
|
120
|
+
excludes=tuple(settings.get('exclude', ())),
|
|
121
|
+
find_links=tuple(settings.get('find-links', ())),
|
|
122
|
+
no_index=settings.get('no-index', False),
|
|
123
|
+
compile_mode=settings.get('compile', 'none'),
|
|
124
|
+
strip_source=settings.get('strip-source', False),
|
|
125
|
+
replace=settings.get('replace', False),
|
|
126
|
+
profile=profile,
|
|
127
|
+
config=path if configured or config is not None else None,
|
|
128
|
+
)
|