From 421c20669299934a288095411593e7f02279fc33 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 13 Oct 2026 13:26:39 +0200 Subject: [PATCH 1/2] Recorded: deployments for PyPI releases alone --- .github/workflows/workflow.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index 034b094e..a8feda1f 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -173,6 +173,7 @@ jobs: runs-on: ubuntu-latest environment: name: ${{ github.event_name == 'workflow_dispatch' && inputs.target || 'pypi' }} + deployment: ${{ github.event_name != 'workflow_dispatch' || inputs.target == 'pypi' }} permissions: id-token: write steps: From 3c7d787e8e22b7f34d4e0a4f51102dd15d026c90 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 13 Oct 2026 14:09:06 +0200 Subject: [PATCH 2/2] Carried: the release deployment settings in every distributed wheel --- .github/workflows/workflow.yml | 3 + docs/development/application/undo.md | 2 +- hatch_build.py | 64 +++++++++++---- scripts/bootstrap/layout.py | 8 +- scripts/bundle.py | 31 ++++++-- scripts/release/deployment.yaml | 2 + scripts/runtime_hooks/release_environment.py | 4 - scripts/verify_bundle.py | 29 +++++-- scripts/verify_wheel.py | 64 +++++++++++++++ .../config/deployment/deployment.py | 10 +-- .../config/deployment/test_deployment.py | 13 +++ tests/unit/scripts/test_bundle.py | 26 ++++-- tests/unit/scripts/test_verify_bundle.py | 53 ++++++++++++- tests/unit/scripts/test_verify_wheel.py | 69 ++++++++++++++++ tests/unit/test_hatch_build.py | 79 +++++++++++++++---- 15 files changed, 398 insertions(+), 59 deletions(-) create mode 100644 scripts/release/deployment.yaml delete mode 100644 scripts/runtime_hooks/release_environment.py create mode 100644 scripts/verify_wheel.py create mode 100644 tests/unit/scripts/test_verify_wheel.py diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index a8feda1f..8b9d4fab 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -37,6 +37,9 @@ jobs: - name: Build sdist and wheel run: uv build + - name: Check the wheel carries the release deployment settings + run: python3 scripts/verify_wheel.py + - name: Check metadata renders on PyPI run: uvx twine check --strict dist/* diff --git a/docs/development/application/undo.md b/docs/development/application/undo.md index 10cfad30..2a5552d3 100644 --- a/docs/development/application/undo.md +++ b/docs/development/application/undo.md @@ -69,4 +69,4 @@ Committed entries are language-independent. An entry stores its action as a `His The entry budget is a persisted user preference (`ApplicationConfig.history.budget`). The render cache's byte budget is a behavior setting (`rendering.cache_megabytes`). Strict checking and log level are deployment settings (`application/deployment.yaml` → `DeploymentConfig`), and the deployment model takes every value from the YAML with no field defaults. The history panel renders a window of rows around the cursor (`layout.sequencer.history.max_rendered_entries`) and repaints rows in place through an index-keyed diff. -**Strict checking is on where the code is written.** `deployment.yaml` has the development values, so an edit path that reaches the project outside a transaction raises `UntrackedMutationError` at once. That holds in a development run and in the test suite alike, since several tests build the whole application and read that file. A user build takes the opposite values from `scripts/runtime_hooks/release_environment.py`. A gap that reaches a release is healed into an `UNTRACKED` entry and is not shown to the user. The gap therefore surfaces where it can be fixed and stays quiet where it cannot. +**Strict checking is on where the code is written.** `deployment.yaml` has the development values, so an edit path that reaches the project outside a transaction raises `UntrackedMutationError` at once. That holds in a development run and in the test suite alike, since several tests build the whole application and read that file. Every wheel built for distribution takes the opposite values from `scripts/release/deployment.yaml`, and so does every published package and release bundle. A gap that reaches a release is healed into an `UNTRACKED` entry and is not shown to the user. The gap therefore surfaces where it can be fixed and stays quiet where it cannot. diff --git a/hatch_build.py b/hatch_build.py index 12be6361..125e43c9 100644 --- a/hatch_build.py +++ b/hatch_build.py @@ -1,3 +1,4 @@ +import os import shutil import sys import tempfile @@ -12,15 +13,26 @@ SOURCE_DIRECTORY: Final[str] = "src" SITE_DESTINATION: Final[str] = "sampletones_assets/documentation" SITE_PREFIX: Final[str] = "sampletones-documentation-" +DEPLOYMENT_DESTINATION: Final[str] = "sampletones_config/application/deployment.yaml" +RELEASE_DEPLOYMENT: Final[str] = "scripts/release/deployment.yaml" +DEPLOYMENT_PREFIX: Final[str] = "sampletones-deployment-" +DEVELOPMENT_WHEEL: Final[str] = "SAMPLETONES_DEVELOPMENT_WHEEL" +DEVELOPMENT_WHEEL_ENABLED: Final[str] = "1" -class DocumentationBuildHook(BuildHookInterface[BuilderConfig]): - """Writes the user documentation into every wheel, as the pages Help > Documentation opens. +class DistributionBuildHook(BuildHookInterface[BuilderConfig]): + """Writes into every wheel what the checkout keeps outside its packages: the user documentation, as + the pages Help > Documentation opens, and the release deployment settings. - The maker in ``assets/documentation`` writes the site from the checkout's docs into a - temporary folder, which the wheel carries at the assets package's documentation path and - which is removed once the wheel is written. An editable install reads the checkout itself, - where ``make docs`` writes the same site. + The maker in ``assets/documentation`` writes the site from the checkout's docs, which the + wheel carries at the assets package's documentation path. The release settings in + ``scripts/release/deployment.yaml`` take the place of the development ones the checkout reads, + so a published wheel, and a release bundle installed from one, is quiet and self-healing. The + site and the copy of the settings lie in temporary folders, removed once the wheel is written. + + An editable install reads the checkout itself, where ``make docs`` writes the same site. A + build run with ``SAMPLETONES_DEVELOPMENT_WHEEL=1``, as a development bundle's is, keeps the + development settings. """ PLUGIN_NAME = "custom" @@ -30,6 +42,25 @@ def initialize(self, version: str, build_data: Dict[str, Any]) -> None: return root = Path(self.root) + build_data[FORCE_INCLUDE][str(self._build_site(root))] = SITE_DESTINATION + if os.environ.get(DEVELOPMENT_WHEEL) != DEVELOPMENT_WHEEL_ENABLED: + build_data[FORCE_INCLUDE][str(self._release_deployment(root))] = DEPLOYMENT_DESTINATION + + def finalize( + self, + version: str, + build_data: Dict[str, Any], + artifact_path: str, + ) -> None: + for source, destination in build_data[FORCE_INCLUDE].items(): + if destination == SITE_DESTINATION: + shutil.rmtree(source) + elif destination == DEPLOYMENT_DESTINATION: + shutil.rmtree(Path(source).parent) + + @staticmethod + def _build_site(root: Path) -> Path: + """The documentation site written from the checkout's docs into a temporary folder.""" for entry in (root / SOURCE_DIRECTORY, root): if str(entry) not in sys.path: sys.path.insert(0, str(entry)) @@ -45,14 +76,15 @@ def initialize(self, version: str, build_data: Dict[str, Any]) -> None: site = Path(tempfile.mkdtemp(prefix=SITE_PREFIX)) build_site(SiteRequest(docs=DOCS_DIRECTORY, project=PROJECT_FILE, output=site)) - build_data[FORCE_INCLUDE][str(site)] = SITE_DESTINATION + return site - def finalize( - self, - version: str, - build_data: Dict[str, Any], - artifact_path: str, - ) -> None: - for source, destination in build_data[FORCE_INCLUDE].items(): - if destination == SITE_DESTINATION: - shutil.rmtree(source) + @staticmethod + def _release_deployment(root: Path) -> Path: + """A copy of the release deployment settings in a temporary folder. + + Hatchling lets a forced file take the place of a packaged one when the forced file lies + outside the project, so the copy replaces the checkout's ``deployment.yaml`` in the wheel. + """ + copy = Path(tempfile.mkdtemp(prefix=DEPLOYMENT_PREFIX)) / Path(DEPLOYMENT_DESTINATION).name + shutil.copyfile(root / RELEASE_DEPLOYMENT, copy) + return copy diff --git a/scripts/bootstrap/layout.py b/scripts/bootstrap/layout.py index 12b7b545..c3651171 100644 --- a/scripts/bootstrap/layout.py +++ b/scripts/bootstrap/layout.py @@ -8,11 +8,15 @@ SCREENS_DIRECTORY: Final[str] = "tests/screens" DISTRIBUTION: Final[str] = "bin" BUNDLES: Final[str] = "bundles" +WHEELS: Final[str] = "dist" BUILD_ENVIRONMENT: Final[str] = ".venv-build" DOCUMENTATION_PACKAGE_PATH: Final[str] = "sampletones_assets/documentation" DOCUMENTATION_SITE: Final[str] = f"{SOURCE_DIRECTORY}/{DOCUMENTATION_PACKAGE_PATH}" DOCUMENTATION_FILES: Final[Tuple[str, ...]] = ("index.html", "stamp") -RELEASE_HOOK: Final[str] = "scripts/runtime_hooks/release_environment.py" +DEPLOYMENT_PACKAGE_PATH: Final[str] = "sampletones_config/application/deployment.yaml" +RELEASE_DEPLOYMENT: Final[str] = "scripts/release/deployment.yaml" +DEVELOPMENT_WHEEL: Final[str] = "SAMPLETONES_DEVELOPMENT_WHEEL" +DEVELOPMENT_WHEEL_ENABLED: Final[str] = "1" GPU_HOOK: Final[str] = "scripts/runtime_hooks/cuda_bundle.py" PYINSTALLER_HOOKS: Final[str] = "scripts/pyinstaller_hooks" NOTICES: Final[Tuple[str, ...]] = ("LICENSE", "THIRD-PARTY-NOTICES.md", "THIRD-PARTY-LICENSES.txt") @@ -27,7 +31,7 @@ BUNDLES, DOCUMENTATION_SITE, "build", - "dist", + WHEELS, "htmlcov", ".coverage", ) diff --git a/scripts/bundle.py b/scripts/bundle.py index e9e5dca6..f3b898c1 100644 --- a/scripts/bundle.py +++ b/scripts/bundle.py @@ -15,13 +15,14 @@ CUDA_LIBRARY_DIRECTORIES, CUDA_PACKAGE, CUPY_PACKAGES, + DEVELOPMENT_WHEEL, + DEVELOPMENT_WHEEL_ENABLED, DISTRIBUTION, GPU_HOOK, GPU_PACKAGES, INTERNAL_DIRECTORY, NOTICES, PYINSTALLER_HOOKS, - RELEASE_HOOK, repository_root, ) from bootstrap.platforms.bundling import Bundling @@ -96,6 +97,27 @@ def backend_arguments(options: BundleOptions) -> List[str]: return arguments +def wheel_environment(options: BundleOptions, environment: Mapping[str, str]) -> Dict[str, str]: + """The variables the package is built and installed into the build environment under. + + The wheel build writes the release deployment settings into every wheel it makes, which is + what a release bundle carries. A development bundle asks the wheel build to keep the + checkout's development settings, and a release removes that request whatever the caller set. + + Args: + options: How the bundle is built. + environment: The variables the build was started with. + + Returns: + Dict[str, str]: The variables. + """ + variables = {name: value for name, value in environment.items() if name != DEVELOPMENT_WHEEL} + if not options.release: + variables[DEVELOPMENT_WHEEL] = DEVELOPMENT_WHEEL_ENABLED + + return variables + + def cuda_library_directories(site_packages: Path) -> List[Path]: """The directories the CUDA wheels installed in ``site_packages`` keep their shared libraries in. @@ -246,9 +268,6 @@ def pyinstaller_command( command.extend(("--exclude-module", module)) command.extend(backend_arguments(options)) - if options.release: - command.extend(("--runtime-hook", RELEASE_HOOK)) - command.append(project.entry_script) return command @@ -307,7 +326,7 @@ def build_bundle( bundling = platform.bundling() project = read_project(root) if options.release: - print("Release build: onedir bundle, injecting release deployment configuration") + print("Release build: onedir bundle carrying the release deployment configuration") if options.gpu_extra is not None: print(f"GPU build: onedir bundle carrying the '{options.gpu_extra}' extra") @@ -323,7 +342,7 @@ def build_bundle( python, extras=extras(options), runner=runner, - environment=environment, + environment=wheel_environment(options, environment), ) check_build_interpreter( python, diff --git a/scripts/release/deployment.yaml b/scripts/release/deployment.yaml new file mode 100644 index 00000000..40e80238 --- /dev/null +++ b/scripts/release/deployment.yaml @@ -0,0 +1,2 @@ +log_level: INFO +strict_history: false diff --git a/scripts/runtime_hooks/release_environment.py b/scripts/runtime_hooks/release_environment.py deleted file mode 100644 index 3084ceb9..00000000 --- a/scripts/runtime_hooks/release_environment.py +++ /dev/null @@ -1,4 +0,0 @@ -import os - -os.environ.setdefault("SAMPLETONES_LOG_LEVEL", "INFO") -os.environ.setdefault("SAMPLETONES_STRICT_HISTORY", "false") diff --git a/scripts/verify_bundle.py b/scripts/verify_bundle.py index 64458b60..e627db6a 100644 --- a/scripts/verify_bundle.py +++ b/scripts/verify_bundle.py @@ -6,12 +6,14 @@ from bootstrap.layout import ( BUILD_TOOLS, + DEPLOYMENT_PACKAGE_PATH, DISTRIBUTION, DOCUMENTATION_FILES, DOCUMENTATION_PACKAGE_PATH, GPU_PACKAGES, INTERNAL_DIRECTORY, NOTICES, + RELEASE_DEPLOYMENT, repository_root, ) from bootstrap.platforms.factory import current_platform @@ -37,6 +39,20 @@ def missing_documentation(bundle: Path) -> List[str]: return [name for name in DOCUMENTATION_FILES if not (site / name).is_file()] +def carries_release_deployment(root: Path, bundle: Path) -> bool: + """Whether a bundle carries the release deployment settings, as the wheel it was built from does. + + Args: + root: The repository, which holds the release settings. + bundle: The bundle's directory, beside its launcher. + + Returns: + bool: Whether the bundle's deployment file matches the release settings byte for byte. + """ + shipped = bundle / INTERNAL_DIRECTORY / DEPLOYMENT_PACKAGE_PATH + return shipped.is_file() and shipped.read_bytes() == (root / RELEASE_DEPLOYMENT).read_bytes() + + def carried_packages(bundle: Path, names: Sequence[str]) -> List[str]: """The packages among ``names`` found in a bundle, beside its launcher or among its internals.""" directories = (bundle, bundle / INTERNAL_DIRECTORY) @@ -71,8 +87,8 @@ def bundle_failures( ) -> List[str]: """What keeps the release bundle under ``root`` from shipping, each as a line of its own. - The bundle ships its notices and its documentation, carries the application and its CPU runtime - dependencies alone, and offers a launcher that starts. + The bundle ships its notices and its documentation, carries the release deployment settings, + the application and its CPU runtime dependencies alone, and offers a launcher that starts. Args: root: The repository the bundle was built in. @@ -98,6 +114,9 @@ def bundle_failures( if pages: failures.append(f"Bundle {bundle} is missing its documentation: {', '.join(pages)}") + if not carries_release_deployment(root, bundle): + failures.append(f"Bundle {bundle} carries deployment settings other than {RELEASE_DEPLOYMENT}") + carried = carried_build_tools(bundle) if carried: failures.append(f"Bundle {bundle} carries build-time tooling its notices leave out: {', '.join(carried)}") @@ -118,8 +137,8 @@ def bundle_failures( def main(argv: Sequence[str]) -> int: - """Confirms the release bundle ships its notices and its documentation, holds to the notices, and that its - launcher starts.""" + """Confirms the release bundle ships its notices, its documentation and the release deployment settings, + holds to the notices, and that its launcher starts.""" parser = argparse.ArgumentParser(description="Verify the release bundle before it is archived.") parser.parse_args(list(argv)) @@ -130,7 +149,7 @@ def main(argv: Sequence[str]) -> int: if failures: return 1 - print("The bundle ships its notices and its documentation, and its launcher starts") + print("The bundle ships its notices, its documentation and the release settings, and its launcher starts") return 0 diff --git a/scripts/verify_wheel.py b/scripts/verify_wheel.py new file mode 100644 index 00000000..224a3318 --- /dev/null +++ b/scripts/verify_wheel.py @@ -0,0 +1,64 @@ +import argparse +import sys +import zipfile +from pathlib import Path +from typing import Final, List, Sequence + +from bootstrap.layout import DEPLOYMENT_PACKAGE_PATH, RELEASE_DEPLOYMENT, WHEELS, repository_root + +WHEEL_PATTERN: Final[str] = "*.whl" + + +def deployment_entries(wheel: Path) -> List[bytes]: + """Every copy of the deployment settings a wheel carries, in archive order.""" + with zipfile.ZipFile(wheel) as archive: + return [archive.read(entry) for entry in archive.infolist() if entry.filename == DEPLOYMENT_PACKAGE_PATH] + + +def wheel_failures(root: Path) -> List[str]: + """What keeps the wheels built under ``root`` from publishing, each as a line of its own. + + Every wheel carries the release deployment settings once, as the wheel build writes them, so an + installed package is quiet and self-healing. + + Args: + root: The repository the wheels were built in. + + Returns: + List[str]: The failures, empty for wheels ready to publish. + """ + directory = root / WHEELS + wheels = sorted(directory.glob(WHEEL_PATTERN)) + if not wheels: + return [f"No wheel found in {directory}"] + + release = (root / RELEASE_DEPLOYMENT).read_bytes() + failures: List[str] = [] + for wheel in wheels: + entries = deployment_entries(wheel) + if len(entries) != 1: + failures.append(f"Wheel {wheel.name} carries {len(entries)} copies of {DEPLOYMENT_PACKAGE_PATH}") + elif entries[0] != release: + failures.append(f"Wheel {wheel.name} carries deployment settings other than {RELEASE_DEPLOYMENT}") + + return failures + + +def main(argv: Sequence[str]) -> int: + """Confirms every built wheel carries the release deployment settings.""" + parser = argparse.ArgumentParser(description="Verify the built wheels before they are published.") + parser.parse_args(list(argv)) + + failures = wheel_failures(repository_root()) + for failure in failures: + print(f"::error::{failure}") + + if failures: + return 1 + + print("Every wheel carries the release deployment settings") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/src/sampletones_application/config/deployment/deployment.py b/src/sampletones_application/config/deployment/deployment.py index d537bc7b..df1ce793 100644 --- a/src/sampletones_application/config/deployment/deployment.py +++ b/src/sampletones_application/config/deployment/deployment.py @@ -22,13 +22,13 @@ class DeploymentConfig(BaseModel, frozen=True): Every field is required, and ``deployment.yaml`` supplies the baseline. It carries the development values — verbose logging, and strict history so a missing transaction is reported the moment it happens rather than healed in silence — since the source tree is - where an edit is written and where that report is worth having. A release build injects the - user-facing values through ``scripts/runtime_hooks/release_environment.py``, so a shipped artifact is quiet - and self-healing whatever the tree it was built from said. + where an edit is written and where that report is worth having. Every wheel built for + distribution carries the user-facing values from ``scripts/release/deployment.yaml`` in this + file's place (``hatch_build.py``), so a published package, and a release bundle installed from + one, is quiet and self-healing whatever the tree it was built from said. The ``SAMPLETONES_LOG_LEVEL`` and ``SAMPLETONES_STRICT_HISTORY`` environment variables set - either field, which is how the release hook states its values and how a run of either kind - takes the other's. + either field, which is how a run of either kind takes the other's. """ log_level: LogLevel diff --git a/tests/unit/sampletones_application/config/deployment/test_deployment.py b/tests/unit/sampletones_application/config/deployment/test_deployment.py index 5d44b39d..32125aae 100644 --- a/tests/unit/sampletones_application/config/deployment/test_deployment.py +++ b/tests/unit/sampletones_application/config/deployment/test_deployment.py @@ -4,8 +4,11 @@ import yaml from pydantic import ValidationError +from bootstrap.layout import RELEASE_DEPLOYMENT from sampletones_application.config.deployment.deployment import DeploymentConfig from sampletones_application.config.deployment.logs import LogLevel +from sampletones_application.paths import DEPLOYMENT_CONFIG_PATH +from sampletones_shared.paths.source import REPOSITORY_ROOT LOG_LEVEL_ENV = "SAMPLETONES_LOG_LEVEL" STRICT_HISTORY_ENV = "SAMPLETONES_STRICT_HISTORY" @@ -113,3 +116,13 @@ def test_non_mapping_file_raises_type_error(self, tmp_path: Path) -> None: with pytest.raises(TypeError): DeploymentConfig.load(path) + + +class TestShippedDeployments: + """The checkout reports a missing transaction the moment it happens, and a release heals it.""" + + def test_the_checkout_holds_history_strictly(self) -> None: + assert DeploymentConfig.load(DEPLOYMENT_CONFIG_PATH).strict_history is True + + def test_a_release_heals_history(self) -> None: + assert DeploymentConfig.load(REPOSITORY_ROOT / RELEASE_DEPLOYMENT).strict_history is False diff --git a/tests/unit/scripts/test_bundle.py b/tests/unit/scripts/test_bundle.py index 83276979..e26beffb 100644 --- a/tests/unit/scripts/test_bundle.py +++ b/tests/unit/scripts/test_bundle.py @@ -10,13 +10,14 @@ BUILD_TOOLS, CUDA_PACKAGE, CUPY_PACKAGES, + DEVELOPMENT_WHEEL, + DEVELOPMENT_WHEEL_ENABLED, DISTRIBUTION, GPU_HOOK, GPU_PACKAGES, INTERNAL_DIRECTORY, NOTICES, PYINSTALLER_HOOKS, - RELEASE_HOOK, ) from bootstrap.platforms.linux import Linux from bootstrap.platforms.macos import MacOS @@ -59,14 +60,14 @@ def test_gpu_support_follows_the_extra(self) -> None: class TestPyInstallerCommand: - def test_a_release_is_a_directory_with_the_runtime_hook(self, tmp_path: Path) -> None: + def test_a_release_is_a_directory_taking_its_settings_from_the_wheel(self, tmp_path: Path) -> None: project = read_project(_repository(tmp_path)) command = bundle.pyinstaller_command(Path("python"), Linux().bundling(), project, CPU_RELEASE) assert command[:3] == ["python", "-m", "PyInstaller"] assert "--onedir" in command - assert _values(command, "--runtime-hook") == [RELEASE_HOOK] + assert "--runtime-hook" not in command assert command[-1] == project.entry_script def test_a_cpu_development_bundle_is_one_file_without_the_hook(self, tmp_path: Path) -> None: @@ -98,13 +99,26 @@ def test_a_gpu_bundle_is_a_directory_collecting_cupy_through_the_hooks(self, tmp assert _values(command, "--runtime-hook") == [GPU_HOOK] assert _values(command, "--exclude-module") == list(BUILD_TOOLS) - def test_a_gpu_release_carries_both_runtime_hooks(self, tmp_path: Path) -> None: + def test_a_gpu_release_carries_the_gpu_runtime_hook_alone(self, tmp_path: Path) -> None: project = read_project(_repository(tmp_path)) options = bundle.BundleOptions(release=True, gpu_extra=GPU_EXTRA) command = bundle.pyinstaller_command(Path("python"), Linux().bundling(), project, options) - assert _values(command, "--runtime-hook") == [GPU_HOOK, RELEASE_HOOK] + assert _values(command, "--runtime-hook") == [GPU_HOOK] + + +class TestWheelEnvironment: + def test_a_development_bundle_asks_the_wheel_for_the_checkout_settings(self) -> None: + variables = bundle.wheel_environment(CPU_DEVELOPMENT, {"PATH": "/usr/bin"}) + + assert variables == {"PATH": "/usr/bin", DEVELOPMENT_WHEEL: DEVELOPMENT_WHEEL_ENABLED} + + def test_a_release_takes_the_release_settings_whatever_the_caller_set(self) -> None: + environment = {"PATH": "/usr/bin", DEVELOPMENT_WHEEL: DEVELOPMENT_WHEEL_ENABLED} + + assert bundle.wheel_environment(CPU_RELEASE, environment) == {"PATH": "/usr/bin"} + assert environment[DEVELOPMENT_WHEEL] == DEVELOPMENT_WHEEL_ENABLED class TestExtras: @@ -258,6 +272,7 @@ def test_a_release_runs_every_step_in_order_and_places_the_notices(self, tmp_pat assert runner.lines[6] == f"{launcher} {bundle.SELF_CHECK} {bundle.DOCUMENTATION_FLAG}" assert all((launcher.parent / notice).read_text() == notice for notice in NOTICES) assert _environment_of(runner, "PyInstaller") == {} + assert DEVELOPMENT_WHEEL not in _environment_of(runner, f".[{BUILD_EXTRA}]") def test_a_gpu_bundle_installs_the_extra_probes_cupy_and_lands_in_a_directory(self, tmp_path: Path) -> None: root = _repository(tmp_path) @@ -273,6 +288,7 @@ def test_a_gpu_bundle_installs_the_extra_probes_cupy_and_lands_in_a_directory(se assert "PyInstaller" in runner.lines[6] assert runner.lines[7] == f"{launcher} {bundle.SELF_CHECK} {bundle.DOCUMENTATION_FLAG} {bundle.GPU_FLAG}" assert not any((launcher.parent / notice).exists() for notice in NOTICES) + assert _environment_of(runner, f".[{BUILD_EXTRA},{GPU_EXTRA}]")[DEVELOPMENT_WHEEL] == DEVELOPMENT_WHEEL_ENABLED def test_a_gpu_build_resolves_cuda_libraries_in_the_wheels_and_the_bundle_finds_them_alone( self, diff --git a/tests/unit/scripts/test_verify_bundle.py b/tests/unit/scripts/test_verify_bundle.py index 6d0f4b29..927b2ce1 100644 --- a/tests/unit/scripts/test_verify_bundle.py +++ b/tests/unit/scripts/test_verify_bundle.py @@ -2,8 +2,11 @@ import pytest +import hatch_build from bootstrap.layout import ( BUILD_TOOLS, + DEPLOYMENT_PACKAGE_PATH, + DEVELOPMENT_WHEEL, DISTRIBUTION, DOCUMENTATION_FILES, DOCUMENTATION_PACKAGE_PATH, @@ -11,18 +14,23 @@ GPU_PACKAGES, INTERNAL_DIRECTORY, NOTICES, + RELEASE_DEPLOYMENT, SOURCE_DIRECTORY, ) from bootstrap.platforms.linux import Linux from bootstrap.platforms.macos import MacOS from hatch_build import SITE_DESTINATION +from sampletones_application.paths import DEPLOYMENT_CONFIG_PATH from sampletones_shared.documentation.site import DOCUMENTATION_DIRECTORY, INDEX_FILE, STAMP_FILE -from sampletones_shared.paths.resources import ASSETS_PACKAGE +from sampletones_shared.paths.resources import ASSETS_PACKAGE, CONFIG_DIRECTORY from tests.suite.bootstrap import PROJECT_NAME, RecordingRunner, write_project from tests.suite.scripts import load_script verify_bundle = load_script("verify_bundle.py") +RELEASE_SETTINGS = b"log_level: INFO\nstrict_history: false\n" +DEVELOPMENT_SETTINGS = b"log_level: DEBUG\nstrict_history: true\n" + @pytest.fixture(name="root") def root_fixture(tmp_path: Path) -> Path: @@ -38,6 +46,10 @@ def root_fixture(tmp_path: Path) -> Path: for name in DOCUMENTATION_FILES: (site / name).write_text(name) + for settings in (tmp_path / RELEASE_DEPLOYMENT, _deployment(launcher.parent)): + settings.parent.mkdir(parents=True, exist_ok=True) + settings.write_bytes(RELEASE_SETTINGS) + return write_project(tmp_path) @@ -49,6 +61,10 @@ def _site(bundle: Path) -> Path: return bundle / INTERNAL_DIRECTORY / DOCUMENTATION_PACKAGE_PATH +def _deployment(bundle: Path) -> Path: + return bundle / INTERNAL_DIRECTORY / DEPLOYMENT_PACKAGE_PATH + + class TestMissingNotices: def test_a_complete_bundle_lacks_nothing(self, root: Path) -> None: assert verify_bundle.missing_notices(_bundle(root)) == [] @@ -96,6 +112,34 @@ def test_the_files_are_the_index_and_the_stamp(self) -> None: assert DOCUMENTATION_FILES == (INDEX_FILE, STAMP_FILE) +class TestTheDeploymentContract: + """The scripts spell the deployment file's place, the release settings and the request for a development + wheel themselves; each has to be the one the application reads and the build hook writes.""" + + def test_the_package_path_is_the_one_the_application_reads(self) -> None: + assert Path(DEPLOYMENT_PACKAGE_PATH) == DEPLOYMENT_CONFIG_PATH.relative_to(CONFIG_DIRECTORY.parent) + assert DEPLOYMENT_PACKAGE_PATH == hatch_build.DEPLOYMENT_DESTINATION + + def test_the_release_settings_and_the_request_are_the_build_hook_s(self) -> None: + assert RELEASE_DEPLOYMENT == hatch_build.RELEASE_DEPLOYMENT + assert DEVELOPMENT_WHEEL == hatch_build.DEVELOPMENT_WHEEL + + +class TestCarriesReleaseDeployment: + def test_the_release_settings_are_carried(self, root: Path) -> None: + assert verify_bundle.carries_release_deployment(root, _bundle(root)) + + def test_the_development_settings_are_refused(self, root: Path) -> None: + _deployment(_bundle(root)).write_bytes(DEVELOPMENT_SETTINGS) + + assert not verify_bundle.carries_release_deployment(root, _bundle(root)) + + def test_a_bundle_without_settings_is_refused(self, root: Path) -> None: + _deployment(_bundle(root)).unlink() + + assert not verify_bundle.carries_release_deployment(root, _bundle(root)) + + class TestCarriedBuildTools: def test_an_application_bundle_holds_to_its_notices(self, root: Path) -> None: assert verify_bundle.carried_build_tools(_bundle(root)) == [] @@ -151,6 +195,13 @@ def test_bundled_build_tooling_is_named(self, root: Path) -> None: assert [BUILD_TOOLS[0] in failure for failure in failures] == [True] + def test_a_bundle_with_development_settings_is_named(self, root: Path) -> None: + _deployment(_bundle(root)).write_bytes(DEVELOPMENT_SETTINGS) + + failures = verify_bundle.bundle_failures(root, Linux(), runner=RecordingRunner({}, None), environment={}) + + assert [RELEASE_DEPLOYMENT in failure for failure in failures] == [True] + def test_a_release_built_with_gpu_support_is_named(self, root: Path) -> None: (_bundle(root) / INTERNAL_DIRECTORY / GPU_PACKAGES[0]).mkdir(parents=True) diff --git a/tests/unit/scripts/test_verify_wheel.py b/tests/unit/scripts/test_verify_wheel.py new file mode 100644 index 00000000..29799233 --- /dev/null +++ b/tests/unit/scripts/test_verify_wheel.py @@ -0,0 +1,69 @@ +import warnings +import zipfile +from dataclasses import dataclass +from pathlib import Path +from typing import Optional, Sequence + +import pytest + +from bootstrap.layout import DEPLOYMENT_PACKAGE_PATH, RELEASE_DEPLOYMENT, WHEELS +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase +from tests.suite.scripts import load_script + +verify_wheel = load_script("verify_wheel.py") + +RELEASE_SETTINGS = b"log_level: INFO\nstrict_history: false\n" +DEVELOPMENT_SETTINGS = b"log_level: DEBUG\nstrict_history: true\n" +WHEEL_NAME = "sampletones-0.3.2-py3-none-any.whl" + + +def _write_wheel(wheel: Path, settings: Sequence[bytes]) -> None: + """A wheel holding a module and ``settings`` as its deployment file, once per entry.""" + wheel.parent.mkdir(parents=True, exist_ok=True) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + with zipfile.ZipFile(wheel, "w") as archive: + archive.writestr("sampletones/__init__.py", "") + for entry in settings: + archive.writestr(DEPLOYMENT_PACKAGE_PATH, entry) + + +@pytest.fixture(name="root") +def root_fixture(tmp_path: Path) -> Path: + """A repository holding the release settings.""" + release = tmp_path / RELEASE_DEPLOYMENT + release.parent.mkdir(parents=True) + release.write_bytes(RELEASE_SETTINGS) + return tmp_path + + +class TestWheelFailures(BaseTestSuite): + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + settings: Sequence[bytes] + expected: Optional[str] + + test_cases = ( + TestCase(label="the release settings once", settings=(RELEASE_SETTINGS,), expected=None), + TestCase(label="the development settings", settings=(DEVELOPMENT_SETTINGS,), expected=RELEASE_DEPLOYMENT), + TestCase(label="no settings", settings=(), expected="0 copies"), + TestCase(label="two copies", settings=(RELEASE_SETTINGS, DEVELOPMENT_SETTINGS), expected="2 copies"), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_each_wheel_carries_the_release_settings_once(self, root: Path, test_case: TestCase) -> None: + _write_wheel(root / WHEELS / WHEEL_NAME, test_case.settings) + + failures = verify_wheel.wheel_failures(root) + + if test_case.expected is None: + assert failures == [] + return + + assert len(failures) == 1 + assert WHEEL_NAME in failures[0] + assert test_case.expected in failures[0] + + def test_a_build_without_wheels_is_refused(self, root: Path) -> None: + assert verify_wheel.wheel_failures(root) == [f"No wheel found in {root / WHEELS}"] diff --git a/tests/unit/test_hatch_build.py b/tests/unit/test_hatch_build.py index d48d04bc..aa7244af 100644 --- a/tests/unit/test_hatch_build.py +++ b/tests/unit/test_hatch_build.py @@ -5,7 +5,16 @@ from assets.documentation.build import SiteRequest from assets.documentation.paths import DOCS_DIRECTORY, PROJECT_FILE -from hatch_build import EDITABLE, FORCE_INCLUDE, SITE_DESTINATION, DocumentationBuildHook +from hatch_build import ( + DEPLOYMENT_DESTINATION, + DEVELOPMENT_WHEEL, + DEVELOPMENT_WHEEL_ENABLED, + EDITABLE, + FORCE_INCLUDE, + RELEASE_DEPLOYMENT, + SITE_DESTINATION, + DistributionBuildHook, +) from sampletones_shared.documentation.site import INDEX_FILE, DocumentationSite from sampletones_shared.paths.source import REPOSITORY_ROOT @@ -32,9 +41,14 @@ def builds(monkeypatch: pytest.MonkeyPatch) -> RecordedBuilds: return recorded +@pytest.fixture(autouse=True) +def _distribution_wheel(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv(DEVELOPMENT_WHEEL, raising=False) + + @pytest.fixture -def hook(tmp_path: Path) -> DocumentationBuildHook: - return DocumentationBuildHook( +def hook(tmp_path: Path) -> DistributionBuildHook: + return DistributionBuildHook( root=str(REPOSITORY_ROOT), config={}, build_config=None, @@ -49,10 +63,15 @@ def build_data() -> Dict[str, Any]: return {FORCE_INCLUDE: {}} -class TestDocumentationBuildHook: +def _source_of(build_data: Dict[str, Any], destination: str) -> Path: + [source] = [source for source, target in build_data[FORCE_INCLUDE].items() if target == destination] + return Path(source) + + +class TestDistributionBuildHook: def test_an_editable_build_carries_no_site( self, - hook: DocumentationBuildHook, + hook: DistributionBuildHook, builds: RecordedBuilds, build_data: Dict[str, Any], ) -> None: @@ -63,29 +82,61 @@ def test_an_editable_build_carries_no_site( def test_a_wheel_carries_the_checkout_docs_at_the_package_path( self, - hook: DocumentationBuildHook, + hook: DistributionBuildHook, + builds: RecordedBuilds, + build_data: Dict[str, Any], + ) -> None: + hook.initialize(STANDARD, build_data) + + site = _source_of(build_data, SITE_DESTINATION) + assert builds.requests == [SiteRequest(docs=DOCS_DIRECTORY, project=PROJECT_FILE, output=site)] + assert DocumentationSite(site).read_stamp() is not None + + hook.finalize(STANDARD, build_data, "sampletones.whl") + + def test_a_wheel_carries_the_release_deployment_from_outside_the_checkout( + self, + hook: DistributionBuildHook, builds: RecordedBuilds, build_data: Dict[str, Any], ) -> None: hook.initialize(STANDARD, build_data) - [(source, destination)] = build_data[FORCE_INCLUDE].items() - assert destination == SITE_DESTINATION - assert builds.requests == [SiteRequest(docs=DOCS_DIRECTORY, project=PROJECT_FILE, output=Path(source))] - assert DocumentationSite(Path(source)).read_stamp() is not None + deployment = _source_of(build_data, DEPLOYMENT_DESTINATION) + assert deployment.read_bytes() == (REPOSITORY_ROOT / RELEASE_DEPLOYMENT).read_bytes() + assert REPOSITORY_ROOT not in deployment.parents + assert len(builds.requests) == 1 + + hook.finalize(STANDARD, build_data, "sampletones.whl") + + def test_a_development_wheel_keeps_the_checkout_deployment( + self, + hook: DistributionBuildHook, + builds: RecordedBuilds, + build_data: Dict[str, Any], + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv(DEVELOPMENT_WHEEL, DEVELOPMENT_WHEEL_ENABLED) + + hook.initialize(STANDARD, build_data) + + assert list(build_data[FORCE_INCLUDE].values()) == [SITE_DESTINATION] + assert len(builds.requests) == 1 hook.finalize(STANDARD, build_data, "sampletones.whl") - def test_the_site_is_removed_once_the_wheel_is_written( + def test_the_site_and_the_deployment_copy_are_removed_once_the_wheel_is_written( self, - hook: DocumentationBuildHook, + hook: DistributionBuildHook, builds: RecordedBuilds, build_data: Dict[str, Any], ) -> None: hook.initialize(STANDARD, build_data) - [source] = build_data[FORCE_INCLUDE] + site = _source_of(build_data, SITE_DESTINATION) + deployment = _source_of(build_data, DEPLOYMENT_DESTINATION) hook.finalize(STANDARD, build_data, "sampletones.whl") assert len(builds.requests) == 1 - assert not Path(source).exists() + assert not site.exists() + assert not deployment.parent.exists()