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 ADDED
@@ -0,0 +1,3 @@
1
+ """Portable application bundles with verified wheels and an offline launcher."""
2
+
3
+ __version__ = '0.1.0'
portablepy/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from portablepy.cli import main
2
+
3
+ raise SystemExit(main())
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
+ )