From 8d92a5521520129f9de10f679c7b83ff62a05099 Mon Sep 17 00:00:00 2001 From: fredaime Date: Thu, 1 Oct 2026 10:30:00 +0000 Subject: [PATCH] release: 0.3.3 A second distribution is built from this repository: sayfirst, the product installed whole. It installs no code of its own, depends on sayfirst-cli, sayfirst-control-plane and sayfirstd at its own version, and names their three commands, so that one install yields all three. The gate measures what installing it brings, on a real install. sayfirst-cli changes in its version and its pins only. Signed-off-by: fredaime --- .github/workflows/release.yml | 27 +- CHANGELOG.md | 12 + QUICKSTART.md | 26 +- README.md | 6 +- docs/publication-checklist.md | 19 +- pyproject.toml | 6 +- scripts/check_umbrella_install.py | 280 ++++++++++++++++++ scripts/gate.sh | 33 ++- scripts/release_version.py | 59 ++-- tests/test_gate_modes.py | 14 +- tests/test_pointers_survive_publication.py | 1 + tests/test_public_vocabulary.py | 3 + ...t_publication_checklist_names_every_act.py | 6 +- .../test_release_builds_every_distribution.py | 68 +++++ .../test_release_reads_both_distributions.py | 87 ++++++ tests/test_umbrella_install.py | 171 +++++++++++ ...mbrella_metadata_agrees_with_the_client.py | 82 +++++ tests/test_umbrella_notices.py | 91 ++++++ ...mbrella_page_says_what_the_install_does.py | 97 ++++++ tests/test_umbrella_pins_one_version.py | 99 +++++++ tests/test_umbrella_ships_no_code.py | 68 +++++ umbrella/README.md | 77 +++++ umbrella/hatch_build.py | 31 ++ umbrella/pyproject.toml | 60 ++++ 24 files changed, 1352 insertions(+), 71 deletions(-) create mode 100644 scripts/check_umbrella_install.py create mode 100644 tests/test_release_builds_every_distribution.py create mode 100644 tests/test_release_reads_both_distributions.py create mode 100644 tests/test_umbrella_install.py create mode 100644 tests/test_umbrella_metadata_agrees_with_the_client.py create mode 100644 tests/test_umbrella_notices.py create mode 100644 tests/test_umbrella_page_says_what_the_install_does.py create mode 100644 tests/test_umbrella_pins_one_version.py create mode 100644 tests/test_umbrella_ships_no_code.py create mode 100644 umbrella/README.md create mode 100644 umbrella/hatch_build.py create mode 100644 umbrella/pyproject.toml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 937d5d6..0832832 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,12 +8,12 @@ name: release # that starts a publication is the operator's tag and nothing else: there is no # `branches:` under `push` and no `pull_request:` here, and # `tests/test_release_workflow_publishes_only_on_a_tag.py` fails if either -# arrives. A tag naming a version this distribution does not carry is refused -# by name in the build and publishes nothing. +# arrives. A tag naming a version the two distributions built here do not +# carry is refused by name in the build and publishes nothing. # # The dry run exists so the mechanics are proven BEFORE the marks rather than # discovered after them: started by hand with publishing switched off, it runs -# the gate, builds the distribution and checks the artefact, and stops. +# the gate, builds the distributions and checks the artefacts, and stops. # # WHERE THIS WORKFLOW READS THE CONTRACT, AND WHY IT IS NOT WHERE `ci.yml` # READS IT. The gate builds `sayfirst-contract` from a checkout of the control @@ -72,7 +72,7 @@ permissions: jobs: build: - name: build and check the distribution + name: build and check the distributions runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 @@ -95,12 +95,12 @@ jobs: echo "::error::a dispatch publishes nothing: re-run it with publish false to dry-run, and publish by tagging" exit 1 - - name: The tag names the version this distribution carries + - name: The tag names the version the distributions carry if: github.event_name == 'push' working-directory: sayfirst-cli run: uv run --no-project --python 3.12 python scripts/release_version.py --expect "${GITHUB_REF_NAME#v}" - - name: The distribution carries a version + - name: The distributions carry one version if: github.event_name == 'workflow_dispatch' working-directory: sayfirst-cli run: uv run --no-project --python 3.12 python scripts/release_version.py @@ -160,8 +160,8 @@ jobs: 0) echo "### gate: full green" >> "$GITHUB_STEP_SUMMARY" echo "" >> "$GITHUB_STEP_SUMMARY" - echo "Format, lint, every test and the dependency-closure guard of articles 13" >> "$GITHUB_STEP_SUMMARY" - echo "and 14 ran against the contract this release pins." >> "$GITHUB_STEP_SUMMARY" + echo "Format, lint, every test, the dependency-closure guard of articles 13 and 14" >> "$GITHUB_STEP_SUMMARY" + echo "and the umbrella's install check ran against the contract this release pins." >> "$GITHUB_STEP_SUMMARY" ;; 75) echo "::error::the gate came back reduced: a release is never cut from a reduced gate" @@ -172,9 +172,16 @@ jobs: ;; esac - - name: Build the distribution + # Two projects, one `dist/`: the client, and the umbrella that installs + # the product whole. The publish job uploads what is left here, so a + # project this step does not build is a distribution a tag does not + # publish; `tests/test_release_builds_every_distribution.py` holds the + # two lines against the projects the version reader reads. + - name: Build the distributions working-directory: sayfirst-cli - run: uv build --out-dir dist + run: | + uv build --out-dir dist + uv build umbrella --out-dir dist # The checker is pinned, as the control plane's is and to the same version: # an unpinned checker is a check whose rules can change between two # releases without either release changing. diff --git a/CHANGELOG.md b/CHANGELOG.md index 8603bcb..249fde5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ declares; a release with no section fails the gate. ## Unreleased +## 0.3.3 + +- A second distribution is built from this repository: `sayfirst`, the product + installed whole. It installs no code of its own. It depends on `sayfirst-cli`, + `sayfirst-control-plane` and `sayfirstd` at its own version and names their + three commands, so one install — `uv tool install sayfirst` — yields + `sayfirst`, `sayfirst-daemon` and `sayfirstd`. `sayfirst-cli` is still the + client alone: installing it still brings the contract and the boundary and + never the daemon. +- The contract and the boundary move to 0.3.3, released with this client. + `sayfirst-cli` itself changes in its version and its pins only. + ## 0.3.2 - The contract and the boundary move to 0.3.2, which carries security fixes. diff --git a/QUICKSTART.md b/QUICKSTART.md index 7db9cb9..df17d4b 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -11,12 +11,16 @@ $ sayfirst instrument run --pack subprocess --scope local -- python3 my_agent.py ``` Everything on this page was run, in this order, before it was written down: the -commands are pasted from that run, and so are their answers. The run installed -0.3.2 with the first command from the Python index, on a PATH with `python3` -and no `python`, in a shell with neither repository on any path, under a home -directory made for it; its paths are written here the way they read under an -ordinary account (`~`, and `/run/user/1000` for the runtime directory). Your -identifiers, timestamps and hashes will differ; the shapes will not. +commands are pasted from a run, and so are their answers. The walk recorded for +this release installed 0.3.3 with the first command from wheels built from the +two release trees, given `--no-index` (it ran before 0.3.3 reached the index; +« From checkouts instead » below is that install), on a PATH with `python3` and +no `python`, in a shell with neither repository on any path, under a home +directory made for it. It answered in the same shapes as the walk whose answers +are printed here, which is an earlier release's: only the five versions of +section 1 were moved. Paths are written the way they read under an ordinary +account (`~`, and `/run/user/1000` for the runtime directory). Your +identifiers, timestamps, sizes and hashes will differ; the shapes will not. ## What you need @@ -40,11 +44,11 @@ $ uv tool install sayfirst-cli --with-executables-from sayfirst-control-plane -- Resolved 5 packages in 3ms Prepared 5 packages in 6ms Installed 5 packages in 1ms - + sayfirst-boundary==0.3.2 - + sayfirst-cli==0.3.2 - + sayfirst-contract==0.3.2 - + sayfirst-control-plane==0.3.2 - + sayfirstd==0.3.2 + + sayfirst-boundary==0.3.3 + + sayfirst-cli==0.3.3 + + sayfirst-contract==0.3.3 + + sayfirst-control-plane==0.3.3 + + sayfirstd==0.3.3 Installed 1 executable from `sayfirst-control-plane`: sayfirst-daemon Installed 1 executable from `sayfirstd`: sayfirstd Installed 1 executable: sayfirst diff --git a/README.md b/README.md index 5b74e60..1e1ff84 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ command on that page was run, in that order, before it was written down. ### What the index holds -Everything the three commands above need is on the Python index at 0.3.2: +Everything the three commands above need is on the Python index at 0.3.3: `sayfirst-cli`, the `sayfirst-contract` it speaks and the `sayfirst-boundary` a governed program holds its grants in, and the control plane's `sayfirst-control-plane` and `sayfirstd`. The first command installs all five @@ -228,7 +228,7 @@ contract is built from a checkout of the control plane's repository, at the tag this client pins: ```console -$ SAYFIRST_CONTRACT_SOURCE=../sayfirst-control-plane SAYFIRST_CONTRACT_REF=v0.3.2 ./scripts/gate.sh +$ SAYFIRST_CONTRACT_SOURCE=../sayfirst-control-plane SAYFIRST_CONTRACT_REF=v0.3.3 ./scripts/gate.sh ``` The workflow does the same and carries no credential of any kind: a fork can @@ -255,7 +255,7 @@ GitHub's private reporting, never a public issue — [`LICENSE`](LICENSE), ## Status -**0.2.0 is the first public release**, 2026-09-17. 0.3.2 is the current one. +**0.2.0 is the first public release**, 2026-09-17. 0.3.3 is the current one. What is *not* here is named too, because a surface a reader assumes is an overclaim: `connect`, `profile`, `whoami`, `integrate` and `version` are in [`docs/PARTITION.md`](docs/PARTITION.md) and none of them exists here. diff --git a/docs/publication-checklist.md b/docs/publication-checklist.md index f064225..f1698ca 100644 --- a/docs/publication-checklist.md +++ b/docs/publication-checklist.md @@ -64,15 +64,28 @@ the hosting platform. over exactly what will be pushed, before the push, so that every claim the guards hold is checked rather than asserted. Let the private address in `SECURITY.md` leave in the same change. - [ ] **Change the display name before the tag if wanted.** The author recorded in this - repository's one project file is `fredaime `, which is the identity - every commit is signed off under. A different display name for the index is a change to that - file's `authors` table, before the tag. + repository's two project files is `fredaime `, which is the identity + every commit is signed off under. A different display name for the index is a change to the + `authors` table of both, in one commit, before the tag: + `tests/test_umbrella_metadata_agrees_with_the_client.py` holds them equal. - [ ] **Require a reviewer on the `pypi` deployment environment, and create the trusted-publishing relationship** for this repository's release workflow on the index itself. The release workflow names the environment and asks for `id-token: write` instead of a stored token; whether the environment demands an approval, and whether the index trusts this workflow's identity, are settings of the repository and of the index that no file here can read, and none of them claims to. +- [ ] **Create a pending publisher for every project the index does not know yet.** A release + that adds a project uploads its files one at a time and stops at the first file of a project + the index does not know: what was sent before it stays, which may be nothing or part of a + project, and a file the index already holds cannot be sent again. A project that has never + been uploaded is trusted by nothing until a pending publisher names it. For `sayfirst`, first + released in 0.3.3: on the index, under the account's publishing settings, add a pending + publisher with the project name `sayfirst`, this public repository, the workflow + `release.yml` and the environment `pypi`. The index refused a second pending publisher for + one repository, workflow and environment on 2026-09-17, so a release that adds two projects + the index does not know is two releases, or one upload by hand. No file here can read whether + it was done, and a release rehearsed with publishing switched off cannot either, since it + uploads nothing. - [ ] **Set the sign-off check as a required status** in the public repository's branch protection. Contributions arrive under the Developer Certificate of Origin (article 15), and whether a failing check blocks a merge is a branch-protection setting of the public repository which no diff --git a/pyproject.toml b/pyproject.toml index 7d0a2d3..6037870 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ # SPDX-License-Identifier: Apache-2.0 [project] name = "sayfirst-cli" -version = "0.3.2" +version = "0.3.3" description = "The product command-line interface of the sayfirst control plane" readme = "README.md" requires-python = ">=3.12,<3.15" @@ -20,7 +20,7 @@ classifiers = [ # promise made here — it is measured after an install. `sayfirst-boundary` joins # it because a governed program holds a grant in it (article 10); it depends on # the contract only, and it is not the server (article 14). -dependencies = ["sayfirst-contract==0.3.2", "sayfirst-boundary==0.3.2"] +dependencies = ["sayfirst-contract==0.3.3", "sayfirst-boundary==0.3.3"] # The public repository this distribution sends a reader to, created fresh in # the act that published it (2026-09-17) under the names the operator chose for @@ -41,7 +41,7 @@ sayfirst = "sayfirst_cli.main:run" dev = [ "pytest==8.4.1", "ruff==0.12.12", - "sayfirst-contract[stub]==0.3.2", + "sayfirst-contract[stub]==0.3.3", ] [build-system] diff --git a/scripts/check_umbrella_install.py b/scripts/check_umbrella_install.py new file mode 100644 index 0000000..c3af0de --- /dev/null +++ b/scripts/check_umbrella_install.py @@ -0,0 +1,280 @@ +# SPDX-License-Identifier: Apache-2.0 +"""What installing `sayfirst` brings, measured after an install rather than promised in a file. + +This repository builds two distributions. `sayfirst-cli` is the client, and +`scripts/check_dependency_closure.py` holds what installing it brings: three +distributions, never the server (article 14). `sayfirst` is the product +installed whole: it installs no module, depends on the client, on the daemon and +on the daemon's operator surface, and names their three commands so that an +installer which exposes only the commands of the package it was handed exposes +all three. + +Three rules, each measured on a real install into an empty environment: + +* **The closure is six distributions of this project and nothing else.** Stated + in the positive, as the client's own closure rule is: a list of forbidden + names catches only the names somebody thought of. +* **Every command the umbrella names is declared, word for word, by the + distribution that owns it.** The umbrella's entry points are mirrors. A + mirror that drifts from its owner points a command at code the owner no + longer ships, and nothing in this repository's own tree can see the owners + that live in the control plane's — so the comparison is made where all four + distributions are installed side by side. +* **Each of the three commands starts.** `--help` answers 0 from the + environment the install made. + +A fourth rule needs no install and is held from the two project files: the +umbrella pins its three dependencies to its own version, which is the client's. + +Run it, and it proves itself: after the real install passes, it plants a +distribution into the same environment and requires the closure rule to fail. +A gate that has never been seen to fail is not known to be a gate. +""" + +from __future__ import annotations + +import argparse +import json +import shutil +import subprocess +import sys +import tempfile +import tomllib +from collections.abc import Iterable, Mapping +from pathlib import Path + +REPOSITORY = Path(__file__).resolve().parents[1] + +#: The directory of the second distribution this repository builds. +UMBRELLA = REPOSITORY / "umbrella" + +#: The distribution this script measures. +UMBRELLA_NAME = "sayfirst" + +#: Which distribution owns each command the umbrella names. The left column is +#: also the whole set of commands the umbrella may declare. +OWNERS: Mapping[str, str] = { + "sayfirst": "sayfirst-cli", + "sayfirst-daemon": "sayfirst-control-plane", + "sayfirstd": "sayfirstd", +} + +#: Everything the installed closure of the umbrella is, and nothing else: the +#: umbrella, the three owners, and the two distributions the owners depend on. +#: The standard library is not a distribution and does not appear here. +EXPECTED_CLOSURE: frozenset[str] = frozenset( + {UMBRELLA_NAME, *OWNERS.values(), "sayfirst-contract", "sayfirst-boundary"} +) + + +def normalise(name: str) -> str: + """A distribution name as an index compares it (PEP 503).""" + return "".join("-" if character in "-_." else character for character in name.lower()) + + +def closure_failures(closure: Iterable[str]) -> list[str]: + """Every way an installed closure is not the six distributions, one line each.""" + installed = {normalise(name) for name in closure} + reported = [] + # Anti-vacuity floor: a closure without the umbrella is an install that did + # not happen, not a closure that is clean. + if UMBRELLA_NAME not in installed: + reported.append(f"the closure does not contain {UMBRELLA_NAME}: nothing was installed") + if missing := sorted(EXPECTED_CLOSURE - installed): + reported.append(f"the install did not bring {missing}: the product is not whole") + if extra := sorted(installed - EXPECTED_CLOSURE): + reported.append(f"the install brought distributions the product does not name: {extra}") + return reported + + +def mirror_failures(declared: Mapping[str, Mapping[str, str]]) -> list[str]: + """Every way the umbrella's commands differ from what their owners declare. + + `declared` maps a distribution's name to the console scripts it declares, + each as `name -> target`, read from an installed environment. + """ + by_name = {normalise(name): dict(scripts) for name, scripts in declared.items()} + mirrored = by_name.get(UMBRELLA_NAME, {}) + reported = [] + if unknown := sorted(set(mirrored) - set(OWNERS)): + reported.append(f"the umbrella declares commands no distribution owns: {unknown}") + for command, owner in sorted(OWNERS.items()): + owned = by_name.get(owner, {}).get(command) + if owned is None: + reported.append(f"{owner} declares no command {command!r} for the umbrella to mirror") + elif command not in mirrored: + reported.append(f"the umbrella does not name {command!r}, which {owner} declares") + elif mirrored[command] != owned: + reported.append( + f"{command!r}: the umbrella points at {mirrored[command]!r} " + f"and {owner} declares {owned!r}" + ) + return reported + + +def pin_failures(umbrella: Mapping[str, object], client: Mapping[str, object]) -> list[str]: + """Every way the umbrella's project table breaks « three pins, one version ». + + Both arguments are `[project]` tables: the umbrella's, and the client's, + whose version is the one this repository releases. + """ + version = client.get("version") + reported = [] + if umbrella.get("version") != version: + reported.append( + f"the umbrella carries {umbrella.get('version')} and the client {version}: " + f"one repository releases one version" + ) + wanted = sorted(f"{owner}=={version}" for owner in OWNERS.values()) + declared = umbrella.get("dependencies") + found = sorted(str(item) for item in declared) if isinstance(declared, list) else [] + if found != wanted: + reported.append(f"the umbrella depends on {found} and the rule is exactly {wanted}") + extras = umbrella.get("optional-dependencies") + if extras: + named = sorted(extras) if isinstance(extras, dict) else extras + reported.append( + f"the umbrella declares extras {named}: " + f"the product is three pins, and an extra is a fourth dependency by another door" + ) + return reported + + +def project_table(directory: Path) -> dict[str, object]: + """The `[project]` table of the project file in this directory.""" + return tomllib.loads((directory / "pyproject.toml").read_text(encoding="utf-8"))["project"] + + +#: Asked of the installed environment, never answered here: which console +#: scripts each installed distribution declares. +SCRIPTS_PROGRAM = ( + "import json, importlib.metadata as m;" + "print(json.dumps({d.metadata['Name']: " + "{e.name: e.value for e in d.entry_points if e.group == 'console_scripts'} " + "for d in m.distributions()}))" +) + + +def read_scripts(python: Path) -> dict[str, dict[str, str]]: + """The console scripts every distribution of one environment declares.""" + output = subprocess.run( + (str(python), "-c", SCRIPTS_PROGRAM), capture_output=True, text=True, check=True + ).stdout + return json.loads(output) + + +def command_failures(binaries: Path) -> list[str]: + """Every command of the umbrella that does not start from this environment.""" + reported = [] + for command in sorted(OWNERS): + executable = binaries / command + if not executable.is_file(): + reported.append(f"the install left no {command} beside its interpreter") + continue + finished = subprocess.run((str(executable), "--help"), capture_output=True, text=True) + if finished.returncode != 0: + said = finished.stderr.strip().splitlines() + reported.append( + f"{command} --help answered {finished.returncode}: " + f"{said[-1] if said else 'nothing on its error stream'}" + ) + return reported + + +def main(argv: list[str] | None = None) -> int: + # Read here and not at the top of this file: the helpers below install and + # build, and their module imports the client, which a machine with no + # contract cannot import. The rules above are importable everywhere, so the + # tests that plant defects against them speak in a reduced run too. + import check_dependency_closure as closure_check + + parser = argparse.ArgumentParser(prog="check_umbrella_install") + parser.add_argument("--contract-source", default=None) + parser.add_argument("--python", default="3.13") + arguments = parser.parse_args(argv) + control_plane = closure_check._contract_source(arguments.contract_source) + + if reported := pin_failures(project_table(UMBRELLA), project_table(REPOSITORY)): + for line in reported: + print(f"FAIL {line}", file=sys.stderr) + return 1 + + with tempfile.TemporaryDirectory(prefix="sayfirst-umbrella-") as scratch: + root = Path(scratch) + wheelhouse = root / "wheelhouse" + wheelhouse.mkdir() + for package in ( + "sayfirst-contract", + "sayfirst-boundary", + "sayfirst-control-plane", + "sayfirstd", + ): + closure_check._build(control_plane, wheelhouse, package=package) + closure_check._build(REPOSITORY, wheelhouse) + closure_check._build(UMBRELLA, wheelhouse) + + environment = root / "environment" + subprocess.run( + ("uv", "venv", "--python", arguments.python, str(environment)), + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + python = environment / "bin" / "python" + install = subprocess.run( + ( + "uv", + "pip", + "install", + "--python", + str(python), + "--no-index", + "--find-links", + str(wheelhouse), + UMBRELLA_NAME, + ), + capture_output=True, + text=True, + ) + if install.returncode != 0: + print( + "FAIL the product could not be installed from this project's own " + "distributions. A dependency that resolves only against an index is " + "one the umbrella does not name.", + file=sys.stderr, + ) + print(install.stderr.strip(), file=sys.stderr) + return 1 + + installed = closure_check.read_closure(python) + print(f"installed closure: {installed}") + reported = closure_failures(installed) + reported += mirror_failures(read_scripts(python)) + reported += command_failures(environment / "bin") + if reported: + for line in reported: + print(f"FAIL {line}", file=sys.stderr) + return 1 + print("PASS installing sayfirst brings six distributions of this project, and nothing else") + print("PASS each command the umbrella names is declared, word for word, by its owner") + print(f"PASS the commands start from that environment: {sorted(OWNERS)}") + + # The self-proof. It runs on the environment that has just passed, so + # what it proves is that this run's check would have caught it. + planted = closure_check.plant_a_web_framework(closure_check.site_packages_of(python)) + caught = closure_failures(closure_check.read_closure(python)) + shutil.rmtree(planted) + if not caught: + print( + "FAIL a planted distribution was not caught: this guard cannot fail", + file=sys.stderr, + ) + return 1 + print("PASS the guard rejects a planted distribution:") + for line in caught: + print(f" would have failed: {line}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/gate.sh b/scripts/gate.sh index 1e36042..a42a55b 100755 --- a/scripts/gate.sh +++ b/scripts/gate.sh @@ -11,6 +11,11 @@ # articles 13 and 14, measured on a real # install, and proving on every run that it # can fail +# umbrella scripts/check_umbrella_install.py +# what installing `sayfirst` brings, measured +# on a real install: six distributions, three +# commands that start, each one declared word +# for word by the distribution that owns it # # The contract this client pins is built from source, never taken from an index: # the version a change pins may not be on one yet (it is published with the @@ -41,11 +46,12 @@ # What a reduced run does not prove: nothing about the contract, its generation # marker or its golden scenarios; nothing about the installed dependency closure # of articles 13 and 14, whose guard installs the contract in order to measure -# it; and — as of this writing, and readable in the list the run prints rather -# than promised here — nothing about this client against a daemon, because the -# tests that start one are among the ones it could not run. It proves that the -# source is formatted, that it lints, and that the guards reading only this -# repository's own files still hold. +# it; nothing about what installing `sayfirst` brings, whose guard installs the +# daemon in order to measure it; and — as of this writing, and readable in the +# list the run prints rather than promised here — nothing about this client +# against a daemon, because the tests that start one are among the ones it +# could not run. It proves that the source is formatted, that it lints, and +# that the guards reading only this repository's own files still hold. # # Which tests need the contract is decided by watching them ask for it, never by # a list here: `tests/contract_absence.py` holds the rule, this script reads back @@ -187,7 +193,11 @@ if [ "$mode" = full ]; then echo "== closure (articles 13 and 14) ==" "$python" scripts/check_dependency_closure.py \ --contract-source "$materialised" --python "$python_version" - echo "gate: full green — format, lint, $modules_run test modules and the closure check ran." + echo "== umbrella (the product installed whole) ==" + "$python" scripts/check_umbrella_install.py \ + --contract-source "$materialised" --python "$python_version" + echo "gate: full green — format, lint, $modules_run test modules, the closure check" + echo "gate: and the umbrella's install check ran." exit "$GATE_FULL_GREEN" fi @@ -203,19 +213,20 @@ if [ "$modules_run" -lt 1 ]; then exit 1 fi -echo "gate: contract absent: $((checks_not_run + 1)) checks not run" +echo "gate: contract absent: $((checks_not_run + 2)) checks not run" while IFS=$'\t' read -r kind what why; do [ -n "$what" ] || continue echo "gate: not run: $what — $why" done <"$not_run" echo "gate: not run: closure (articles 13 and 14) — it installs the contract to measure it" +echo "gate: not run: umbrella (the product installed whole) — it installs the daemon to measure it" echo "gate: reduced green — format, lint and $modules_run of $modules_present test modules ran." echo "gate: A check counted above is a test module, a single test, or a named step. The" echo "gate: tests inside a module that would not import cannot be counted, and are not:" echo "gate: this run does not know how many of them there are." echo "gate: This is not a pass. It proves nothing about the contract, nothing about the" -echo "gate: installed closure of articles 13 and 14, and — read the list above rather than" -echo "gate: this line — nothing about this client against a daemon. Point" -echo "gate: SAYFIRST_CONTRACT_SOURCE at a checkout of the control plane repository, and" -echo "gate: this becomes the full gate." +echo "gate: installed closure of articles 13 and 14, nothing about what installing" +echo "gate: \`sayfirst\` brings, and — read the list above rather than this line — nothing" +echo "gate: about this client against a daemon. Point SAYFIRST_CONTRACT_SOURCE at a" +echo "gate: checkout of the control plane repository, and this becomes the full gate." exit "$GATE_REDUCED_GREEN" diff --git a/scripts/release_version.py b/scripts/release_version.py index 4ef01c2..5f52961 100644 --- a/scripts/release_version.py +++ b/scripts/release_version.py @@ -1,11 +1,12 @@ # SPDX-License-Identifier: Apache-2.0 """The one reader of « the version this tree would release ». -This repository builds one distribution, so the reading is short; it is written -as a module rather than inline in two callers for the same reason the control -plane's is — the changelog guard and the release workflow must agree about the -version, and a version spelled twice is a version that will eventually be -spelled two ways. +This repository builds two distributions — the client, and the umbrella that +installs the product whole — and releases them under one version. The reading is +written as a module rather than inline in two callers for the same reason the +control plane's is — the changelog guard and the release workflow must agree +about the version, and a version spelled twice is a version that will eventually +be spelled two ways. The release workflow runs this through uv's own interpreter, never the runner's system `python`, and passes `--no-project`: this project's own `uv run` cannot @@ -29,38 +30,44 @@ REPOSITORY = Path(__file__).resolve().parents[1] +#: Where each project file this repository builds from lives, relative to its +#: root. The client's is the root's own; the umbrella's is one directory down. +#: A project file that is absent is skipped, so a tree that builds only the +#: client is still read; one that is present and declares no version is +#: refused. +PROJECT_DIRECTORIES: tuple[str, ...] = (".", "umbrella") + def distribution_versions(repository: Path) -> dict[str, str]: - """The distribution this repository builds, and the version it carries. + """Each distribution this repository builds, and the version it carries. - Raises `ValueError` naming the project file when it declares no version. A + Raises `ValueError` naming the project file when one declares no version. A project file with no `version` is a mistake somebody made, not a release candidate, and the reading has to say which file it read: the alternative is a `KeyError` in the middle of a release step, which reports a crash rather than a refusal and names nothing a reader can go and open. """ - project_file = repository / "pyproject.toml" - project = tomllib.loads(project_file.read_text(encoding="utf-8"))["project"] - if "version" not in project: - raise ValueError(f"{project_file} declares no version, so this tree has none to release") - return {project["name"]: project["version"]} + versions: dict[str, str] = {} + for directory in PROJECT_DIRECTORIES: + project_file = (repository / directory / "pyproject.toml").resolve() + if directory != "." and not project_file.is_file(): + continue + project = tomllib.loads(project_file.read_text(encoding="utf-8"))["project"] + if "version" not in project: + raise ValueError( + f"{project_file} declares no version, so this tree has none to release" + ) + versions[project["name"]] = project["version"] + return versions def release_version(repository: Path) -> str: """The version this tree would release. - Raises `ValueError` when the project file declares none — the reading above - refuses it — so that a caller gets a refusal rather than a `KeyError` from - the middle of a workflow. - - The branch below, which refuses several distributions carrying several - versions, is reachable only when the reading finds more than one - distribution. This repository builds one, so it cannot fire here. It is kept - because the shape is shared with the control plane's reader, where the - reading walks a directory of distributions and can find two versions among - them; one shape for one rule is how the two readers go on agreeing, and a - branch held for parity is worth more than a reading that would have to grow - the day this repository builds a second distribution. + Raises `ValueError` when a project file declares none — the reading above + refuses it — and when the distributions carry more than one version, so + that a caller gets a refusal rather than a `KeyError` from the middle of a + workflow, and a tag never publishes two distributions under two numbers. """ versions = distribution_versions(repository) distinct = sorted(set(versions.values())) @@ -82,8 +89,8 @@ def main(argv: Sequence[str] | None = None) -> int: return 1 if arguments.expect is not None and arguments.expect != version: print( - f"release_version: the tag names {arguments.expect} and this distribution " - f"carries {version}; nothing is published", + f"release_version: the tag names {arguments.expect} and the distributions " + f"carry {version}; nothing is published", file=sys.stderr, ) return 1 diff --git a/tests/test_gate_modes.py b/tests/test_gate_modes.py index 6808e5a..aca5d48 100644 --- a/tests/test_gate_modes.py +++ b/tests/test_gate_modes.py @@ -218,7 +218,15 @@ def test_the_reduced_status_is_reached_by_exactly_one_path() -> None: """One `exit` writes it, at the end of the reduced run, after the count.""" assert GATE_TEXT.count('exit "$GATE_REDUCED_GREEN"') == 1 assert re.search(r"^gate: contract absent: ", GATE_TEXT, re.MULTILINE) is None - assert 'echo "gate: contract absent: $((checks_not_run + 1)) checks not run"' in GATE_TEXT + assert 'echo "gate: contract absent: $((checks_not_run + 2)) checks not run"' in GATE_TEXT + # Two named steps install the contract's side in order to measure something, and + # a reduced run names each: the count above is the tests that stood down, plus + # these two lines. + for step in ( + "closure (articles 13 and 14) — it installs the contract to measure it", + "umbrella (the product installed whole) — it installs the daemon to measure it", + ): + assert f'echo "gate: not run: {step}"' in GATE_TEXT, step def test_a_failure_can_never_be_read_as_a_reduced_run() -> None: @@ -416,12 +424,12 @@ def test_every_check_the_workflow_runs_is_the_gate() -> None: One exception, stated in this file's header and held by the test below it: the sign-off check, which has no range to be given off a pull request. The - three names read for here are the tools `scripts/gate.sh` already runs, so a + four names read for here are the tools `scripts/gate.sh` already runs, so a step that ran any of them a second way — with different options, against a different tree — would be a check nobody could reproduce even though the gate appears to cover it. """ - for tool in ("pytest", "ruff", "check_dependency_closure"): + for tool in ("pytest", "ruff", "check_dependency_closure", "check_umbrella_install"): assert tool not in WORKFLOW_CODE, f"the workflow runs {tool} itself" diff --git a/tests/test_pointers_survive_publication.py b/tests/test_pointers_survive_publication.py index bfd6587..9f7fe73 100644 --- a/tests/test_pointers_survive_publication.py +++ b/tests/test_pointers_survive_publication.py @@ -57,6 +57,7 @@ def _reader_facing() -> list[Path]: """ found = [path for path in sorted(REPOSITORY.glob("*.md")) if path.is_file()] found += [path for path in sorted(REPOSITORY.glob("docs/**/*.md")) if path.is_file()] + found += [path for path in sorted(REPOSITORY.glob("umbrella/*.md")) if path.is_file()] notice = REPOSITORY / "NOTICE" if notice.is_file(): found.append(notice) diff --git a/tests/test_public_vocabulary.py b/tests/test_public_vocabulary.py index 79d740e..c947f5c 100644 --- a/tests/test_public_vocabulary.py +++ b/tests/test_public_vocabulary.py @@ -63,6 +63,8 @@ #: a notice rather than a work, which is the same exception article 15 makes. #: The changelog is one more: a release note is read by every person deciding #: whether to upgrade, and it is as published as `README.md`. +#: The umbrella's directory is one more: its page and its project file are what +#: the index shows for the product's own name, as published as `README.md`. #: #: WHAT IS READ OF EACH FILE UNDER THESE ROOTS, per suffix, because a root read #: in part is not a root read. Every detector in `EVERYWHERE` reads every one of @@ -88,6 +90,7 @@ "TRADEMARKS.md", "pyproject.toml", "CHANGELOG.md", + "umbrella", ) #: Suffixes whose whole content is prose. diff --git a/tests/test_publication_checklist_names_every_act.py b/tests/test_publication_checklist_names_every_act.py index 23b0bcc..87127a4 100644 --- a/tests/test_publication_checklist_names_every_act.py +++ b/tests/test_publication_checklist_names_every_act.py @@ -95,6 +95,10 @@ "the deployment environment's reviewer and the trusted-publishing relationship", "trusted-publishing relationship", ), + ( + "a pending publisher for each project the index does not know yet", + "pending publisher for every project", + ), ( "the sign-off check is a required status", "required status", @@ -117,7 +121,7 @@ #: `len(ACTS)`, so that shrinking the act list cannot quietly shrink the floor #: with it: the two have to be edited apart, and a diff that moves one alone is #: a diff a reader can see. -BULLET_FLOOR = 12 +BULLET_FLOOR = 13 #: A bullet and everything indented under it, up to the next bullet, the next #: heading, or the end. diff --git a/tests/test_release_builds_every_distribution.py b/tests/test_release_builds_every_distribution.py new file mode 100644 index 0000000..3c51867 --- /dev/null +++ b/tests/test_release_builds_every_distribution.py @@ -0,0 +1,68 @@ +# SPDX-License-Identifier: Apache-2.0 +"""A tag builds every distribution this repository releases. + +The publish job uploads whatever the build job left in `dist/`. The version the +tag is checked against is read from every project file the release reader +knows (`scripts/release_version.py`). So a project directory the reader reads +and the workflow does not build is a distribution whose version was checked, +whose changelog entry was required, and which was then never published — the +release would be green and half of it would be missing from the index. + +The rule joins the two lists: every directory the reader reads has its build +line in the workflow. + +What it does NOT assert: that the line sits in the build job, runs on a tag, or +writes where the upload reads. It reads the workflow as lines, not as a +document, so a build line moved to another job, put behind a condition or +given another working directory still satisfies it. The release rehearsal and +the review of a workflow change hold those; this guard holds that a project +the version reader reads is never left without its build line. + +It needs no contract package, so it speaks in a reduced run too. +""" + +from __future__ import annotations + +from collections.abc import Iterable +from pathlib import Path + +from release_version import PROJECT_DIRECTORIES + +REPOSITORY = Path(__file__).resolve().parents[1] +WORKFLOW = REPOSITORY / ".github" / "workflows" / "release.yml" + + +def build_line(directory: str) -> str: + """The command that builds the project in this directory into `dist/`.""" + return "uv build --out-dir dist" if directory == "." else f"uv build {directory} --out-dir dist" + + +def builds_missing(workflow: str, directories: Iterable[str]) -> list[str]: + """Every build line the workflow owes and does not carry, as a whole line of a script.""" + lines = {line.strip().removeprefix("run: ") for line in workflow.splitlines()} + return [ + build_line(directory) for directory in directories if build_line(directory) not in lines + ] + + +def test_the_release_builds_every_project_the_version_reader_reads() -> None: + # Anti-vacuity floor: a reader that read one project would make this the + # rule that already held before the second distribution existed. + assert PROJECT_DIRECTORIES == (".", "umbrella") + for directory in PROJECT_DIRECTORIES: + assert (REPOSITORY / directory / "pyproject.toml").is_file(), directory + assert builds_missing(WORKFLOW.read_text(encoding="utf-8"), PROJECT_DIRECTORIES) == [] + + +def test_a_workflow_that_builds_only_the_client_is_caught() -> None: + """WATCHED FIRING, on the workflow as it stood before the umbrella.""" + before = WORKFLOW.read_text(encoding="utf-8").replace("uv build umbrella --out-dir dist\n", "") + assert builds_missing(before, PROJECT_DIRECTORIES) == ["uv build umbrella --out-dir dist"] + + +def test_a_build_line_that_only_resembles_the_owed_one_is_not_accepted() -> None: + """WATCHED FIRING. A build into another directory is not a build the publish job uploads.""" + elsewhere = ( + " run: uv build umbrella --out-dir build\n run: uv build --out-dir dist\n" + ) + assert builds_missing(elsewhere, PROJECT_DIRECTORIES) == ["uv build umbrella --out-dir dist"] diff --git a/tests/test_release_reads_both_distributions.py b/tests/test_release_reads_both_distributions.py new file mode 100644 index 0000000..ede47e5 --- /dev/null +++ b/tests/test_release_reads_both_distributions.py @@ -0,0 +1,87 @@ +# SPDX-License-Identifier: Apache-2.0 +"""One repository releases one version, read from both of its project files. + +This repository builds two distributions: the client, from the project file at +its root, and the umbrella, from `umbrella/`. A tag names one version, and the +release workflow asks `scripts/release_version.py` whether the tree carries it. +A reader that read only the root would let a tag publish the umbrella under a +number the tag never named. + +It needs no contract package, so it speaks in a reduced run too. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from release_version import PROJECT_DIRECTORIES, distribution_versions, main, release_version + +REPOSITORY = Path(__file__).resolve().parents[1] + + +def _tree(root: Path, client: str, umbrella: str | None) -> Path: + """A tree with a client at one version and, unless None, an umbrella at another.""" + (root / "pyproject.toml").write_text( + f'[project]\nname = "sayfirst-cli"\nversion = "{client}"\n', encoding="utf-8" + ) + if umbrella is not None: + (root / "umbrella").mkdir() + (root / "umbrella" / "pyproject.toml").write_text( + f'[project]\nname = "sayfirst"\nversion = "{umbrella}"\n', encoding="utf-8" + ) + return root + + +def test_the_release_reads_both_distributions_and_finds_one_version() -> None: + """ANTI-VACUITY as well: a reader that lost the second file would still agree with itself.""" + assert PROJECT_DIRECTORIES == (".", "umbrella") + versions = distribution_versions(REPOSITORY) + assert sorted(versions) == ["sayfirst", "sayfirst-cli"] + assert set(versions.values()) == {release_version(REPOSITORY)} + + +def test_two_versions_in_one_tree_stop_the_release( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """WATCHED FIRING on the reader the release workflow runs.""" + tree = _tree(tmp_path, "1.0.1", "1.0.0") + with pytest.raises(ValueError) as refused: + release_version(tree) + assert "sayfirst 1.0.0, sayfirst-cli 1.0.1" in str(refused.value) + + assert main(["--repository", str(tree), "--expect", "1.0.1"]) == 1 + printed = capsys.readouterr() + assert printed.out == "", "a refused reading prints no version" + assert len(printed.err.splitlines()) == 1, printed.err + assert "more than one version" in printed.err + + +def test_a_tag_is_checked_against_both_distributions_at_once( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + tree = _tree(tmp_path, "1.0.1", "1.0.1") + assert main(["--repository", str(tree), "--expect", "1.0.1"]) == 0 + assert capsys.readouterr().out == "1.0.1\n" + assert main(["--repository", str(tree), "--expect", "1.0.2"]) == 1 + assert "nothing is published" in capsys.readouterr().err + + +def test_an_umbrella_with_no_version_is_refused_and_its_file_is_named(tmp_path: Path) -> None: + """WATCHED FIRING. The refusal names the file a reader has to open, not the root's.""" + tree = _tree(tmp_path, "1.0.1", None) + (tree / "umbrella").mkdir() + (tree / "umbrella" / "pyproject.toml").write_text( + '[project]\nname = "sayfirst"\n', encoding="utf-8" + ) + with pytest.raises(ValueError) as refused: + release_version(tree) + assert str(Path("umbrella") / "pyproject.toml") in str(refused.value) + assert "declares no version" in str(refused.value) + + +def test_a_tree_that_builds_only_the_client_is_still_read(tmp_path: Path) -> None: + """An unpacked source distribution of another shape, or a tree before the umbrella.""" + tree = _tree(tmp_path, "1.0.1", None) + assert distribution_versions(tree) == {"sayfirst-cli": "1.0.1"} + assert release_version(tree) == "1.0.1" diff --git a/tests/test_umbrella_install.py b/tests/test_umbrella_install.py new file mode 100644 index 0000000..bcdae08 --- /dev/null +++ b/tests/test_umbrella_install.py @@ -0,0 +1,171 @@ +# SPDX-License-Identifier: Apache-2.0 +"""The rules of the umbrella's install check, with the defect planted against each. + +`scripts/check_umbrella_install.py` measures these rules on a real install; +this file proves each rule catches what it exists to catch, without installing +anything. Both read the same functions, so there is no second copy to drift. + +It needs no contract package, so it speaks in a reduced run too. +""" + +from __future__ import annotations + +from pathlib import Path + +import check_umbrella_install as guard + +WHOLE = sorted(guard.EXPECTED_CLOSURE) + +#: What the four distributions declare today, as an installed environment +#: reports it. The real comparison is made by the gate on a real install; this +#: is the shape the rule is planted against. +DECLARED = { + "sayfirst": { + "sayfirst": "sayfirst_cli.main:run", + "sayfirst-daemon": "sayfirst_quickstart.command:main", + "sayfirstd": "sayfirstd.main:run", + }, + "sayfirst-cli": {"sayfirst": "sayfirst_cli.main:run"}, + "sayfirst-control-plane": {"sayfirst-daemon": "sayfirst_quickstart.command:main"}, + "sayfirstd": {"sayfirstd": "sayfirstd.main:run"}, + "sayfirst-contract": {}, + "sayfirst-boundary": {}, +} + + +def _with(distribution: str, scripts: dict[str, str]) -> dict[str, dict[str, str]]: + """A copy of the real declarations with one distribution's scripts replaced.""" + return {**DECLARED, distribution: scripts} + + +def test_the_whole_product_is_six_distributions_of_this_project() -> None: + """The positive rule, spelled once here so that a seventh name is a diff.""" + assert set(guard.EXPECTED_CLOSURE) == { + "sayfirst", + "sayfirst-cli", + "sayfirst-control-plane", + "sayfirstd", + "sayfirst-contract", + "sayfirst-boundary", + } + assert guard.closure_failures(WHOLE) == [] + + +def test_a_planted_seventh_distribution_fails_the_closure() -> None: + """WATCHED FIRING. Anything the product does not name is a failure, whatever it is.""" + reported = guard.closure_failures([*WHOLE, "starlette"]) + assert reported == [ + "the install brought distributions the product does not name: ['starlette']" + ] + + +def test_a_missing_owner_fails_the_closure_and_is_named() -> None: + """WATCHED FIRING. A product that arrives without its daemon is not whole.""" + without = [name for name in WHOLE if name != "sayfirst-control-plane"] + reported = guard.closure_failures(without) + assert reported == [ + "the install did not bring ['sayfirst-control-plane']: the product is not whole" + ] + + +def test_an_empty_closure_is_a_failure_and_never_a_pass() -> None: + """ANTI-VACUITY. An install that did not happen has no extra distribution either.""" + reported = guard.closure_failures([]) + assert reported[0] == "the closure does not contain sayfirst: nothing was installed" + + +def test_a_name_spelled_another_way_is_the_same_name() -> None: + """A closure is compared the way an index compares names (PEP 503).""" + respelled = ["SayFirst", "sayfirst_cli", "sayfirst.control.plane", "SAYFIRSTD"] + assert guard.closure_failures([*respelled, "sayfirst_contract", "sayfirst_boundary"]) == [] + + +def test_the_declarations_as_they_stand_are_mirrors() -> None: + assert guard.mirror_failures(DECLARED) == [] + + +def test_a_mirror_that_drifted_from_its_owner_is_named_with_both_targets() -> None: + """WATCHED FIRING. The owner moved its entry point and the umbrella did not follow.""" + moved = _with("sayfirstd", {"sayfirstd": "sayfirstd.cli:main"}) + assert guard.mirror_failures(moved) == [ + "'sayfirstd': the umbrella points at 'sayfirstd.main:run' " + "and sayfirstd declares 'sayfirstd.cli:main'" + ] + + +def test_an_owner_that_no_longer_declares_its_command_is_named() -> None: + """WATCHED FIRING. The umbrella would point a command at a module nobody promises.""" + dropped = _with("sayfirst-control-plane", {}) + assert guard.mirror_failures(dropped) == [ + "sayfirst-control-plane declares no command 'sayfirst-daemon' for the umbrella to mirror" + ] + + +def test_a_command_the_umbrella_does_not_name_is_named() -> None: + """WATCHED FIRING. One install line has to yield all three commands.""" + two = {name: target for name, target in DECLARED["sayfirst"].items() if name != "sayfirstd"} + assert guard.mirror_failures(_with("sayfirst", two)) == [ + "the umbrella does not name 'sayfirstd', which sayfirstd declares" + ] + + +def test_a_command_no_distribution_owns_is_refused() -> None: + """WATCHED FIRING. The umbrella has no code to point a command of its own at.""" + extra = {**DECLARED["sayfirst"], "sayfirst-extra": "sayfirst_cli.main:run"} + assert guard.mirror_failures(_with("sayfirst", extra)) == [ + "the umbrella declares commands no distribution owns: ['sayfirst-extra']" + ] + + +def test_an_environment_reporting_names_another_way_is_read_the_same() -> None: + """Installed metadata spells a name as its project file did; the rule does not care.""" + respelled = {name.replace("-", "_").upper(): scripts for name, scripts in DECLARED.items()} + assert guard.mirror_failures(respelled) == [] + + +def _executable(directory: Path, name: str, script: str) -> None: + """A stand-in for an installed command: a shell script that does what the test says.""" + path = directory / name + path.write_text(f"#!/bin/sh\n{script}\n", encoding="utf-8") + path.chmod(0o755) + + +def test_three_commands_that_start_report_nothing(tmp_path: Path) -> None: + for command in guard.OWNERS: + _executable(tmp_path, command, "exit 0") + assert guard.command_failures(tmp_path) == [] + + +def test_a_command_the_install_did_not_leave_is_named(tmp_path: Path) -> None: + """WATCHED FIRING. An entry point the installer skipped is a command a reader cannot type.""" + for command in ("sayfirst", "sayfirstd"): + _executable(tmp_path, command, "exit 0") + assert guard.command_failures(tmp_path) == [ + "the install left no sayfirst-daemon beside its interpreter" + ] + + +def test_a_command_that_does_not_start_is_named_with_its_last_word(tmp_path: Path) -> None: + """WATCHED FIRING. A mirror pointing at a module that is gone installs, and then fails here.""" + for command in ("sayfirst", "sayfirst-daemon"): + _executable(tmp_path, command, "exit 0") + _executable( + tmp_path, + "sayfirstd", + 'echo "Traceback (most recent call last):" >&2\n' + "echo \"ModuleNotFoundError: No module named 'sayfirstd'\" >&2\n" + "exit 1", + ) + assert guard.command_failures(tmp_path) == [ + "sayfirstd --help answered 1: ModuleNotFoundError: No module named 'sayfirstd'" + ] + + +def test_a_command_that_fails_in_silence_is_still_named(tmp_path: Path) -> None: + """WATCHED FIRING. No word on the error stream is said, not left as an empty tail.""" + for command in ("sayfirst-daemon", "sayfirstd"): + _executable(tmp_path, command, "exit 0") + _executable(tmp_path, "sayfirst", "exit 3") + assert guard.command_failures(tmp_path) == [ + "sayfirst --help answered 3: nothing on its error stream" + ] diff --git a/tests/test_umbrella_metadata_agrees_with_the_client.py b/tests/test_umbrella_metadata_agrees_with_the_client.py new file mode 100644 index 0000000..85bf114 --- /dev/null +++ b/tests/test_umbrella_metadata_agrees_with_the_client.py @@ -0,0 +1,82 @@ +# SPDX-License-Identifier: Apache-2.0 +"""The umbrella's index page says what the client's says, wherever both say it. + +Two project files in one repository are two places to state the interpreters +admitted, the licence, the author and the repository a reader is sent to. The +umbrella installs the client, so a page that admits an interpreter the client +refuses is a page that offers an install which then fails; and a second author +or a second repository on the project's most visible name is a claim nobody +decided to make. + +The umbrella has no guard of its own for these: it is held to the client's +project file, which the client's own guards hold. + +It needs no contract package, so it speaks in a reduced run too. +""" + +from __future__ import annotations + +import tomllib +from pathlib import Path + +import pytest +from test_pointers_survive_publication import PUBLIC_CLIENT, project_urls + +REPOSITORY = Path(__file__).resolve().parents[1] +UMBRELLA = REPOSITORY / "umbrella" + +#: What the two project tables state identically. +SHARED = ("requires-python", "license", "authors", "classifiers", "version") + + +def _project(directory: Path) -> dict[str, object]: + return tomllib.loads((directory / "pyproject.toml").read_text(encoding="utf-8"))["project"] + + +def disagreements(umbrella: dict[str, object], client: dict[str, object]) -> list[str]: + """Every shared key on which the two project tables differ.""" + return [key for key in SHARED if umbrella.get(key) != client.get(key)] + + +def test_the_two_project_files_agree_wherever_both_speak() -> None: + client = _project(REPOSITORY) + # Anti-vacuity floor: two tables that both lack a key agree about nothing. + for key in SHARED: + assert client.get(key), key + assert disagreements(_project(UMBRELLA), client) == [] + + +def test_the_rule_fires_on_an_interpreter_the_client_does_not_admit() -> None: + """WATCHED FIRING.""" + client = _project(REPOSITORY) + widened = {**_project(UMBRELLA), "requires-python": ">=3.10,<3.15"} + assert disagreements(widened, client) == ["requires-python"] + + +def test_the_umbrella_is_named_for_the_decided_name_and_nothing_else() -> None: + """Article 0: the name of the product, as a distribution, is the decided name itself.""" + assert _project(UMBRELLA)["name"] == "sayfirst" + assert _project(REPOSITORY)["name"] == "sayfirst-cli" + + +def test_every_url_of_the_umbrella_is_this_public_repository() -> None: + """The same repository as the client's, and a page that exists in this tree.""" + urls = project_urls(UMBRELLA) + client = project_urls(REPOSITORY) + assert set(urls) == set(client), urls + assert urls["Repository"] == client["Repository"] == PUBLIC_CLIENT + assert urls["Changelog"] == client["Changelog"] + page = urls["Documentation"].removeprefix(f"{PUBLIC_CLIENT}/blob/main/") + assert page == "umbrella/README.md", urls["Documentation"] + assert (REPOSITORY / page).is_file(), page + + +@pytest.mark.parametrize("key", ["description", "readme"]) +def test_the_umbrella_states_its_own_page(key: str) -> None: + """What is NOT shared: its one-line description and its own README.""" + umbrella, client = _project(UMBRELLA), _project(REPOSITORY) + assert umbrella.get(key), key + if key == "description": + assert umbrella[key] != client[key] + else: + assert (UMBRELLA / str(umbrella[key])).is_file() diff --git a/tests/test_umbrella_notices.py b/tests/test_umbrella_notices.py new file mode 100644 index 0000000..ad1c313 --- /dev/null +++ b/tests/test_umbrella_notices.py @@ -0,0 +1,91 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Article 15: the second distribution this repository publishes reproduces the notices too. + +The client's wheel finds the licence and the notice beside its project file. +The umbrella's project file lives one directory down, where neither is, so both +are carried in by hand: into the source distribution by its build configuration, +and into the wheel by `umbrella/hatch_build.py`. A thing carried by hand is a +thing that can be dropped by hand, so it is held here the way the client's own +is held: the metadata DECLARES the files, the archive CARRIES them, and the +bytes are this repository's. + +Built the way a release builds it — the source distribution, and the wheel made +from it — so the artefact read here is the artefact a tag publishes. + +It needs no contract package, so it speaks in a reduced run too. +""" + +from __future__ import annotations + +import subprocess +import tarfile +import zipfile +from pathlib import Path + +REPOSITORY = Path(__file__).resolve().parents[1] +UMBRELLA = REPOSITORY / "umbrella" + +#: The notices every redistribution of this repository carries. +NOTICES = ("LICENSE", "NOTICE") + + +def _build_both(into: Path) -> tuple[Path, Path]: + """The umbrella's source distribution, and the wheel made from it.""" + subprocess.run( + ("uv", "build", str(UMBRELLA), "--out-dir", str(into)), + cwd=REPOSITORY, + check=True, + capture_output=True, + text=True, + ) + sdists = sorted(into.glob("*.tar.gz")) + wheels = sorted(into.glob("*.whl")) + assert len(sdists) == 1, sdists + assert len(wheels) == 1, wheels + return sdists[0], wheels[0] + + +def test_the_umbrella_declares_and_carries_the_repository_notices(tmp_path: Path) -> None: + sdist, wheel = _build_both(tmp_path / "dist") + with tarfile.open(sdist) as archive: + roots = {Path(name).name for name in archive.getnames() if len(Path(name).parts) == 2} + for notice in NOTICES: + assert notice in roots, f"{sdist.name} carries no {notice} at its root" + member = archive.extractfile( + next(name for name in archive.getnames() if Path(name).name == notice) + ) + assert member is not None + assert member.read() == (REPOSITORY / notice).read_bytes(), f"{sdist.name}: {notice}" + with zipfile.ZipFile(wheel) as archive: + members = archive.namelist() + metadata = next(name for name in members if name.endswith(".dist-info/METADATA")) + declared = archive.read(metadata).decode("utf-8") + for notice in NOTICES: + assert f"License-File: {notice}" in declared, f"{wheel.name} declares no {notice}" + member = next( + (name for name in members if name.endswith(f".dist-info/licenses/{notice}")), None + ) + assert member is not None, f"{wheel.name} carries no {notice}" + assert archive.read(member) == (REPOSITORY / notice).read_bytes(), ( + f"{wheel.name}: the {notice} it carries is not this repository's" + ) + + +def test_a_wheel_built_straight_from_the_tree_carries_them_too(tmp_path: Path) -> None: + """The gate's install check builds the wheel this way, without a source distribution.""" + subprocess.run( + ("uv", "build", str(UMBRELLA), "--wheel", "--out-dir", str(tmp_path)), + cwd=REPOSITORY, + check=True, + capture_output=True, + text=True, + ) + (wheel,) = sorted(tmp_path.glob("*.whl")) + with zipfile.ZipFile(wheel) as archive: + for notice in NOTICES: + member = next( + name + for name in archive.namelist() + if name.endswith(f".dist-info/licenses/{notice}") + ) + assert archive.read(member) == (REPOSITORY / notice).read_bytes(), notice diff --git a/tests/test_umbrella_page_says_what_the_install_does.py b/tests/test_umbrella_page_says_what_the_install_does.py new file mode 100644 index 0000000..ef3b025 --- /dev/null +++ b/tests/test_umbrella_page_says_what_the_install_does.py @@ -0,0 +1,97 @@ +# SPDX-License-Identifier: Apache-2.0 +"""The umbrella's index page is held to what the install check measures. + +`umbrella/README.md` is what a reader sees on the index before installing +anything. Three of its statements are facts the gate measures elsewhere, and a +page that states them a second time is a page that can drift from them: + +* which command comes from which distribution — `OWNERS`, in + `scripts/check_umbrella_install.py`; +* what removing the product whole removes — `EXPECTED_CLOSURE`, in the same + file: every distribution the install brought, and no other; +* how a reader who installed the three commands as the tool `sayfirst-cli` + arrives here — `uv` refuses to overwrite a command another tool owns, so the + page has to name the removal before the install. + +It needs no contract package, so it speaks in a reduced run too. +""" + +from __future__ import annotations + +import re + +import check_umbrella_install as guard + +PAGE = guard.UMBRELLA / "README.md" + +#: A row of the page's table: a command, what it is, the distribution that owns it. +_ROW = re.compile(r"^\| `([a-z-]+)` \| .* \| `([a-z-]+)` \|$", re.MULTILINE) + +#: The line that removes the product whole from an environment a reader owns. +_REMOVAL = re.compile(r"^\$ pip uninstall (.+)$", re.MULTILINE) + + +def owners_on_the_page(page: str) -> dict[str, str]: + """Each command the page's table names, and the distribution it says owns it.""" + return dict(_ROW.findall(page)) + + +def removed_whole(page: str) -> list[list[str]]: + """The distributions each `pip uninstall` line of the page names.""" + return [found.split() for found in _REMOVAL.findall(page)] + + +def arrives_after_removing(page: str, old_tool: str) -> bool: + """Whether the page removes the old tool on a line before a line that installs this one.""" + lines = page.splitlines() + removal = f"$ uv tool uninstall {old_tool}" + install = f"$ uv tool install {guard.UMBRELLA_NAME}" + return any( + line == removal and install in lines[number + 1 :] for number, line in enumerate(lines) + ) + + +def test_the_table_names_each_command_and_its_owner() -> None: + assert owners_on_the_page(PAGE.read_text(encoding="utf-8")) == dict(guard.OWNERS) + + +def test_removing_the_product_whole_names_everything_the_install_brought() -> None: + lines = removed_whole(PAGE.read_text(encoding="utf-8")) + assert len(lines) == 1, lines + assert sorted(lines[0]) == sorted(guard.EXPECTED_CLOSURE) + + +def test_the_page_removes_the_earlier_tool_before_installing_this_one() -> None: + assert arrives_after_removing(PAGE.read_text(encoding="utf-8"), "sayfirst-cli") + + +def test_the_three_tools_line_is_read_before_the_block_it_changes() -> None: + """A reader runs a console block before reading under it: the variant comes first.""" + page = PAGE.read_text(encoding="utf-8") + variant = "uv tool uninstall " + " ".join(sorted(set(guard.OWNERS.values()))) + assert variant in page, variant + assert page.index(variant) < page.index("$ uv tool uninstall sayfirst-cli\n") + + +def test_a_table_that_gives_a_command_to_the_wrong_owner_is_caught() -> None: + """WATCHED FIRING.""" + page = PAGE.read_text(encoding="utf-8").replace( + "| `sayfirst-control-plane` |", "| `sayfirstd` |" + ) + assert owners_on_the_page(page)["sayfirst-daemon"] == "sayfirstd" + assert owners_on_the_page(page) != dict(guard.OWNERS) + + +def test_a_removal_line_that_forgets_a_distribution_is_caught() -> None: + """WATCHED FIRING. A line that leaves the boundary behind does not remove the product whole.""" + page = PAGE.read_text(encoding="utf-8").replace(" sayfirst-boundary", "") + assert sorted(removed_whole(page)[0]) != sorted(guard.EXPECTED_CLOSURE) + + +def test_a_page_that_installs_before_it_removes_is_caught() -> None: + """WATCHED FIRING. The order is the instruction: the other order is the error `uv` prints.""" + page = "$ uv tool install sayfirst\n$ uv tool uninstall sayfirst-cli\n" + assert not arrives_after_removing(page, "sayfirst-cli") + assert arrives_after_removing( + "$ uv tool uninstall sayfirst-cli\n$ uv tool install sayfirst\n", "sayfirst-cli" + ) diff --git a/tests/test_umbrella_pins_one_version.py b/tests/test_umbrella_pins_one_version.py new file mode 100644 index 0000000..1310743 --- /dev/null +++ b/tests/test_umbrella_pins_one_version.py @@ -0,0 +1,99 @@ +# SPDX-License-Identifier: Apache-2.0 +"""The umbrella pins its three owners to its own version, which is the client's. + +`sayfirst` depends on three distributions and installs nothing of its own, so its pins +ARE the distribution. A pin left behind by a version bump would publish an +umbrella that installs last release's product under this release's number, and +a range in place of a pin would install whatever the index held that day. + +The rule is `pin_failures` in `scripts/check_umbrella_install.py`, which the +gate runs before it installs anything; this file reads the same function and +plants the defects against it. + +It needs no contract package, so it speaks in a reduced run too. +""" + +from __future__ import annotations + +from pathlib import Path + +import check_umbrella_install as guard + +REPOSITORY = Path(__file__).resolve().parents[1] + + +def _tables() -> tuple[dict[str, object], dict[str, object]]: + return guard.project_table(guard.UMBRELLA), guard.project_table(REPOSITORY) + + +def _dependencies(umbrella: dict[str, object]) -> list[str]: + found = umbrella["dependencies"] + assert isinstance(found, list) + # Anti-vacuity floor: three pins, or the rule below is about nothing. + assert len(found) == 3, found + return [str(item) for item in found] + + +def test_the_umbrella_pins_its_three_owners_to_the_clients_version() -> None: + umbrella, client = _tables() + assert guard.pin_failures(umbrella, client) == [] + + +def test_a_pin_left_behind_by_a_version_bump_is_caught() -> None: + """WATCHED FIRING. The client moved and one pin of the umbrella did not.""" + umbrella, client = _tables() + stale = [ + "sayfirst-cli==0.0.1" if item.startswith("sayfirst-cli==") else item + for item in _dependencies(umbrella) + ] + reported = guard.pin_failures({**umbrella, "dependencies": stale}, client) + assert len(reported) == 1 + assert "sayfirst-cli==0.0.1" in reported[0] + assert f"sayfirst-cli=={client['version']}" in reported[0] + + +def test_a_range_in_place_of_a_pin_is_caught() -> None: + """WATCHED FIRING. `>=` installs whatever the index holds that day.""" + umbrella, client = _tables() + ranged = [item.replace("==", ">=") for item in _dependencies(umbrella)] + reported = guard.pin_failures({**umbrella, "dependencies": ranged}, client) + assert len(reported) == 1 + assert f"sayfirstd>={client['version']}" in reported[0] + + +def test_a_fourth_dependency_is_caught() -> None: + """WATCHED FIRING. The product is three owners; a fourth name is a different product.""" + umbrella, client = _tables() + more = [*_dependencies(umbrella), "requests==2.32.0"] + reported = guard.pin_failures({**umbrella, "dependencies": more}, client) + assert len(reported) == 1 + assert "requests==2.32.0" in reported[0] + + +def test_an_extra_is_a_fourth_dependency_by_another_door() -> None: + """WATCHED FIRING. `sayfirst[web]` would install what the product does not name.""" + umbrella, client = _tables() + extra = {**umbrella, "optional-dependencies": {"web": ["starlette"]}} + assert guard.pin_failures(extra, client) == [ + "the umbrella declares extras ['web']: " + "the product is three pins, and an extra is a fourth dependency by another door" + ] + + +def test_an_umbrella_at_another_version_than_the_client_is_caught() -> None: + """WATCHED FIRING on the first half of the rule: both lines speak, each of its own half.""" + umbrella, client = _tables() + reported = guard.pin_failures({**umbrella, "version": "9.9.9"}, client) + assert reported == [ + f"the umbrella carries 9.9.9 and the client {client['version']}: " + f"one repository releases one version" + ] + + +def test_an_umbrella_with_no_dependencies_at_all_is_caught() -> None: + """WATCHED FIRING. A missing table is a failure, never a crash and never a pass.""" + umbrella, client = _tables() + without = {key: value for key, value in umbrella.items() if key != "dependencies"} + reported = guard.pin_failures(without, client) + assert len(reported) == 1 + assert reported[0].startswith("the umbrella depends on [] and the rule is exactly ") diff --git a/tests/test_umbrella_ships_no_code.py b/tests/test_umbrella_ships_no_code.py new file mode 100644 index 0000000..6903df4 --- /dev/null +++ b/tests/test_umbrella_ships_no_code.py @@ -0,0 +1,68 @@ +# SPDX-License-Identifier: Apache-2.0 +"""The umbrella is its metadata: three pins, three commands, two notices, and no module. + +`sayfirst` exists so that one install yields the product. Everything the +product does is in the distributions it depends on, where it is tested and +documented. A module shipped from here would be code nobody's gate reads, under +the most visible name the project has. + +Built into a directory of its own under the test's own temporary path, never +the tree's `dist/`: a guard that wrote there would make the next release's +build read what a test left behind. + +It needs no contract package, so it speaks in a reduced run too. +""" + +from __future__ import annotations + +import subprocess +import zipfile +from pathlib import Path + +REPOSITORY = Path(__file__).resolve().parents[1] +UMBRELLA = REPOSITORY / "umbrella" + +#: The three commands, as the wheel's own entry-point file spells them. +ENTRY_POINTS = ( + "[console_scripts]\n" + "sayfirst = sayfirst_cli.main:run\n" + "sayfirst-daemon = sayfirst_quickstart.command:main\n" + "sayfirstd = sayfirstd.main:run\n" +) + + +def outside_the_metadata(members: list[str]) -> list[str]: + """Every member of a wheel that is not part of its own metadata directory.""" + return sorted(name for name in members if ".dist-info/" not in name) + + +def _build_the_wheel(into: Path) -> Path: + subprocess.run( + ("uv", "build", str(UMBRELLA), "--wheel", "--out-dir", str(into)), + cwd=REPOSITORY, + check=True, + capture_output=True, + text=True, + ) + wheels = sorted(into.glob("*.whl")) + assert len(wheels) == 1, wheels + return wheels[0] + + +def test_the_wheel_carries_its_metadata_and_nothing_else(tmp_path: Path) -> None: + wheel = _build_the_wheel(tmp_path / "wheelhouse") + assert wheel.name.startswith("sayfirst-"), wheel.name + with zipfile.ZipFile(wheel) as archive: + members = archive.namelist() + # Anti-vacuity floor: an archive with no metadata is not a wheel that + # ships no code, it is no wheel. + assert any(name.endswith(".dist-info/METADATA") for name in members), members + assert outside_the_metadata(members) == [] + entry_points = next(name for name in members if name.endswith("entry_points.txt")) + assert archive.read(entry_points).decode("utf-8") == ENTRY_POINTS + + +def test_the_reading_catches_a_module_that_arrived() -> None: + """WATCHED FIRING, on the list a wheel with one module in it would give.""" + members = ["sayfirst-1.0.0.dist-info/METADATA", "sayfirst/__init__.py"] + assert outside_the_metadata(members) == ["sayfirst/__init__.py"] diff --git a/umbrella/README.md b/umbrella/README.md new file mode 100644 index 0000000..a74fde6 --- /dev/null +++ b/umbrella/README.md @@ -0,0 +1,77 @@ + +# `sayfirst` — ask before you act + +This distribution is the product, installed whole. It installs no code of its +own: it depends on the three distributions that carry the three commands, each +at this same version, and names those commands so that one install exposes +them. + +```console +$ uv tool install sayfirst +$ sayfirst-daemon up --quickstart +$ sayfirst instrument run --pack subprocess --scope local -- python3 my_agent.py +``` + +| Command | What it is | The distribution that owns it | +|---|---|---| +| `sayfirst` | the product client: ask, trace, explain, evidence, approvals, instrument, packs | `sayfirst-cli` | +| `sayfirst-daemon` | the control plane's daemon: `serve`, `up --quickstart`, `down` | `sayfirst-control-plane` | +| `sayfirstd` | the daemon's operator surface: `status`, `whoami`, `plugins`, `conformance` | `sayfirstd` | + +The client's [`QUICKSTART.md`](https://github.com/fredaime/sayfirst-cli/blob/main/QUICKSTART.md) +walks all of it from the second command on; its first command is the +three-package install that this distribution replaces. Each command is +documented where it is declared. + +## What it is not + +- **Not the client alone.** A program that only asks, reads and verifies needs + `sayfirst-cli`, which brings the contract and the boundary and never the + daemon. This distribution brings the daemon on purpose. +- **Not a place where anything is decided or documented.** Every sentence + about what the product does, and does not do, is on the pages of the + distributions above. + +## Coming from the three-package install + +Before this distribution existed, the same three commands were installed as +one tool named `sayfirst-cli`. `uv` refuses to write a command another tool +already owns, so stop the quickstart daemon, if `up` started one, and remove +the tool first. If the three commands were installed as three tools instead, +all three have to go, and the second line is +`uv tool uninstall sayfirst-cli sayfirst-control-plane sayfirstd`. + +```console +$ sayfirst-daemon down +$ uv tool uninstall sayfirst-cli +$ uv tool install sayfirst +``` + +None of these lines removes the policy, the configuration or the evidence +under `~/.sayfirst/quickstart`: they are kept, and the next +`sayfirst-daemon up --quickstart` finds them. + +`uv`'s refusal offers `--force`. It installs this tool over the old one and +leaves the old one installed; removing `sayfirst-cli` afterwards removes the +three commands again, and `uv tool install --reinstall sayfirst` puts them back. + +## Removing it + +Stop the quickstart daemon first, if `up` started one. Once the commands are +gone, nothing is left to stop it with: + +```console +$ sayfirst-daemon down +``` + +Then, as a tool, remove the tool: `uv tool uninstall sayfirst`. In an +environment of your own, remove the product whole, since this distribution and +the three it depends on each declare the commands: + +```console +$ pip uninstall sayfirst sayfirst-cli sayfirst-control-plane sayfirstd sayfirst-contract sayfirst-boundary +``` + +Removing `sayfirst` alone from such an environment removes the three commands +and leaves the distributions that own them installed, without their commands. +`pip install --force-reinstall --no-deps` of an owner puts its command back. diff --git a/umbrella/hatch_build.py b/umbrella/hatch_build.py new file mode 100644 index 0000000..9f52f32 --- /dev/null +++ b/umbrella/hatch_build.py @@ -0,0 +1,31 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Reproduce the repository's notices in this distribution's wheel.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from hatchling.builders.hooks.plugin.interface import BuildHookInterface + + +class CustomBuildHook(BuildHookInterface): + """Carry the repository's licence and notice into the wheel's metadata.""" + + def initialize(self, version: str, build_data: dict[str, Any]) -> None: + # In the repository the notices live one directory up and have to be + # carried in by hand. In an unpacked source distribution they travel + # beside this file, where the backend's own licence globs already find + # them, so carrying them again would add the same file twice. + root = Path(self.root) + if not (root / "LICENSE").is_file(): + metadata_directory = ( + f"{self.metadata.core.name.replace('-', '_')}-{self.metadata.version}.dist-info" + ) + repository = root.parent + build_data["force_include"].update( + { + str(repository / "LICENSE"): f"{metadata_directory}/licenses/LICENSE", + str(repository / "NOTICE"): f"{metadata_directory}/licenses/NOTICE", + } + ) diff --git a/umbrella/pyproject.toml b/umbrella/pyproject.toml new file mode 100644 index 0000000..cf70e41 --- /dev/null +++ b/umbrella/pyproject.toml @@ -0,0 +1,60 @@ +# SPDX-License-Identifier: Apache-2.0 +[project] +name = "sayfirst" +version = "0.3.3" +description = "The sayfirst control plane, its daemon and its command, installed whole" +readme = "README.md" +requires-python = ">=3.12,<3.15" +license = "Apache-2.0" +authors = [{ name = "fredaime", email = "frederic.aime@gmail.com" }] +classifiers = [ + "Development Status :: 4 - Beta", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", +] +# This distribution is the product installed whole, and it is not the client. +# It depends on the client, on the daemon and on the daemon's operator surface, +# each at this one version, and it installs no module of its own. The client's +# own closure is untouched by it: `scripts/check_dependency_closure.py` still +# installs `sayfirst-cli` alone and still reads three distributions (article +# 14). What installing THIS distribution brings is measured the same way, by +# `scripts/check_umbrella_install.py`. +dependencies = [ + "sayfirst-cli==0.3.3", + "sayfirst-control-plane==0.3.3", + "sayfirstd==0.3.3", +] + +[project.urls] +Repository = "https://github.com/fredaime/sayfirst-cli" +Documentation = "https://github.com/fredaime/sayfirst-cli/blob/main/umbrella/README.md" +Changelog = "https://github.com/fredaime/sayfirst-cli/blob/main/CHANGELOG.md" + +# Each entry mirrors the declaration of the distribution that owns the command, +# word for word. An installer that exposes only the commands of the package it +# was handed (`uv tool install`, `pipx install`) would otherwise expose none, +# since this distribution has no code to point a command of its own at. The +# mirror is measured on a real install, against what each owner declares, by +# `scripts/check_umbrella_install.py`. +[project.scripts] +sayfirst = "sayfirst_cli.main:run" +sayfirst-daemon = "sayfirst_quickstart.command:main" +sayfirstd = "sayfirstd.main:run" + +[build-system] +requires = ["hatchling==1.32.0"] +build-backend = "hatchling.build" + +# No module is selected, on purpose: the wheel is its metadata and the two +# notices. `tests/test_umbrella_ships_no_code.py` holds that. +[tool.hatch.build.targets.wheel] +bypass-selection = true + +[tool.hatch.build.targets.sdist.force-include] +"../LICENSE" = "LICENSE" +"../NOTICE" = "NOTICE" + +[tool.hatch.build.targets.wheel.hooks.custom] +path = "hatch_build.py"