pyencode-protector 0.3.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.
pyencode/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Build encrypted Python module bundles."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.3.0"
pyencode/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ from __future__ import annotations
2
+
3
+ from .cli import main
4
+
5
+
6
+ if __name__ == "__main__":
7
+ raise SystemExit(main())
8
+
pyencode/builder.py ADDED
@@ -0,0 +1,628 @@
1
+ """Build encrypted module trees and their portable Python runtime."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import ast
7
+ import hashlib
8
+ import json
9
+ import marshal
10
+ import os
11
+ import secrets
12
+ import shutil
13
+ import sys
14
+ import tempfile
15
+ import tokenize
16
+ from dataclasses import dataclass
17
+ from datetime import date, datetime, timezone
18
+ from pathlib import Path, PurePosixPath
19
+ from typing import Sequence
20
+
21
+ from cryptography.hazmat.primitives import serialization
22
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
23
+
24
+ from . import __version__
25
+ from .container import canonical_json_bytes
26
+ from .code_hardening import harden_code
27
+ from .crypto import derive_bound_master_key
28
+ from .discovery import ProjectLayout, discover_project
29
+ from .errors import PyEncodeError
30
+ from .integrity import (
31
+ BOUND_FILES_ALGORITHM,
32
+ BOUND_FILES_FORMAT,
33
+ bound_files_commitment,
34
+ )
35
+ from .inventory import (
36
+ discover_inventory_files,
37
+ hash_inventory_files,
38
+ validate_inventory_path,
39
+ )
40
+ from .opaque import (
41
+ OpaqueModuleContext,
42
+ derive_module_root_key,
43
+ encrypt_module_index,
44
+ module_index_commitment,
45
+ pack_opaque_artifact,
46
+ )
47
+ from .source import prepare_source
48
+
49
+
50
+ MANIFEST_NAME = ".pyencode-manifest.json"
51
+ RUNTIME_TEMPLATE_FILES = (
52
+ "__init__.py",
53
+ "_mp_main.py",
54
+ "_runtime.py",
55
+ )
56
+ RESERVED_TOP_LEVEL_MODULES = frozenset(
57
+ {
58
+ "cryptography",
59
+ "__main__",
60
+ "pyencode_runtime",
61
+ *(name.partition(".")[0].casefold() for name in sys.builtin_module_names),
62
+ *(name.partition(".")[0].casefold() for name in sys.stdlib_module_names),
63
+ }
64
+ )
65
+
66
+ DEFAULT_LAUNCHER = """import sys
67
+ sys.dont_write_bytecode = True
68
+
69
+ import os
70
+
71
+ _runtime_directory = os.path.join(os.path.dirname(os.path.realpath(__file__)), "pyencode_runtime")
72
+ _expected_runtime_files = {"__init__.py", "_mp_main.py", "_runtime.py", "_build.py"}
73
+ try:
74
+ with os.scandir(_runtime_directory) as _runtime_iterator:
75
+ _runtime_entries = tuple(_runtime_iterator)
76
+ except OSError as exc:
77
+ raise ImportError("pyencode runtime directory is missing or unreadable") from exc
78
+ _runtime_names = {entry.name for entry in _runtime_entries}
79
+ _runtime_problems = []
80
+ if _expected_runtime_files - _runtime_names:
81
+ _runtime_problems.append("missing " + ", ".join(sorted(_expected_runtime_files - _runtime_names)))
82
+ if _runtime_names - _expected_runtime_files:
83
+ _runtime_problems.append("unexpected " + ", ".join(sorted(_runtime_names - _expected_runtime_files)))
84
+ _non_files = sorted(
85
+ entry.name
86
+ for entry in _runtime_entries
87
+ if entry.is_symlink() or not entry.is_file(follow_symlinks=False)
88
+ )
89
+ if _non_files:
90
+ _runtime_problems.append("not regular files " + ", ".join(_non_files))
91
+ if _runtime_problems:
92
+ raise ImportError(
93
+ "pyencode runtime directory contains unsigned files or directories ("
94
+ + "; ".join(_runtime_problems)
95
+ + ")"
96
+ )
97
+
98
+ from pyencode_runtime import run
99
+
100
+ if __name__ == "__main__":
101
+ run()
102
+ """
103
+
104
+
105
+ @dataclass(frozen=True, slots=True)
106
+ class BuildOptions:
107
+ source: Path
108
+ output: Path
109
+ entry_module: str | None = None
110
+ excludes: tuple[str, ...] = ()
111
+ include_resources: bool = True
112
+ strip_docstrings: bool = True
113
+ optimize: int = 0
114
+ expires: date | None = None
115
+ launcher: Path | None = None
116
+ rename_locals: bool = False
117
+ support: tuple[tuple[Path, str], ...] = ()
118
+ allow_extra_data: bool = False
119
+
120
+
121
+ @dataclass(frozen=True, slots=True)
122
+ class BuildResult:
123
+ output: Path
124
+ entry_module: str
125
+ build_id: str
126
+ python_tag: str
127
+ module_count: int
128
+ resource_count: int
129
+ support_count: int
130
+ launcher_name: str
131
+
132
+
133
+ def _python_tag() -> str:
134
+ return f"cp{sys.version_info.major}{sys.version_info.minor}"
135
+
136
+
137
+ def _validate_build_interpreter() -> None:
138
+ version = (sys.version_info.major, sys.version_info.minor)
139
+ if getattr(sys.implementation, "name", None) != "cpython" or version < (3, 10):
140
+ raise PyEncodeError("Builds require standard CPython 3.10 or newer")
141
+
142
+
143
+ def _validate_entry(layout: ProjectLayout, requested: str | None) -> str:
144
+ entry = requested or layout.inferred_entry
145
+ if entry is None:
146
+ raise PyEncodeError(
147
+ "Could not infer an entry module; pass --entry (for example --entry myapp.__main__)"
148
+ )
149
+ names = {module.name for module in layout.modules}
150
+ if entry not in names:
151
+ raise PyEncodeError(f"Entry module {entry!r} was not found in the protected source tree")
152
+ return entry
153
+
154
+
155
+ def _validate_reserved_modules(layout: ProjectLayout) -> None:
156
+ conflicts = sorted(
157
+ module.name
158
+ for module in layout.modules
159
+ if module.name.partition(".")[0].casefold() in RESERVED_TOP_LEVEL_MODULES
160
+ )
161
+ if conflicts:
162
+ raise PyEncodeError(
163
+ "Protected source uses a reserved runtime namespace: "
164
+ + ", ".join(conflicts)
165
+ )
166
+
167
+
168
+ def _validate_module_hierarchy(layout: ProjectLayout) -> None:
169
+ package_flags = {module.name: module.is_package for module in layout.modules}
170
+ for module in layout.modules:
171
+ parts = module.name.split(".")
172
+ for end in range(1, len(parts)):
173
+ ancestor = ".".join(parts[:end])
174
+ if ancestor in package_flags and not package_flags[ancestor]:
175
+ raise PyEncodeError(
176
+ f"Protected module {ancestor!r} is not a package but has "
177
+ f"protected child {module.name!r}"
178
+ )
179
+
180
+
181
+ def _validate_launcher(launcher: Path | None) -> tuple[Path | None, str]:
182
+ if launcher is None:
183
+ return None, "run.py"
184
+ source = launcher.expanduser().resolve()
185
+ if not source.is_file():
186
+ raise PyEncodeError(f"Launcher does not exist or is not a file: {source}")
187
+ name = source.name
188
+ if source.suffix.lower() != ".py" or not source.stem.isidentifier():
189
+ raise PyEncodeError("Launcher must be a single valid Python filename")
190
+ if name.casefold() == "pyencode_runtime.py":
191
+ raise PyEncodeError("Launcher name would shadow the pyencode_runtime package")
192
+ _validate_custom_launcher_bootstrap(source)
193
+ return source, name
194
+
195
+
196
+ def _validate_custom_launcher_bootstrap(source: Path) -> None:
197
+ try:
198
+ with tokenize.open(source) as stream:
199
+ tree = ast.parse(stream.read(), filename=str(source))
200
+ except (OSError, SyntaxError, UnicodeError) as exc:
201
+ raise PyEncodeError(f"Custom launcher is not valid Python: {source}") from exc
202
+
203
+ sys_aliases: set[str] = set()
204
+ protected = False
205
+ for statement in tree.body:
206
+ if (
207
+ isinstance(statement, ast.Expr)
208
+ and isinstance(statement.value, ast.Constant)
209
+ and isinstance(statement.value.value, str)
210
+ ):
211
+ continue
212
+ if isinstance(statement, ast.ImportFrom) and statement.module == "__future__":
213
+ continue
214
+ if isinstance(statement, ast.Import):
215
+ imported = {alias.name for alias in statement.names}
216
+ if not protected and not imported.issubset({"sys", "os"}):
217
+ break
218
+ for alias in statement.names:
219
+ if alias.name == "sys":
220
+ sys_aliases.add(alias.asname or "sys")
221
+ continue
222
+ if isinstance(statement, (ast.Assign, ast.AnnAssign)):
223
+ targets = statement.targets if isinstance(statement, ast.Assign) else [statement.target]
224
+ value = statement.value
225
+ if any(
226
+ isinstance(target, ast.Attribute)
227
+ and target.attr == "dont_write_bytecode"
228
+ and isinstance(target.value, ast.Name)
229
+ and target.value.id in sys_aliases
230
+ for target in targets
231
+ ) and isinstance(value, ast.Constant) and value.value is True:
232
+ protected = True
233
+ break
234
+ if not protected:
235
+ break
236
+ if not protected:
237
+ raise PyEncodeError(
238
+ "Custom launcher must set sys.dont_write_bytecode = True before "
239
+ "importing any non-bootstrap module"
240
+ )
241
+
242
+
243
+ def _validate_output(source: Path, output: Path) -> Path:
244
+ output = output.expanduser().resolve()
245
+ if output == source or output in source.parents:
246
+ raise PyEncodeError("Output must not replace the source or one of its parent directories")
247
+ if output.exists():
248
+ if not output.is_dir():
249
+ raise PyEncodeError(f"Output exists and is not a directory: {output}")
250
+ if any(output.iterdir()):
251
+ raise PyEncodeError(f"Output directory is not empty: {output}")
252
+ output.parent.mkdir(parents=True, exist_ok=True)
253
+ return output
254
+
255
+
256
+ def _compile_module(path: Path, module_name: str, options: BuildOptions) -> bytes:
257
+ with tokenize.open(path) as source_file:
258
+ text = source_file.read()
259
+ synthetic_filename = f"<pyencode:{module_name}>"
260
+ prepared = prepare_source(text, synthetic_filename, strip_docstrings=options.strip_docstrings)
261
+ code = compile(
262
+ prepared,
263
+ synthetic_filename,
264
+ "exec",
265
+ dont_inherit=True,
266
+ optimize=options.optimize,
267
+ )
268
+ if options.rename_locals:
269
+ code = harden_code(code, module_name=module_name)
270
+ return marshal.dumps(code)
271
+
272
+
273
+ def _mask_master_key(master_key: bytes, build_id: str) -> tuple[list[str], list[int]]:
274
+ mask = hashlib.sha256(b"pyencode-key-mask\0" + bytes.fromhex(build_id)).digest()
275
+ masked = bytes(value ^ mask[index] for index, value in enumerate(master_key))
276
+ widths = (5, 7, 4, 8, 8)
277
+ chunks: list[bytes] = []
278
+ offset = 0
279
+ for width in widths:
280
+ chunks.append(masked[offset : offset + width])
281
+ offset += width
282
+
283
+ shuffled_indexes = list(range(len(chunks)))
284
+ secrets.SystemRandom().shuffle(shuffled_indexes)
285
+ stored = [base64.b85encode(chunks[index]).decode("ascii") for index in shuffled_indexes]
286
+ restore_order = [shuffled_indexes.index(index) for index in range(len(chunks))]
287
+ return stored, restore_order
288
+
289
+
290
+ def _write_runtime_config(
291
+ runtime_directory: Path,
292
+ *,
293
+ build_id: str,
294
+ python_tag: str,
295
+ public_key: bytes,
296
+ master_key: bytes,
297
+ launcher_name: str,
298
+ bootstrap_paths: tuple[str, ...],
299
+ ) -> None:
300
+ parts, order = _mask_master_key(master_key, build_id)
301
+ content = "\n".join(
302
+ (
303
+ "# Generated by pyencode. Do not edit.",
304
+ f"BUILD_ID = {build_id!r}",
305
+ f"PYTHON_TAG = {python_tag!r}",
306
+ f"PUBLIC_KEY_B64 = {base64.b64encode(public_key).decode('ascii')!r}",
307
+ f"KEY_PARTS = {parts!r}",
308
+ f"KEY_ORDER = {order!r}",
309
+ f"MANIFEST_NAME = {MANIFEST_NAME!r}",
310
+ f"LAUNCHER_NAME = {launcher_name!r}",
311
+ f"BOOTSTRAP_PATHS = {list(bootstrap_paths)!r}",
312
+ "",
313
+ )
314
+ )
315
+ (runtime_directory / "_build.py").write_text(content, encoding="utf-8", newline="\n")
316
+
317
+
318
+ def _copy_runtime(destination: Path) -> None:
319
+ template = Path(__file__).with_name("runtime_template")
320
+ if not template.is_dir():
321
+ raise PyEncodeError(f"Runtime template is missing: {template}")
322
+ destination.mkdir(parents=True, exist_ok=True)
323
+ for filename in RUNTIME_TEMPLATE_FILES:
324
+ source = template / filename
325
+ if not source.is_file():
326
+ raise PyEncodeError(f"Runtime template file is missing: {source}")
327
+ shutil.copy2(source, destination / filename)
328
+
329
+
330
+ def _write_launcher(destination: Path, source: Path | None, name: str) -> None:
331
+ target = destination / name
332
+ if source is not None:
333
+ shutil.copyfile(source, target)
334
+ else:
335
+ target.write_text(DEFAULT_LAUNCHER, encoding="utf-8", newline="\n")
336
+
337
+
338
+ def _write_requirements(destination: Path) -> None:
339
+ (destination / "requirements.txt").write_text(
340
+ "cryptography>=42\n", encoding="utf-8", newline="\n"
341
+ )
342
+
343
+
344
+ def _copy_regular_file(source: Path, target: Path, destination: Path) -> None:
345
+ if source.is_symlink():
346
+ raise PyEncodeError(f"Refusing to copy symlink into protected output: {source}")
347
+ if not source.is_file():
348
+ raise PyEncodeError(f"Support source is not a regular file: {source}")
349
+ if target.exists():
350
+ try:
351
+ relative = target.relative_to(destination).as_posix()
352
+ except ValueError:
353
+ relative = str(target)
354
+ raise PyEncodeError(f"Support output path collides with an existing file: {relative}")
355
+ target.parent.mkdir(parents=True, exist_ok=True)
356
+ shutil.copy2(source, target)
357
+
358
+
359
+ def _copy_support_inputs(
360
+ destination: Path,
361
+ support: Sequence[tuple[Path, str]],
362
+ launcher_name: str,
363
+ ) -> int:
364
+ copied = 0
365
+ for raw_source, raw_target in support:
366
+ target_text = validate_inventory_path(raw_target)
367
+ _validate_generated_path_conflict(target_text, launcher_name)
368
+ source = Path(raw_source).expanduser()
369
+ if source.is_symlink():
370
+ raise PyEncodeError(f"Refusing to copy symlink support input: {source}")
371
+ try:
372
+ source = source.resolve(strict=True)
373
+ except OSError as exc:
374
+ raise PyEncodeError(f"Support input is missing or unreadable: {source}") from exc
375
+ target_root = destination / Path(*target_text.split("/"))
376
+ if source.is_file():
377
+ _copy_regular_file(source, target_root, destination)
378
+ copied += 1
379
+ continue
380
+ if not source.is_dir():
381
+ raise PyEncodeError(f"Support input is not a regular file or directory: {source}")
382
+ for relative_text in discover_inventory_files(source):
383
+ relative = Path(*relative_text.split("/"))
384
+ _copy_regular_file(source / relative, target_root / relative, destination)
385
+ copied += 1
386
+ return copied
387
+
388
+
389
+ def _validate_generated_path_conflict(relative: str, launcher_name: str) -> None:
390
+ parts = relative.split("/")
391
+ if parts[0].casefold() == "pyencode_runtime":
392
+ raise PyEncodeError(
393
+ "Output path uses the reserved pyencode_runtime subtree: " + relative
394
+ )
395
+ if relative.casefold() == MANIFEST_NAME.casefold():
396
+ raise PyEncodeError(f"Output path is reserved for the signed manifest: {relative}")
397
+ if relative.casefold() == launcher_name.casefold():
398
+ raise PyEncodeError(f"Output path collides with the generated launcher: {relative}")
399
+ if PurePosixPath(relative).suffix.casefold() == ".pye":
400
+ raise PyEncodeError(f"Output path uses the reserved .pye artifact suffix: {relative}")
401
+
402
+
403
+ def _opaque_artifact_path(module_name: str, is_package: bool, token: str) -> Path:
404
+ parts = module_name.split(".")
405
+ directory_parts = parts if is_package else parts[:-1]
406
+ return Path(*directory_parts, f"{token}.pye")
407
+
408
+
409
+ def _public_key_bytes(private_key: Ed25519PrivateKey) -> bytes:
410
+ return private_key.public_key().public_bytes(
411
+ encoding=serialization.Encoding.Raw,
412
+ format=serialization.PublicFormat.Raw,
413
+ )
414
+
415
+
416
+ def _signed_manifest(
417
+ payload: dict[str, object], private_key: Ed25519PrivateKey
418
+ ) -> dict[str, object]:
419
+ payload_bytes = canonical_json_bytes(payload)
420
+ signature = private_key.sign(payload_bytes)
421
+ wrapper: dict[str, object] = {
422
+ "manifest": payload,
423
+ "signature": base64.b64encode(signature).decode("ascii"),
424
+ }
425
+ return wrapper
426
+
427
+
428
+ def _build_into(
429
+ layout: ProjectLayout,
430
+ destination: Path,
431
+ options: BuildOptions,
432
+ entry_module: str,
433
+ ) -> BuildResult:
434
+ build_id = secrets.token_hex(16)
435
+ python_tag = _python_tag()
436
+ master_key = os.urandom(32)
437
+ signing_key = Ed25519PrivateKey.generate()
438
+ public_key = _public_key_bytes(signing_key)
439
+ launcher_source, launcher_name = _validate_launcher(options.launcher)
440
+ module_index_entries: dict[str, dict[str, object]] = {}
441
+ module_tokens: dict[str, str] = {}
442
+
443
+ runtime_directory = destination / "pyencode_runtime"
444
+ _copy_runtime(runtime_directory)
445
+ _write_launcher(destination, launcher_source, launcher_name)
446
+
447
+ for resource in layout.resources:
448
+ _validate_generated_path_conflict(
449
+ resource.output_path.as_posix(), launcher_name
450
+ )
451
+ output_path = destination / resource.output_path
452
+ output_path.parent.mkdir(parents=True, exist_ok=True)
453
+ if resource.source_path.is_symlink():
454
+ raise PyEncodeError(
455
+ f"Refusing to copy symlink resource: {resource.source_path}"
456
+ )
457
+ shutil.copy2(resource.source_path, output_path)
458
+ # Keep the generated runtime dependency authoritative even when a
459
+ # project-root source tree also contains a requirements.txt resource.
460
+ _write_requirements(destination)
461
+ support_count = _copy_support_inputs(destination, options.support, launcher_name)
462
+
463
+ _write_runtime_config(
464
+ runtime_directory,
465
+ build_id=build_id,
466
+ python_tag=python_tag,
467
+ public_key=public_key,
468
+ master_key=master_key,
469
+ launcher_name=launcher_name,
470
+ bootstrap_paths=tuple(
471
+ sorted(
472
+ {
473
+ "_vendor"
474
+ for _source, target in options.support
475
+ if validate_inventory_path(target) == "_vendor"
476
+ }
477
+ )
478
+ ),
479
+ )
480
+
481
+ bound_paths = discover_inventory_files(destination)
482
+ bound_file_digests = hash_inventory_files(destination, bound_paths)
483
+ commitment = bound_files_commitment(bound_file_digests)
484
+ support_root_key = derive_bound_master_key(master_key, build_id, commitment)
485
+
486
+ used_tokens: set[str] = set()
487
+ for module in layout.modules:
488
+ while True:
489
+ token = secrets.token_hex(16)
490
+ if token not in used_tokens:
491
+ break
492
+ used_tokens.add(token)
493
+ module_tokens[module.name] = token
494
+ module_index_entries[module.name] = {
495
+ "artifact": token,
496
+ "package": module.is_package,
497
+ }
498
+
499
+ encrypted_index = encrypt_module_index(
500
+ {
501
+ "format": 1,
502
+ "entry_module": entry_module,
503
+ "modules": module_index_entries,
504
+ },
505
+ support_root_key,
506
+ build_id=build_id,
507
+ python_tag=python_tag,
508
+ )
509
+ index_commitment = module_index_commitment(
510
+ encrypted_index,
511
+ build_id=build_id,
512
+ python_tag=python_tag,
513
+ )
514
+ module_root_key = derive_module_root_key(
515
+ support_root_key,
516
+ build_id,
517
+ index_commitment,
518
+ )
519
+ artifacts_manifest: dict[str, str] = {}
520
+
521
+ for module in layout.modules:
522
+ marshalled = _compile_module(module.source_path, module.name, options)
523
+ token = module_tokens[module.name]
524
+ context = OpaqueModuleContext(
525
+ build_id=build_id,
526
+ python_tag=python_tag,
527
+ module=module.name,
528
+ package=module.is_package,
529
+ token=token,
530
+ marshal_version=marshal.version,
531
+ )
532
+ protected = pack_opaque_artifact(marshalled, module_root_key, context)
533
+ relative_output = _opaque_artifact_path(module.name, module.is_package, token)
534
+ output_path = destination / relative_output
535
+ if output_path.exists():
536
+ raise PyEncodeError(
537
+ f"Opaque artifact path collides with a resource: {relative_output.as_posix()}"
538
+ )
539
+ output_path.parent.mkdir(parents=True, exist_ok=True)
540
+ output_path.write_bytes(protected)
541
+ artifacts_manifest[token] = hashlib.sha256(protected).hexdigest()
542
+
543
+ # A final cross-platform scan catches case-fold collisions introduced by
544
+ # logical package directories and opaque artifact paths before publishing.
545
+ discover_inventory_files(destination)
546
+
547
+ policy: dict[str, object] = {}
548
+ if options.expires is not None:
549
+ policy["expires"] = options.expires.isoformat()
550
+ if options.allow_extra_data:
551
+ policy["allow_extra_data"] = True
552
+ manifest_payload: dict[str, object] = {
553
+ "format": 3,
554
+ "tool_version": __version__,
555
+ "build_id": build_id,
556
+ "python_tag": python_tag,
557
+ "created_utc": datetime.now(timezone.utc).isoformat(timespec="seconds"),
558
+ "module_index": {
559
+ "format": 1,
560
+ "cipher": "aes-256-gcm",
561
+ "compression": "zlib",
562
+ "nonce_b64": base64.b64encode(encrypted_index.nonce).decode("ascii"),
563
+ "ciphertext_b64": base64.b64encode(encrypted_index.ciphertext).decode("ascii"),
564
+ "commitment_sha256": index_commitment.hex(),
565
+ },
566
+ "artifacts": dict(sorted(artifacts_manifest.items())),
567
+ "policy": policy,
568
+ "integrity": {
569
+ "format": BOUND_FILES_FORMAT,
570
+ "algorithm": BOUND_FILES_ALGORITHM,
571
+ "files": bound_file_digests,
572
+ "commitment_sha256": commitment.hex(),
573
+ },
574
+ }
575
+ wrapper = _signed_manifest(manifest_payload, signing_key)
576
+ (destination / MANIFEST_NAME).write_text(
577
+ json.dumps(wrapper, indent=2, sort_keys=True, ensure_ascii=False) + "\n",
578
+ encoding="utf-8",
579
+ newline="\n",
580
+ )
581
+
582
+ return BuildResult(
583
+ output=destination,
584
+ entry_module=entry_module,
585
+ build_id=build_id,
586
+ python_tag=python_tag,
587
+ module_count=len(layout.modules),
588
+ resource_count=len(layout.resources),
589
+ support_count=support_count,
590
+ launcher_name=launcher_name,
591
+ )
592
+
593
+
594
+ def build(options: BuildOptions) -> BuildResult:
595
+ _validate_build_interpreter()
596
+ if options.optimize not in (0, 1, 2):
597
+ raise PyEncodeError("Optimization level must be 0, 1, or 2")
598
+ layout = discover_project(
599
+ options.source,
600
+ excludes=options.excludes,
601
+ include_resources=options.include_resources,
602
+ )
603
+ _validate_reserved_modules(layout)
604
+ _validate_module_hierarchy(layout)
605
+ entry_module = _validate_entry(layout, options.entry_module)
606
+ output = _validate_output(layout.source, options.output)
607
+
608
+ temporary = Path(
609
+ tempfile.mkdtemp(prefix=f".{output.name}.pyencode-", dir=str(output.parent))
610
+ )
611
+ try:
612
+ temporary_result = _build_into(layout, temporary, options, entry_module)
613
+ if output.exists():
614
+ output.rmdir()
615
+ temporary.replace(output)
616
+ return BuildResult(
617
+ output=output,
618
+ entry_module=temporary_result.entry_module,
619
+ build_id=temporary_result.build_id,
620
+ python_tag=temporary_result.python_tag,
621
+ module_count=temporary_result.module_count,
622
+ resource_count=temporary_result.resource_count,
623
+ support_count=temporary_result.support_count,
624
+ launcher_name=temporary_result.launcher_name,
625
+ )
626
+ except Exception:
627
+ shutil.rmtree(temporary, ignore_errors=True)
628
+ raise