Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/workflow.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/*

Expand Down Expand Up @@ -173,6 +176,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:
Expand Down
2 changes: 1 addition & 1 deletion docs/development/application/undo.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
64 changes: 48 additions & 16 deletions hatch_build.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import os
import shutil
import sys
import tempfile
Expand All @@ -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"
Expand All @@ -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))
Expand All @@ -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
8 changes: 6 additions & 2 deletions scripts/bootstrap/layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -27,7 +31,7 @@
BUNDLES,
DOCUMENTATION_SITE,
"build",
"dist",
WHEELS,
"htmlcov",
".coverage",
)
Expand Down
31 changes: 25 additions & 6 deletions scripts/bundle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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")
Expand All @@ -323,7 +342,7 @@ def build_bundle(
python,
extras=extras(options),
runner=runner,
environment=environment,
environment=wheel_environment(options, environment),
)
check_build_interpreter(
python,
Expand Down
2 changes: 2 additions & 0 deletions scripts/release/deployment.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
log_level: INFO
strict_history: false
4 changes: 0 additions & 4 deletions scripts/runtime_hooks/release_environment.py

This file was deleted.

29 changes: 24 additions & 5 deletions scripts/verify_bundle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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.
Expand All @@ -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)}")
Expand All @@ -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))

Expand All @@ -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


Expand Down
64 changes: 64 additions & 0 deletions scripts/verify_wheel.py
Original file line number Diff line number Diff line change
@@ -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:]))
10 changes: 5 additions & 5 deletions src/sampletones_application/config/deployment/deployment.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading