From ab4206411dce51fc6bdbb4c8417c21eb5de77df2 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 22 Jul 2026 14:59:13 +0000 Subject: [PATCH 01/15] Add OpenShell middleware initializer --- projects/README.md | 2 + projects/openshell-middleware-init/AGENTS.md | 17 + projects/openshell-middleware-init/README.md | 98 ++++ .../openshell-middleware-init/pyproject.toml | 56 +++ .../requirements.txt | 34 ++ .../src/openshell_middleware_init/__init__.py | 3 + .../src/openshell_middleware_init/cli.py | 88 ++++ .../openshell_middleware_init/generator.py | 435 +++++++++++++++++ .../templates/python/.gitignore | 13 + .../templates/python/README.md | 60 +++ .../templates/python/pyproject.toml | 42 ++ .../templates/python/src/package/__init__.py | 1 + .../python/src/package/bindings/__init__.py | 1 + .../templates/python/src/package/server.py | 101 ++++ .../templates/python/tests/test_server.py | 44 ++ .../templates/rust/.gitignore | 7 + .../templates/rust/Cargo.toml | 17 + .../templates/rust/README.md | 56 +++ .../templates/rust/build.rs | 16 + .../templates/rust/src/lib.rs | 106 +++++ .../templates/rust/src/main.rs | 19 + .../tests/test_cli.py | 74 +++ .../tests/test_generator.py | 443 ++++++++++++++++++ projects/openshell-middleware-init/uv.lock | 402 ++++++++++++++++ 24 files changed, 2135 insertions(+) create mode 100644 projects/openshell-middleware-init/AGENTS.md create mode 100644 projects/openshell-middleware-init/README.md create mode 100644 projects/openshell-middleware-init/pyproject.toml create mode 100644 projects/openshell-middleware-init/requirements.txt create mode 100644 projects/openshell-middleware-init/src/openshell_middleware_init/__init__.py create mode 100644 projects/openshell-middleware-init/src/openshell_middleware_init/cli.py create mode 100644 projects/openshell-middleware-init/src/openshell_middleware_init/generator.py create mode 100644 projects/openshell-middleware-init/src/openshell_middleware_init/templates/python/.gitignore create mode 100644 projects/openshell-middleware-init/src/openshell_middleware_init/templates/python/README.md create mode 100644 projects/openshell-middleware-init/src/openshell_middleware_init/templates/python/pyproject.toml create mode 100644 projects/openshell-middleware-init/src/openshell_middleware_init/templates/python/src/package/__init__.py create mode 100644 projects/openshell-middleware-init/src/openshell_middleware_init/templates/python/src/package/bindings/__init__.py create mode 100644 projects/openshell-middleware-init/src/openshell_middleware_init/templates/python/src/package/server.py create mode 100644 projects/openshell-middleware-init/src/openshell_middleware_init/templates/python/tests/test_server.py create mode 100644 projects/openshell-middleware-init/src/openshell_middleware_init/templates/rust/.gitignore create mode 100644 projects/openshell-middleware-init/src/openshell_middleware_init/templates/rust/Cargo.toml create mode 100644 projects/openshell-middleware-init/src/openshell_middleware_init/templates/rust/README.md create mode 100644 projects/openshell-middleware-init/src/openshell_middleware_init/templates/rust/build.rs create mode 100644 projects/openshell-middleware-init/src/openshell_middleware_init/templates/rust/src/lib.rs create mode 100644 projects/openshell-middleware-init/src/openshell_middleware_init/templates/rust/src/main.rs create mode 100644 projects/openshell-middleware-init/tests/test_cli.py create mode 100644 projects/openshell-middleware-init/tests/test_generator.py create mode 100644 projects/openshell-middleware-init/uv.lock diff --git a/projects/README.md b/projects/README.md index c803eff0..674946c2 100644 --- a/projects/README.md +++ b/projects/README.md @@ -6,6 +6,8 @@ layout. Current projects: +- `openshell-middleware-init`: Typer CLI that generates version-matched Python + and Rust OpenShell supervisor middleware projects. - `python-project-template`: Minimal, production-ready Python project scaffold managed with uv. - `reachy-mini-openshell`: Reachy Mini conversation demo for OpenShell. diff --git a/projects/openshell-middleware-init/AGENTS.md b/projects/openshell-middleware-init/AGENTS.md new file mode 100644 index 00000000..108e2e32 --- /dev/null +++ b/projects/openshell-middleware-init/AGENTS.md @@ -0,0 +1,17 @@ +# OpenShell Middleware Init contributor guidance + +Read `README.md` and `pyproject.toml` before changing this project. + +- Keep initialization non-destructive: never merge into or replace an existing + destination. +- Keep generated projects version-matched. The proto, generated bindings or + build configuration, dependency lock, and manifest must describe one + OpenShell release. +- Template markers use `__UPPER_SNAKE_CASE__`. Add every new marker to + `TemplateContext.replacements` and cover it with rendering tests. +- Generated Python bindings are generator-owned. Do not add formatting or type + checks that rewrite them. +- Tests for the initializer must be hermetic. Inject protocol download and + project preparation collaborators instead of accessing GitHub, uv, or Cargo. + +Run the validation commands documented in `README.md` after changes. diff --git a/projects/openshell-middleware-init/README.md b/projects/openshell-middleware-init/README.md new file mode 100644 index 00000000..a94ed317 --- /dev/null +++ b/projects/openshell-middleware-init/README.md @@ -0,0 +1,98 @@ +# OpenShell Middleware Init + +`openshell-middleware-init` is a Typer-based Python CLI that creates runnable +OpenShell supervisor middleware projects in Python or Rust. Every generated +project uses the protocol contract from one explicit OpenShell release and +records its source and SHA-256 in a manifest. + +The generated service is a small pass-through implementation of all three +`SupervisorMiddleware` RPCs: `Describe`, `ValidateConfig`, and +`EvaluateHttpRequest`. It provides the transport and build boilerplate without +guessing the middleware's policy or domain behavior. + +## Install for development + +Install [uv](https://docs.astral.sh/uv/), then create the locked environment: + +```sh +uv sync --locked +``` + +## Generate a project + +Every material choice is explicit: + +```sh +uv run openshell-middleware-init audit-headers \ + --language python \ + --openshell-version v0.0.86 +``` + +For Rust: + +```sh +uv run openshell-middleware-init audit-headers \ + --language rust \ + --openshell-version v0.0.86 \ + --output ../audit-headers +``` + +Use `--openshell-version latest` to resolve the current release. For shared +middleware, prefer a pinned tag so regeneration remains reproducible. + +Python package names default to a normalized form of the project name +(`audit-headers` becomes `audit_headers`) and can be overridden: + +```sh +uv run openshell-middleware-init audit-headers \ + --language python \ + --openshell-version v0.0.86 \ + --package-name request_auditor +``` + +Run `uv run openshell-middleware-init --help` for the complete interface. + +## What generation does + +The initializer: + +1. resolves and downloads `proto/supervisor_middleware.proto` from the selected + OpenShell release; +2. renders a runnable pass-through service, tests, development configuration, + and registration guidance; +3. generates package-safe Python protobuf/gRPC modules or configures Rust Tonic + generation with a bundled `protoc`; +4. creates `uv.lock` or `Cargo.lock` and compile/import-checks the project; +5. writes `middleware-dev-manifest.json`; and +6. publishes the project only after validation succeeds. + +Generation happens in a hidden sibling staging directory. The final output must +not already exist, and a reservation directory prevents concurrent initializers +from publishing to the same path. A failed run removes its own staging and +reservation data and does not merge into an existing project. + +Unlike the original `middleware_dev_setup` spike, this project initializer does +not install or replace OpenShell. Install the desired OpenShell release through +its official installer separately. + +## Requirements + +- All generation: network access to GitHub and the selected OpenShell release. +- Python output: `uv`. +- Rust output: Cargo and a toolchain compatible with Rust 1.90 / edition 2024. + +## Develop this CLI + +Run the full local gate from this directory: + +```sh +uv run ruff format --check . +uv run ruff check . +uv run ty check +uv run pytest +uv build +``` + +`src/openshell_middleware_init/templates/` contains the generated project +assets. Tests use local protocol fixtures and do not contact GitHub or invoke +language package managers. diff --git a/projects/openshell-middleware-init/pyproject.toml b/projects/openshell-middleware-init/pyproject.toml new file mode 100644 index 00000000..6c47e66c --- /dev/null +++ b/projects/openshell-middleware-init/pyproject.toml @@ -0,0 +1,56 @@ +[project] +name = "openshell-middleware-init" +version = "0.1.0" +description = "Generate version-matched OpenShell supervisor middleware projects." +readme = "README.md" +requires-python = ">=3.10" +license = "Apache-2.0" +authors = [ + { name = "NVIDIA CORPORATION & AFFILIATES" }, +] +dependencies = [ + "typer>=0.16,<1", +] + +[project.scripts] +openshell-middleware-init = "openshell_middleware_init.cli:main" + +[project.urls] +Repository = "https://github.com/NVIDIA/OpenShell-Research" + +[dependency-groups] +dev = [ + "pytest>=8,<10", + "pytest-cov>=5,<8", + "ruff>=0.12,<1", + "ty>=0.0.53", +] + +[build-system] +requires = ["uv_build>=0.11.8,<0.12.0"] +build-backend = "uv_build" + +[tool.pytest.ini_options] +addopts = [ + "--strict-config", + "--strict-markers", + "--cov=openshell_middleware_init", + "--cov-report=term-missing", + "--cov-fail-under=95", +] +testpaths = ["tests"] + +[tool.ruff] +line-length = 100 +target-version = "py310" +extend-exclude = ["src/openshell_middleware_init/templates"] + +[tool.ruff.lint] +select = ["B", "E", "F", "I", "RUF", "SIM", "UP"] + +[tool.ty.src] +include = ["src", "tests"] +exclude = ["src/openshell_middleware_init/templates"] + +[tool.uv] +required-version = ">=0.11.0" diff --git a/projects/openshell-middleware-init/requirements.txt b/projects/openshell-middleware-init/requirements.txt new file mode 100644 index 00000000..1aed6138 --- /dev/null +++ b/projects/openshell-middleware-init/requirements.txt @@ -0,0 +1,34 @@ +# This file was autogenerated by uv via the following command: +# uv export --format requirements.txt --no-dev --no-emit-project --locked --output-file requirements.txt +annotated-doc==0.0.4 \ + --hash=sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320 \ + --hash=sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4 + # via typer +colorama==0.4.6 ; sys_platform == 'win32' \ + --hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \ + --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 + # via typer +markdown-it-py==4.2.0 \ + --hash=sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49 \ + --hash=sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a + # via rich +mdurl==0.1.2 \ + --hash=sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8 \ + --hash=sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba + # via markdown-it-py +pygments==2.20.0 \ + --hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \ + --hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176 + # via rich +rich==15.0.0 \ + --hash=sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb \ + --hash=sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36 + # via typer +shellingham==1.5.4 \ + --hash=sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686 \ + --hash=sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de + # via typer +typer==0.27.0 \ + --hash=sha256:629bd12ea5d13a17148125d9a264f949eb171fb3f120f9b04d85873cab054fa5 \ + --hash=sha256:6f4b27631e47f077871b7dc30e933ec0131c1390fbe0e387ea5574b5bac9ccf1 + # via openshell-middleware-init diff --git a/projects/openshell-middleware-init/src/openshell_middleware_init/__init__.py b/projects/openshell-middleware-init/src/openshell_middleware_init/__init__.py new file mode 100644 index 00000000..03f94f25 --- /dev/null +++ b/projects/openshell-middleware-init/src/openshell_middleware_init/__init__.py @@ -0,0 +1,3 @@ +"""Generate version-matched OpenShell middleware projects.""" + +__version__ = "0.1.0" diff --git a/projects/openshell-middleware-init/src/openshell_middleware_init/cli.py b/projects/openshell-middleware-init/src/openshell_middleware_init/cli.py new file mode 100644 index 00000000..a6e3de5a --- /dev/null +++ b/projects/openshell-middleware-init/src/openshell_middleware_init/cli.py @@ -0,0 +1,88 @@ +"""Typer command-line interface for the middleware project generator.""" + +from __future__ import annotations + +from enum import Enum +from pathlib import Path +from typing import Annotated + +import typer + +from openshell_middleware_init.generator import InitializationError, initialize_project + + +class Language(str, Enum): + """Middleware implementation languages supported by the generator.""" + + PYTHON = "python" + RUST = "rust" + + +app = typer.Typer( + add_completion=False, + no_args_is_help=True, + pretty_exceptions_enable=False, + help="Generate a runnable, version-matched OpenShell middleware project.", +) + + +@app.command() +def init( + name: Annotated[ + str, + typer.Argument(help="Project name, such as audit-headers."), + ], + language: Annotated[ + Language, + typer.Option("--language", "-l", help="Implementation language."), + ], + openshell_version: Annotated[ + str, + typer.Option( + "--openshell-version", + "--version", + help="OpenShell release tag (for example v0.0.86), or latest.", + ), + ], + output: Annotated[ + Path | None, + typer.Option( + "--output", + "-o", + help="Destination directory. Defaults to ./.", + ), + ] = None, + package_name: Annotated[ + str | None, + typer.Option( + "--package-name", + help="Python import package override (Python projects only).", + ), + ] = None, +) -> None: + """Create a new OpenShell supervisor middleware project.""" + destination = output if output is not None else Path.cwd() / name + try: + result = initialize_project( + name=name, + language=language.value, + requested_version=openshell_version, + destination=destination, + package_name=package_name, + ) + except InitializationError as error: + typer.echo(f"openshell-middleware-init: error: {error}", err=True) + raise typer.Exit(code=1) from error + + typer.echo(f"Created {result.language} middleware project at {result.destination}") + typer.echo(f"OpenShell contract: {result.openshell_version}") + typer.echo(f"Next: cd {result.destination} && {result.run_command}") + + +def main() -> None: + """Run the command-line application.""" + app() + + +if __name__ == "__main__": + main() diff --git a/projects/openshell-middleware-init/src/openshell_middleware_init/generator.py b/projects/openshell-middleware-init/src/openshell_middleware_init/generator.py new file mode 100644 index 00000000..3a1f663a --- /dev/null +++ b/projects/openshell-middleware-init/src/openshell_middleware_init/generator.py @@ -0,0 +1,435 @@ +"""Safe, version-matched project generation.""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import secrets +import shutil +import subprocess +import tempfile +import urllib.error +import urllib.request +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass +from importlib.resources import files +from pathlib import Path + +from openshell_middleware_init import __version__ + +_REPOSITORY_URL = "https://github.com/NVIDIA/OpenShell" +_RAW_URL = "https://raw.githubusercontent.com/NVIDIA/OpenShell" +_PROTO_PATH = "proto/supervisor_middleware.proto" +_GRPCIO_TOOLS_VERSION = "1.81.1" +_VERSION_PATTERN = re.compile(r"^v\d+\.\d+\.\d+(?:[+-][0-9A-Za-z._-]+)?$") +_PYTHON_PACKAGE_PATTERN = re.compile(r"^[a-z][a-z0-9_]*$") +_PROJECT_NAME_PATTERN = re.compile(r"^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$") + + +class InitializationError(RuntimeError): + """A user-actionable project initialization failure.""" + + +@dataclass(frozen=True) +class InitializationResult: + """Details about a successfully generated project.""" + + destination: Path + language: str + openshell_version: str + run_command: str + + +@dataclass(frozen=True) +class TemplateContext: + """Normalized names used while rendering a project.""" + + project_name: str + distribution_name: str + package_name: str + rust_crate_name: str + service_name: str + + @property + def replacements(self) -> Mapping[str, str]: + return { + "__PROJECT_NAME__": self.project_name, + "__DISTRIBUTION_NAME__": self.distribution_name, + "__PACKAGE_NAME__": self.package_name, + "__RUST_CRATE_NAME__": self.rust_crate_name, + "__SERVICE_NAME__": self.service_name, + } + + +DownloadProto = Callable[[str], tuple[bytes, str]] +CommandRunner = Callable[[str, Path, str], None] + + +def initialize_project( + *, + name: str, + language: str, + requested_version: str, + destination: Path, + package_name: str | None = None, + download_proto: DownloadProto | None = None, + command_runner: CommandRunner | None = None, +) -> InitializationResult: + """Generate and validate a project, then publish it atomically.""" + context = _template_context(name, language, package_name) + version = _normalize_version(requested_version) + destination = destination.expanduser().resolve() + _validate_destination(destination) + downloader = download_proto if download_proto is not None else _download_proto + runner = command_runner if command_runner is not None else _prepare_project + + destination.parent.mkdir(parents=True, exist_ok=True) + lock_path = destination.parent / f".{destination.name}.openshell-middleware-init.lock" + lock_token = secrets.token_hex(16) + _acquire_lock(lock_path, lock_token, destination, version) + staging_path: Path | None = None + try: + _validate_destination(destination) + staging_path = Path( + tempfile.mkdtemp( + prefix=f".{destination.name}.openshell-middleware-init.", + dir=destination.parent, + ) + ) + proto, proto_url = downloader(version) + _validate_proto(proto, version) + _render_project(staging_path, language, context) + proto_path = staging_path / "proto" / "supervisor_middleware.proto" + proto_path.parent.mkdir(parents=True, exist_ok=True) + proto_path.write_bytes(proto) + _write_manifest( + staging_path, + version=version, + proto_url=proto_url, + proto=proto, + language=language, + python_package=context.package_name if language == "python" else None, + ) + runner(language, staging_path, context.package_name) + _verify_lock(lock_path, lock_token) + _validate_destination(destination) + staging_path.replace(destination) + staging_path = None + except InitializationError: + raise + except (OSError, subprocess.SubprocessError) as error: + raise InitializationError(str(error)) from error + finally: + if staging_path is not None: + shutil.rmtree(staging_path, ignore_errors=True) + _release_lock(lock_path, lock_token) + + return InitializationResult( + destination=destination, + language=language, + openshell_version=version, + run_command=( + f"uv run {context.distribution_name}" + if language == "python" + else "cargo run -- 127.0.0.1:50051" + ), + ) + + +def _template_context(name: str, language: str, package_name: str | None) -> TemplateContext: + normalized_name = name.strip().lower() + if not _PROJECT_NAME_PATTERN.fullmatch(normalized_name): + raise InitializationError( + "project name must use lowercase letters, digits, dots, hyphens, or underscores" + ) + if language not in {"python", "rust"}: + raise InitializationError("language must be 'python' or 'rust'") + if language == "rust" and package_name is not None: + raise InitializationError("--package-name is only valid with --language python") + + distribution_name = re.sub(r"[._]+", "-", normalized_name) + derived_package = re.sub(r"[^a-z0-9]+", "_", normalized_name).strip("_") + if not derived_package or not derived_package[0].isalpha(): + derived_package = f"middleware_{derived_package}".rstrip("_") + effective_package = package_name if package_name is not None else derived_package + if not _PYTHON_PACKAGE_PATTERN.fullmatch(effective_package): + raise InitializationError( + "Python package name must start with a lowercase letter and contain only " + "lowercase letters, digits, and underscores" + ) + service_name = normalized_name.replace("_", "-").replace(".", "-") + return TemplateContext( + project_name=normalized_name, + distribution_name=distribution_name, + package_name=effective_package, + rust_crate_name=distribution_name, + service_name=service_name, + ) + + +def _normalize_version(requested: str) -> str: + value = requested.strip() + if value == "latest": + return _resolve_latest_version() + if not value.startswith("v"): + value = f"v{value}" + if not _VERSION_PATTERN.fullmatch(value): + raise InitializationError( + f"invalid OpenShell version '{requested}'; expected a tag such as v0.0.86" + ) + return value + + +def _resolve_latest_version() -> str: + request = urllib.request.Request( + f"{_REPOSITORY_URL}/releases/latest", + headers={"User-Agent": f"openshell-middleware-init/{__version__}"}, + ) + try: + with urllib.request.urlopen(request, timeout=30) as response: + resolved_url = response.geturl() + except (OSError, urllib.error.URLError) as error: + raise InitializationError("could not resolve OpenShell's latest release") from error + prefix = f"{_REPOSITORY_URL}/releases/tag/" + if not resolved_url.startswith(prefix): + raise InitializationError(f"unexpected latest-release redirect: {resolved_url}") + version = resolved_url.removeprefix(prefix) + if not _VERSION_PATTERN.fullmatch(version): + raise InitializationError(f"latest release has an unexpected tag: {version}") + return version + + +def _download_proto(version: str) -> tuple[bytes, str]: + url = f"{_RAW_URL}/{version}/{_PROTO_PATH}" + request = urllib.request.Request( + url, + headers={"User-Agent": f"openshell-middleware-init/{__version__}"}, + ) + try: + with urllib.request.urlopen(request, timeout=30) as response: + return response.read(), url + except (OSError, urllib.error.URLError) as error: + raise InitializationError( + f"{version} does not expose {_PROTO_PATH}; choose a middleware-capable release" + ) from error + + +def _validate_proto(proto: bytes, version: str) -> None: + required_fragments = ( + b"package openshell.middleware.v1;", + b"service SupervisorMiddleware", + b"rpc EvaluateHttpRequest", + ) + if not proto or any(fragment not in proto for fragment in required_fragments): + raise InitializationError( + f"downloaded contract for {version} is not a supported supervisor middleware proto" + ) + + +def _validate_destination(destination: Path) -> None: + if os.path.lexists(destination): + raise InitializationError(f"output path must not already exist: {destination}") + if destination.name in {"", ".", ".."}: + raise InitializationError(f"invalid output path: {destination}") + + +def _acquire_lock(lock_path: Path, token: str, destination: Path, version: str) -> None: + try: + lock_path.mkdir() + except FileExistsError as error: + raise InitializationError( + f"output path is reserved by another initializer: {destination}; " + f"inspect {lock_path} before removing a stale reservation" + ) from error + try: + (lock_path / "owner").write_text(token) + (lock_path / "metadata.json").write_text( + json.dumps( + { + "pid": os.getpid(), + "target_version": version, + "final_output": str(destination), + }, + indent=2, + ) + + "\n" + ) + except OSError: + shutil.rmtree(lock_path, ignore_errors=True) + raise + + +def _verify_lock(lock_path: Path, token: str) -> None: + try: + recorded = (lock_path / "owner").read_text() + except OSError as error: + raise InitializationError("output reservation was lost; refusing to publish") from error + if recorded != token: + raise InitializationError("output reservation ownership changed; refusing to publish") + + +def _release_lock(lock_path: Path, token: str) -> None: + try: + if (lock_path / "owner").read_text() != token: + return + except OSError: + return + shutil.rmtree(lock_path, ignore_errors=True) + + +def _render_project(destination: Path, language: str, context: TemplateContext) -> None: + template_root = files("openshell_middleware_init").joinpath("templates").joinpath(language) + template_paths = { + "python": ( + ".gitignore", + "README.md", + "pyproject.toml", + "src/package/__init__.py", + "src/package/server.py", + "src/package/bindings/__init__.py", + "tests/test_server.py", + ), + "rust": ( + ".gitignore", + "README.md", + "Cargo.toml", + "build.rs", + "src/lib.rs", + "src/main.rs", + ), + } + for relative_name in template_paths[language]: + rendered_name = relative_name.replace("src/package", f"src/{context.package_name}") + target = destination / rendered_name + target.parent.mkdir(parents=True, exist_ok=True) + content = template_root.joinpath(relative_name).read_text() + for marker, replacement in context.replacements.items(): + content = content.replace(marker, replacement) + target.write_text(content) + + +def _write_manifest( + project_dir: Path, + *, + version: str, + proto_url: str, + proto: bytes, + language: str, + python_package: str | None, +) -> None: + manifest = { + "openshell_version": version, + "proto_source": proto_url, + "proto_sha256": hashlib.sha256(proto).hexdigest(), + "languages": [language], + "python_package": python_package, + "generator": { + "name": "openshell-middleware-init", + "version": __version__, + }, + } + (project_dir / "middleware-dev-manifest.json").write_text(json.dumps(manifest, indent=2) + "\n") + + +def _prepare_project(language: str, project_dir: Path, package_name: str) -> None: + if language == "python": + _prepare_python_project(project_dir, package_name) + else: + _prepare_rust_project(project_dir) + + +def _require_command(command: str) -> str: + resolved = shutil.which(command) + if resolved is None: + raise InitializationError(f"'{command}' is required to initialize this project") + return resolved + + +def _run( + command: Sequence[str], + *, + cwd: Path, + environment: Mapping[str, str] | None = None, +) -> None: + try: + subprocess.run( + command, + cwd=cwd, + env=dict(environment) if environment is not None else None, + check=True, + ) + except subprocess.CalledProcessError as error: + raise InitializationError( + f"validation command failed with exit code {error.returncode}: {' '.join(command)}" + ) from error + + +def _prepare_python_project(project_dir: Path, package_name: str) -> None: + uv = _require_command("uv") + bindings_dir = project_dir / "src" / package_name / "bindings" + proto_path = project_dir / "proto" / "supervisor_middleware.proto" + _run( + ( + uv, + "run", + "--isolated", + "--no-project", + "--with", + f"grpcio-tools=={_GRPCIO_TOOLS_VERSION}", + "python", + "-m", + "grpc_tools.protoc", + f"-I{proto_path.parent}", + f"--python_out={bindings_dir}", + f"--pyi_out={bindings_dir}", + f"--grpc_python_out={bindings_dir}", + str(proto_path), + ), + cwd=project_dir, + ) + grpc_module = bindings_dir / "supervisor_middleware_pb2_grpc.py" + generated = grpc_module.read_text() + absolute_import = "import supervisor_middleware_pb2 as supervisor__middleware__pb2" + relative_import = "from . import supervisor_middleware_pb2 as supervisor__middleware__pb2" + if absolute_import not in generated: + raise InitializationError( + "generated gRPC module has an unexpected import layout; no project was published" + ) + grpc_module.write_text(generated.replace(absolute_import, relative_import, 1)) + + with tempfile.TemporaryDirectory(prefix="openshell-middleware-init-python-") as environment: + process_environment = os.environ.copy() + process_environment.pop("VIRTUAL_ENV", None) + process_environment["UV_PROJECT_ENVIRONMENT"] = environment + _run( + (uv, "sync", "--project", str(project_dir)), + cwd=project_dir, + environment=process_environment, + ) + _run( + ( + uv, + "run", + "--project", + str(project_dir), + "python", + "-c", + f"from {package_name}.server import Middleware", + ), + cwd=project_dir, + environment=process_environment, + ) + + +def _prepare_rust_project(project_dir: Path) -> None: + cargo = _require_command("cargo") + with tempfile.TemporaryDirectory(prefix="openshell-middleware-init-rust-") as target_dir: + process_environment = os.environ.copy() + process_environment["CARGO_TARGET_DIR"] = target_dir + _run( + (cargo, "check", "--manifest-path", str(project_dir / "Cargo.toml")), + cwd=project_dir, + environment=process_environment, + ) diff --git a/projects/openshell-middleware-init/src/openshell_middleware_init/templates/python/.gitignore b/projects/openshell-middleware-init/src/openshell_middleware_init/templates/python/.gitignore new file mode 100644 index 00000000..17ce2517 --- /dev/null +++ b/projects/openshell-middleware-init/src/openshell_middleware_init/templates/python/.gitignore @@ -0,0 +1,13 @@ +.DS_Store +.env +.env.* +!.env.example +*.key +*.pem +__pycache__/ +*.py[cod] +.venv/ +.pytest_cache/ +.ruff_cache/ +.coverage +dist/ diff --git a/projects/openshell-middleware-init/src/openshell_middleware_init/templates/python/README.md b/projects/openshell-middleware-init/src/openshell_middleware_init/templates/python/README.md new file mode 100644 index 00000000..9195f960 --- /dev/null +++ b/projects/openshell-middleware-init/src/openshell_middleware_init/templates/python/README.md @@ -0,0 +1,60 @@ +# __PROJECT_NAME__ + +A Python OpenShell supervisor middleware starter. It is pinned to the OpenShell +contract recorded in `middleware-dev-manifest.json` and starts as a pass-through: +valid pre-credentials HTTP requests are allowed without mutation. + +## Develop + +Install [uv](https://docs.astral.sh/uv/), then run the local checks: + +```sh +uv sync --locked +uv run ruff format --check . +uv run ruff check . +uv run ty check +uv run pytest +uv build +``` + +Start the middleware on loopback: + +```sh +uv run __DISTRIBUTION_NAME__ --listen 127.0.0.1:50051 +``` + +The server implementation is in `src/__PACKAGE_NAME__/server.py`. Extend +`validate_config` and `evaluate_http_request` with your policy and request +handling. Keep transport adaptation at this boundary and move substantial +domain logic into separate modules. + +## Connect OpenShell + +Register the running service in the gateway configuration: + +```toml +[[openshell.supervisor.middleware]] +name = "__SERVICE_NAME__" +grpc_endpoint = "http://127.0.0.1:50051" +max_body_bytes = 4194304 +timeout = "500ms" +``` + +Then reference `__SERVICE_NAME__` from a sandbox policy's middleware stage. +Review the supervisor middleware documentation for the policy syntax supported +by your pinned OpenShell release. + +## Version-matched generated files + +- `proto/supervisor_middleware.proto` is the exact downloaded contract. +- `src/__PACKAGE_NAME__/bindings/` contains generated protobuf and gRPC modules. +- `middleware-dev-manifest.json` records the release, source URL, and SHA-256. +- `uv.lock` records the Python dependency solution. + +Commit these files. When changing the OpenShell version, regenerate the project +or deliberately regenerate all four artifacts together; do not mix bindings and +contracts from different releases. + +The starter is deliberately permissive. Before deployment, validate untrusted +configuration, bound request and response work, avoid logging request content, +and return stable deny/error behavior for failures appropriate to your policy. diff --git a/projects/openshell-middleware-init/src/openshell_middleware_init/templates/python/pyproject.toml b/projects/openshell-middleware-init/src/openshell_middleware_init/templates/python/pyproject.toml new file mode 100644 index 00000000..b4c97b02 --- /dev/null +++ b/projects/openshell-middleware-init/src/openshell_middleware_init/templates/python/pyproject.toml @@ -0,0 +1,42 @@ +[project] +name = "__DISTRIBUTION_NAME__" +version = "0.1.0" +description = "OpenShell supervisor middleware" +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "grpcio>=1.81.1,<2", + "protobuf>=6.33.5,<7", +] + +[project.scripts] +__DISTRIBUTION_NAME__ = "__PACKAGE_NAME__.server:main" + +[dependency-groups] +dev = [ + "pytest>=8,<10", + "ruff>=0.12,<1", + "ty>=0.0.53", +] + +[build-system] +requires = ["uv_build>=0.11.8,<0.12.0"] +build-backend = "uv_build" + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[tool.ruff] +line-length = 100 +target-version = "py311" +extend-exclude = ["src/__PACKAGE_NAME__/bindings"] + +[tool.ruff.lint] +select = ["B", "E", "F", "I", "RUF", "SIM", "UP"] + +[tool.ty.src] +include = ["src", "tests"] +exclude = ["src/__PACKAGE_NAME__/bindings"] + +[tool.uv] +required-version = ">=0.11.0" diff --git a/projects/openshell-middleware-init/src/openshell_middleware_init/templates/python/src/package/__init__.py b/projects/openshell-middleware-init/src/openshell_middleware_init/templates/python/src/package/__init__.py new file mode 100644 index 00000000..06bf21a9 --- /dev/null +++ b/projects/openshell-middleware-init/src/openshell_middleware_init/templates/python/src/package/__init__.py @@ -0,0 +1 @@ +"""__PROJECT_NAME__: an OpenShell supervisor middleware.""" diff --git a/projects/openshell-middleware-init/src/openshell_middleware_init/templates/python/src/package/bindings/__init__.py b/projects/openshell-middleware-init/src/openshell_middleware_init/templates/python/src/package/bindings/__init__.py new file mode 100644 index 00000000..d206506f --- /dev/null +++ b/projects/openshell-middleware-init/src/openshell_middleware_init/templates/python/src/package/bindings/__init__.py @@ -0,0 +1 @@ +"""Generated OpenShell supervisor middleware bindings. Do not edit.""" diff --git a/projects/openshell-middleware-init/src/openshell_middleware_init/templates/python/src/package/server.py b/projects/openshell-middleware-init/src/openshell_middleware_init/templates/python/src/package/server.py new file mode 100644 index 00000000..bf514f3b --- /dev/null +++ b/projects/openshell-middleware-init/src/openshell_middleware_init/templates/python/src/package/server.py @@ -0,0 +1,101 @@ +"""Pass-through OpenShell supervisor middleware server.""" + +from __future__ import annotations + +import argparse +import asyncio +from collections.abc import Sequence + +import grpc + +from __PACKAGE_NAME__.bindings import supervisor_middleware_pb2 as pb2 +from __PACKAGE_NAME__.bindings import supervisor_middleware_pb2_grpc as pb2_grpc + +SERVICE_NAME = "__SERVICE_NAME__" +SERVICE_VERSION = "0.1.0" +MAX_BODY_BYTES = 4 * 1024 * 1024 + + +def build_manifest() -> pb2.MiddlewareManifest: + """Describe the operation and phase supported by this service.""" + return pb2.MiddlewareManifest( + name=SERVICE_NAME, + service_version=SERVICE_VERSION, + bindings=[ + pb2.MiddlewareBinding( + operation=pb2.SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_REQUEST, + phase=pb2.SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS, + max_body_bytes=MAX_BODY_BYTES, + ) + ], + ) + + +def validate_config(request: pb2.ValidateConfigRequest) -> pb2.ValidateConfigResponse: + """Validate service-specific config before OpenShell admits a policy.""" + del request + return pb2.ValidateConfigResponse(valid=True) + + +def evaluate_http_request(request: pb2.HttpRequestEvaluation) -> pb2.HttpRequestResult: + """Allow a valid pre-credentials request without mutation.""" + if request.phase != pb2.SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS: + return pb2.HttpRequestResult( + decision=pb2.DECISION_DENY, + reason="unsupported middleware phase", + reason_code="unsupported_phase", + ) + return pb2.HttpRequestResult(decision=pb2.DECISION_ALLOW) + + +class Middleware(pb2_grpc.SupervisorMiddlewareServicer): + """Adapt gRPC calls to the middleware's application functions.""" + + async def Describe( + self, + request: object, + context: grpc.aio.ServicerContext, + ) -> pb2.MiddlewareManifest: + del request, context + return build_manifest() + + async def ValidateConfig( + self, + request: pb2.ValidateConfigRequest, + context: grpc.aio.ServicerContext, + ) -> pb2.ValidateConfigResponse: + del context + return validate_config(request) + + async def EvaluateHttpRequest( + self, + request: pb2.HttpRequestEvaluation, + context: grpc.aio.ServicerContext, + ) -> pb2.HttpRequestResult: + del context + return evaluate_http_request(request) + + +async def serve(listen: str) -> None: + """Serve the middleware until termination.""" + server = grpc.aio.server() + pb2_grpc.add_SupervisorMiddlewareServicer_to_server(Middleware(), server) + if server.add_insecure_port(listen) == 0: + raise RuntimeError(f"could not bind middleware server to {listen}") + await server.start() + try: + await server.wait_for_termination() + finally: + await server.stop(grace=0) + + +def main(argv: Sequence[str] | None = None) -> None: + """Run the middleware server.""" + parser = argparse.ArgumentParser(description="Run the __PROJECT_NAME__ middleware") + parser.add_argument("--listen", default="127.0.0.1:50051") + arguments = parser.parse_args(argv) + asyncio.run(serve(arguments.listen)) + + +if __name__ == "__main__": + main() diff --git a/projects/openshell-middleware-init/src/openshell_middleware_init/templates/python/tests/test_server.py b/projects/openshell-middleware-init/src/openshell_middleware_init/templates/python/tests/test_server.py new file mode 100644 index 00000000..4aad6cca --- /dev/null +++ b/projects/openshell-middleware-init/src/openshell_middleware_init/templates/python/tests/test_server.py @@ -0,0 +1,44 @@ +from __PACKAGE_NAME__.bindings import supervisor_middleware_pb2 as pb2 +from __PACKAGE_NAME__.server import ( + MAX_BODY_BYTES, + SERVICE_NAME, + build_manifest, + evaluate_http_request, + validate_config, +) + + +def test_manifest_advertises_pre_credentials_http() -> None: + manifest = build_manifest() + + assert manifest.name == SERVICE_NAME + assert len(manifest.bindings) == 1 + assert manifest.bindings[0].operation == pb2.SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_REQUEST + assert manifest.bindings[0].phase == pb2.SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS + assert manifest.bindings[0].max_body_bytes == MAX_BODY_BYTES + + +def test_default_config_is_valid() -> None: + response = validate_config(pb2.ValidateConfigRequest()) + + assert response.valid is True + assert response.reason == "" + + +def test_valid_request_is_allowed_without_mutation() -> None: + response = evaluate_http_request( + pb2.HttpRequestEvaluation( + phase=pb2.SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS, + ) + ) + + assert response.decision == pb2.DECISION_ALLOW + assert response.has_body is False + assert response.header_mutations == [] + + +def test_unsupported_phase_is_denied() -> None: + response = evaluate_http_request(pb2.HttpRequestEvaluation()) + + assert response.decision == pb2.DECISION_DENY + assert response.reason_code == "unsupported_phase" diff --git a/projects/openshell-middleware-init/src/openshell_middleware_init/templates/rust/.gitignore b/projects/openshell-middleware-init/src/openshell_middleware_init/templates/rust/.gitignore new file mode 100644 index 00000000..6b269eab --- /dev/null +++ b/projects/openshell-middleware-init/src/openshell_middleware_init/templates/rust/.gitignore @@ -0,0 +1,7 @@ +.DS_Store +.env +.env.* +!.env.example +*.key +*.pem +target/ diff --git a/projects/openshell-middleware-init/src/openshell_middleware_init/templates/rust/Cargo.toml b/projects/openshell-middleware-init/src/openshell_middleware_init/templates/rust/Cargo.toml new file mode 100644 index 00000000..c565ea5b --- /dev/null +++ b/projects/openshell-middleware-init/src/openshell_middleware_init/templates/rust/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "__RUST_CRATE_NAME__" +version = "0.1.0" +edition = "2024" +rust-version = "1.90" +publish = false + +[dependencies] +prost = "0.14" +prost-types = "0.14" +tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal"] } +tonic = "0.14" +tonic-prost = "0.14" + +[build-dependencies] +protobuf-src = "1.1.0" +tonic-prost-build = "0.14" diff --git a/projects/openshell-middleware-init/src/openshell_middleware_init/templates/rust/README.md b/projects/openshell-middleware-init/src/openshell_middleware_init/templates/rust/README.md new file mode 100644 index 00000000..409491ea --- /dev/null +++ b/projects/openshell-middleware-init/src/openshell_middleware_init/templates/rust/README.md @@ -0,0 +1,56 @@ +# __PROJECT_NAME__ + +A Rust OpenShell supervisor middleware starter. It is pinned to the OpenShell +contract recorded in `middleware-dev-manifest.json` and starts as a pass-through: +valid pre-credentials HTTP requests are allowed without mutation. + +## Develop + +Use Rust 1.90 or newer, then run: + +```sh +cargo fmt --check +cargo clippy --all-targets --all-features -- -D warnings +cargo test --locked +``` + +Start the middleware on loopback: + +```sh +cargo run --locked -- 127.0.0.1:50051 +``` + +The service implementation is in `src/lib.rs`. Extend `validate_config` and +`evaluate_http_request` with your policy and request handling. Keep transport +adaptation at this boundary and move substantial domain logic into separate +modules. + +## Connect OpenShell + +Register the running service in the gateway configuration: + +```toml +[[openshell.supervisor.middleware]] +name = "__SERVICE_NAME__" +grpc_endpoint = "http://127.0.0.1:50051" +max_body_bytes = 4194304 +timeout = "500ms" +``` + +Then reference `__SERVICE_NAME__` from a sandbox policy's middleware stage. +Review the supervisor middleware documentation for the policy syntax supported +by your pinned OpenShell release. + +## Version-matched generated files + +- `proto/supervisor_middleware.proto` is the exact downloaded contract. +- `build.rs` generates Rust modules into Cargo's `OUT_DIR` from that contract. +- `middleware-dev-manifest.json` records the release, source URL, and SHA-256. +- `Cargo.lock` records the Rust dependency solution. + +Commit these files. When changing the OpenShell version, regenerate the project +or deliberately update the contract and manifest together. + +The starter is deliberately permissive. Before deployment, validate untrusted +configuration, bound request and response work, avoid logging request content, +and return stable deny/error behavior for failures appropriate to your policy. diff --git a/projects/openshell-middleware-init/src/openshell_middleware_init/templates/rust/build.rs b/projects/openshell-middleware-init/src/openshell_middleware_init/templates/rust/build.rs new file mode 100644 index 00000000..ff030cc9 --- /dev/null +++ b/projects/openshell-middleware-init/src/openshell_middleware_init/templates/rust/build.rs @@ -0,0 +1,16 @@ +use std::error::Error; + +fn main() -> Result<(), Box> { + // Bundle protoc so contributors do not need a separate installation. + unsafe { + std::env::set_var("PROTOC", protobuf_src::protoc()); + } + + println!("cargo:rerun-if-changed=proto/supervisor_middleware.proto"); + tonic_prost_build::configure() + .build_client(true) + .build_server(true) + .compile_protos(&["proto/supervisor_middleware.proto"], &["proto"])?; + + Ok(()) +} diff --git a/projects/openshell-middleware-init/src/openshell_middleware_init/templates/rust/src/lib.rs b/projects/openshell-middleware-init/src/openshell_middleware_init/templates/rust/src/lib.rs new file mode 100644 index 00000000..dafc70b4 --- /dev/null +++ b/projects/openshell-middleware-init/src/openshell_middleware_init/templates/rust/src/lib.rs @@ -0,0 +1,106 @@ +//! Pass-through OpenShell supervisor middleware service. + +use tonic::{Request, Response, Status}; + +pub mod pb { + tonic::include_proto!("openshell.middleware.v1"); +} + +use pb::supervisor_middleware_server::SupervisorMiddleware; + +pub const SERVICE_NAME: &str = "__SERVICE_NAME__"; +pub const SERVICE_VERSION: &str = "0.1.0"; +pub const MAX_BODY_BYTES: u64 = 4 * 1024 * 1024; + +#[derive(Debug, Default)] +pub struct Middleware; + +pub fn build_manifest() -> pb::MiddlewareManifest { + pb::MiddlewareManifest { + name: SERVICE_NAME.to_owned(), + service_version: SERVICE_VERSION.to_owned(), + bindings: vec![pb::MiddlewareBinding { + operation: pb::SupervisorMiddlewareOperation::HttpRequest as i32, + phase: pb::SupervisorMiddlewarePhase::PreCredentials as i32, + max_body_bytes: MAX_BODY_BYTES, + timeout: String::new(), + }], + } +} + +pub fn validate_config(_request: pb::ValidateConfigRequest) -> pb::ValidateConfigResponse { + pb::ValidateConfigResponse { + valid: true, + reason: String::new(), + } +} + +pub fn evaluate_http_request(request: pb::HttpRequestEvaluation) -> pb::HttpRequestResult { + if request.phase != pb::SupervisorMiddlewarePhase::PreCredentials as i32 { + return pb::HttpRequestResult { + decision: pb::Decision::Deny as i32, + reason: "unsupported middleware phase".to_owned(), + reason_code: "unsupported_phase".to_owned(), + ..Default::default() + }; + } + pb::HttpRequestResult { + decision: pb::Decision::Allow as i32, + ..Default::default() + } +} + +#[tonic::async_trait] +impl SupervisorMiddleware for Middleware { + async fn describe( + &self, + _request: Request<()>, + ) -> Result, Status> { + Ok(Response::new(build_manifest())) + } + + async fn validate_config( + &self, + request: Request, + ) -> Result, Status> { + Ok(Response::new(validate_config(request.into_inner()))) + } + + async fn evaluate_http_request( + &self, + request: Request, + ) -> Result, Status> { + Ok(Response::new(evaluate_http_request(request.into_inner()))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn manifest_advertises_pre_credentials_http() { + let manifest = build_manifest(); + assert_eq!(manifest.name, SERVICE_NAME); + assert_eq!(manifest.bindings.len(), 1); + assert_eq!(manifest.bindings[0].max_body_bytes, MAX_BODY_BYTES); + } + + #[test] + fn valid_request_is_allowed_without_mutation() { + let response = evaluate_http_request(pb::HttpRequestEvaluation { + phase: pb::SupervisorMiddlewarePhase::PreCredentials as i32, + ..Default::default() + }); + assert_eq!(response.decision, pb::Decision::Allow as i32); + assert!(!response.has_body); + assert!(response.header_mutations.is_empty()); + } + + #[test] + fn unsupported_phase_is_denied() { + let response = evaluate_http_request(pb::HttpRequestEvaluation::default()); + assert_eq!(response.decision, pb::Decision::Deny as i32); + assert_eq!(response.reason_code, "unsupported_phase"); + } +} diff --git a/projects/openshell-middleware-init/src/openshell_middleware_init/templates/rust/src/main.rs b/projects/openshell-middleware-init/src/openshell_middleware_init/templates/rust/src/main.rs new file mode 100644 index 00000000..653b58b4 --- /dev/null +++ b/projects/openshell-middleware-init/src/openshell_middleware_init/templates/rust/src/main.rs @@ -0,0 +1,19 @@ +use std::{env, error::Error, net::SocketAddr}; + +use __PACKAGE_NAME__::{Middleware, pb::supervisor_middleware_server::SupervisorMiddlewareServer}; +use tonic::transport::Server; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let listen = env::args() + .nth(1) + .unwrap_or_else(|| "127.0.0.1:50051".to_owned()); + let address: SocketAddr = listen.parse()?; + + println!("serving __SERVICE_NAME__ on {address}"); + Server::builder() + .add_service(SupervisorMiddlewareServer::new(Middleware)) + .serve(address) + .await?; + Ok(()) +} diff --git a/projects/openshell-middleware-init/tests/test_cli.py b/projects/openshell-middleware-init/tests/test_cli.py new file mode 100644 index 00000000..40d876a9 --- /dev/null +++ b/projects/openshell-middleware-init/tests/test_cli.py @@ -0,0 +1,74 @@ +from pathlib import Path + +from typer.testing import CliRunner + +from openshell_middleware_init import cli +from openshell_middleware_init.generator import InitializationError, InitializationResult + +runner = CliRunner() + + +def test_help_describes_required_choices() -> None: + result = runner.invoke(cli.app, ["--help"]) + + assert result.exit_code == 0 + assert "--language" in result.stdout + assert "--openshell-version" in result.stdout + + +def test_cli_reports_success(monkeypatch, tmp_path: Path) -> None: + destination = tmp_path / "audit" + + def fake_initialize_project(**options): + assert options["name"] == "audit" + assert options["language"] == "python" + assert options["destination"] == destination + return InitializationResult( + destination=destination, + language="python", + openshell_version="v0.0.86", + run_command="uv run audit", + ) + + monkeypatch.setattr(cli, "initialize_project", fake_initialize_project) + + result = runner.invoke( + cli.app, + [ + "audit", + "--language", + "python", + "--openshell-version", + "v0.0.86", + "--output", + str(destination), + ], + ) + + assert result.exit_code == 0 + assert "Created python middleware project" in result.stdout + assert "OpenShell contract: v0.0.86" in result.stdout + + +def test_cli_reports_initialization_error(monkeypatch, tmp_path: Path) -> None: + def fake_initialize_project(**options): + del options + raise InitializationError("output exists") + + monkeypatch.setattr(cli, "initialize_project", fake_initialize_project) + + result = runner.invoke( + cli.app, + [ + "audit", + "--language", + "rust", + "--openshell-version", + "v0.0.86", + "--output", + str(tmp_path / "audit"), + ], + ) + + assert result.exit_code == 1 + assert "error: output exists" in result.stderr diff --git a/projects/openshell-middleware-init/tests/test_generator.py b/projects/openshell-middleware-init/tests/test_generator.py new file mode 100644 index 00000000..b956f5ca --- /dev/null +++ b/projects/openshell-middleware-init/tests/test_generator.py @@ -0,0 +1,443 @@ +from __future__ import annotations + +import json +import os +import subprocess +import sys +import urllib.error +from pathlib import Path + +import pytest + +from openshell_middleware_init import generator +from openshell_middleware_init.generator import InitializationError, initialize_project + +PROTO = b"""syntax = "proto3"; +package openshell.middleware.v1; +service SupervisorMiddleware { + rpc EvaluateHttpRequest(HttpRequestEvaluation) returns (HttpRequestResult); +} +message HttpRequestEvaluation {} +message HttpRequestResult {} +""" + + +def local_proto(version: str) -> tuple[bytes, str]: + return PROTO, f"https://example.test/OpenShell/{version}/supervisor_middleware.proto" + + +def no_op_runner(language: str, project: Path, package: str) -> None: + assert language in {"python", "rust"} + assert project.is_dir() + assert package + lock_name = "uv.lock" if language == "python" else "Cargo.lock" + (project / lock_name).write_text("test lock\n") + + +def test_generates_python_project_with_provenance(tmp_path: Path) -> None: + destination = tmp_path / "audit-headers" + + result = initialize_project( + name="audit-headers", + language="python", + requested_version="0.0.86", + destination=destination, + download_proto=local_proto, + command_runner=no_op_runner, + ) + + assert result.destination == destination + assert result.openshell_version == "v0.0.86" + assert result.run_command == "uv run audit-headers" + assert (destination / "src/audit_headers/server.py").is_file() + assert (destination / "tests/test_server.py").is_file() + assert (destination / "proto/supervisor_middleware.proto").read_bytes() == PROTO + assert "__PACKAGE_NAME__" not in (destination / "pyproject.toml").read_text() + manifest = json.loads((destination / "middleware-dev-manifest.json").read_text()) + assert manifest["openshell_version"] == "v0.0.86" + assert manifest["languages"] == ["python"] + assert manifest["python_package"] == "audit_headers" + assert len(manifest["proto_sha256"]) == 64 + assert not (tmp_path / ".audit-headers.openshell-middleware-init.lock").exists() + + +def test_generates_rust_project_with_normalized_crate_name(tmp_path: Path) -> None: + destination = tmp_path / "request.audit" + + result = initialize_project( + name="request.audit", + language="rust", + requested_version="v0.0.86", + destination=destination, + download_proto=local_proto, + command_runner=no_op_runner, + ) + + assert result.run_command == "cargo run -- 127.0.0.1:50051" + assert 'name = "request-audit"' in (destination / "Cargo.toml").read_text() + assert "use request_audit::" in (destination / "src/main.rs").read_text() + manifest = json.loads((destination / "middleware-dev-manifest.json").read_text()) + assert manifest["languages"] == ["rust"] + assert manifest["python_package"] is None + + +def test_python_package_name_can_be_overridden(tmp_path: Path) -> None: + destination = tmp_path / "project" + + initialize_project( + name="project", + language="python", + requested_version="v0.0.86", + destination=destination, + package_name="custom_package", + download_proto=local_proto, + command_runner=no_op_runner, + ) + + assert (destination / "src/custom_package/server.py").is_file() + + +@pytest.mark.parametrize( + ("name", "language", "package_name", "message"), + [ + ("Bad Name", "python", None, "project name"), + ("project", "go", None, "language"), + ("project", "rust", "irrelevant", "only valid"), + ("project", "python", "bad-package", "Python package name"), + ], +) +def test_rejects_invalid_project_choices( + tmp_path: Path, + name: str, + language: str, + package_name: str | None, + message: str, +) -> None: + with pytest.raises(InitializationError, match=message): + initialize_project( + name=name, + language=language, + requested_version="v0.0.86", + destination=tmp_path / "output", + package_name=package_name, + download_proto=local_proto, + command_runner=no_op_runner, + ) + + +@pytest.mark.parametrize("version", ["", "main", "v1", "v1.2", "v1.2.x"]) +def test_rejects_invalid_versions(tmp_path: Path, version: str) -> None: + with pytest.raises(InitializationError, match="invalid OpenShell version"): + initialize_project( + name="project", + language="python", + requested_version=version, + destination=tmp_path / "output", + download_proto=local_proto, + command_runner=no_op_runner, + ) + + +def test_refuses_an_existing_destination(tmp_path: Path) -> None: + destination = tmp_path / "existing" + destination.mkdir() + + with pytest.raises(InitializationError, match="must not already exist"): + initialize_project( + name="existing", + language="python", + requested_version="v0.0.86", + destination=destination, + download_proto=local_proto, + command_runner=no_op_runner, + ) + + +def test_refuses_a_reserved_destination(tmp_path: Path) -> None: + destination = tmp_path / "reserved" + (tmp_path / ".reserved.openshell-middleware-init.lock").mkdir() + + with pytest.raises(InitializationError, match="reserved by another initializer"): + initialize_project( + name="reserved", + language="rust", + requested_version="v0.0.86", + destination=destination, + download_proto=local_proto, + command_runner=no_op_runner, + ) + + +def test_failure_cleans_staging_and_owned_reservation(tmp_path: Path) -> None: + destination = tmp_path / "failing" + + def fail_runner(language: str, project: Path, package: str) -> None: + del language, project, package + raise InitializationError("validation failed") + + with pytest.raises(InitializationError, match="validation failed"): + initialize_project( + name="failing", + language="python", + requested_version="v0.0.86", + destination=destination, + download_proto=local_proto, + command_runner=fail_runner, + ) + + assert not destination.exists() + assert not (tmp_path / ".failing.openshell-middleware-init.lock").exists() + assert not list(tmp_path.glob(".failing.openshell-middleware-init.*")) + + +def test_rejects_an_unexpected_proto(tmp_path: Path) -> None: + def invalid_proto(version: str) -> tuple[bytes, str]: + return b"not a proto", f"https://example.test/{version}" + + with pytest.raises(InitializationError, match="not a supported"): + initialize_project( + name="invalid-proto", + language="rust", + requested_version="v0.0.86", + destination=tmp_path / "invalid-proto", + download_proto=invalid_proto, + command_runner=no_op_runner, + ) + + +def test_missing_required_command_has_actionable_error(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(generator.shutil, "which", lambda command: None) + + with pytest.raises(InitializationError, match="'uv' is required"): + generator._require_command("uv") + + +def test_failed_subprocess_is_translated(tmp_path: Path) -> None: + with pytest.raises(InitializationError, match="validation command failed"): + generator._run( + (sys.executable, "-c", "raise SystemExit(7)"), + cwd=tmp_path, + ) + + +def test_unexpected_subprocess_error_is_wrapped(tmp_path: Path) -> None: + def fail_runner(language: str, project: Path, package: str) -> None: + del language, project, package + raise subprocess.SubprocessError("tool failed") + + with pytest.raises(InitializationError, match="tool failed"): + initialize_project( + name="failure", + language="rust", + requested_version="v0.0.86", + destination=tmp_path / "failure", + download_proto=local_proto, + command_runner=fail_runner, + ) + + +class FakeResponse: + def __init__(self, *, body: bytes = b"", url: str = "") -> None: + self.body = body + self.url = url + + def __enter__(self) -> FakeResponse: + return self + + def __exit__(self, *exception: object) -> None: + del exception + + def read(self) -> bytes: + return self.body + + def geturl(self) -> str: + return self.url + + +def test_latest_version_is_resolved(monkeypatch: pytest.MonkeyPatch) -> None: + response = FakeResponse(url="https://github.com/NVIDIA/OpenShell/releases/tag/v1.2.3") + monkeypatch.setattr(generator.urllib.request, "urlopen", lambda *args, **kwargs: response) + + assert generator._normalize_version("latest") == "v1.2.3" + + +@pytest.mark.parametrize( + ("resolved_url", "message"), + [ + ("https://example.test/releases/tag/v1.2.3", "unexpected latest-release redirect"), + ("https://github.com/NVIDIA/OpenShell/releases/tag/nightly", "unexpected tag"), + ], +) +def test_latest_version_rejects_unexpected_redirects( + monkeypatch: pytest.MonkeyPatch, resolved_url: str, message: str +) -> None: + response = FakeResponse(url=resolved_url) + monkeypatch.setattr(generator.urllib.request, "urlopen", lambda *args, **kwargs: response) + + with pytest.raises(InitializationError, match=message): + generator._resolve_latest_version() + + +def test_latest_version_translates_network_failure(monkeypatch: pytest.MonkeyPatch) -> None: + def fail(*args: object, **kwargs: object) -> None: + del args, kwargs + raise urllib.error.URLError("offline") + + monkeypatch.setattr(generator.urllib.request, "urlopen", fail) + + with pytest.raises(InitializationError, match="could not resolve"): + generator._resolve_latest_version() + + +def test_download_proto_returns_content_and_source(monkeypatch: pytest.MonkeyPatch) -> None: + response = FakeResponse(body=PROTO) + monkeypatch.setattr(generator.urllib.request, "urlopen", lambda *args, **kwargs: response) + + content, source = generator._download_proto("v1.2.3") + + assert content == PROTO + assert source.endswith("/v1.2.3/proto/supervisor_middleware.proto") + + +def test_download_proto_translates_network_failure(monkeypatch: pytest.MonkeyPatch) -> None: + def fail(*args: object, **kwargs: object) -> None: + del args, kwargs + raise urllib.error.URLError("missing") + + monkeypatch.setattr(generator.urllib.request, "urlopen", fail) + + with pytest.raises(InitializationError, match="middleware-capable release"): + generator._download_proto("v1.2.3") + + +def test_lock_verification_detects_loss_and_changed_owner(tmp_path: Path) -> None: + lock = tmp_path / "lock" + + with pytest.raises(InitializationError, match="reservation was lost"): + generator._verify_lock(lock, "mine") + + lock.mkdir() + (lock / "owner").write_text("theirs") + with pytest.raises(InitializationError, match="ownership changed"): + generator._verify_lock(lock, "mine") + + generator._release_lock(lock, "mine") + assert lock.exists() + + +def test_prepare_project_dispatches_by_language( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + calls: list[tuple[str, Path, str | None]] = [] + monkeypatch.setattr( + generator, + "_prepare_python_project", + lambda path, package: calls.append(("python", path, package)), + ) + monkeypatch.setattr( + generator, + "_prepare_rust_project", + lambda path: calls.append(("rust", path, None)), + ) + + generator._prepare_project("python", tmp_path, "my_package") + generator._prepare_project("rust", tmp_path, "ignored") + + assert calls == [ + ("python", tmp_path, "my_package"), + ("rust", tmp_path, None), + ] + + +def test_require_command_returns_resolved_path(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(generator.shutil, "which", lambda command: f"/tools/{command}") + + assert generator._require_command("cargo") == "/tools/cargo" + + +def test_run_passes_environment_to_subprocess(tmp_path: Path) -> None: + environment = os.environ.copy() + environment["MIDDLEWARE_INIT_TEST_VALUE"] = "present" + + generator._run( + ( + sys.executable, + "-c", + "import os; assert os.environ['MIDDLEWARE_INIT_TEST_VALUE'] == 'present'", + ), + cwd=tmp_path, + environment=environment, + ) + + +def test_prepare_python_generates_relative_import_and_smoke_checks( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + bindings = tmp_path / "src" / "audit_headers" / "bindings" + bindings.mkdir(parents=True) + (tmp_path / "proto").mkdir() + (tmp_path / "proto/supervisor_middleware.proto").write_bytes(PROTO) + calls: list[tuple[str, ...]] = [] + + monkeypatch.setattr(generator, "_require_command", lambda command: f"/tools/{command}") + + def fake_run(command, *, cwd, environment=None) -> None: + del cwd, environment + calls.append(tuple(command)) + if "grpc_tools.protoc" in command: + (bindings / "supervisor_middleware_pb2_grpc.py").write_text( + "import supervisor_middleware_pb2 as supervisor__middleware__pb2\n" + ) + + monkeypatch.setattr(generator, "_run", fake_run) + + generator._prepare_python_project(tmp_path, "audit_headers") + + generated = (bindings / "supervisor_middleware_pb2_grpc.py").read_text() + assert generated.startswith("from . import supervisor_middleware_pb2") + assert len(calls) == 3 + assert calls[1][1] == "sync" + + +def test_prepare_python_rejects_unexpected_generated_import( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + bindings = tmp_path / "src" / "audit" / "bindings" + bindings.mkdir(parents=True) + (tmp_path / "proto").mkdir() + (tmp_path / "proto/supervisor_middleware.proto").write_bytes(PROTO) + monkeypatch.setattr(generator, "_require_command", lambda command: f"/tools/{command}") + + def fake_run(command, *, cwd, environment=None) -> None: + del command, cwd, environment + (bindings / "supervisor_middleware_pb2_grpc.py").write_text("unexpected\n") + + monkeypatch.setattr(generator, "_run", fake_run) + + with pytest.raises(InitializationError, match="unexpected import layout"): + generator._prepare_python_project(tmp_path, "audit") + + +def test_prepare_rust_runs_cargo_with_temporary_target( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + observed: dict[str, object] = {} + monkeypatch.setattr(generator, "_require_command", lambda command: f"/tools/{command}") + + def fake_run(command, *, cwd, environment=None) -> None: + observed.update(command=command, cwd=cwd, environment=environment) + + monkeypatch.setattr(generator, "_run", fake_run) + + generator._prepare_rust_project(tmp_path) + + assert observed["command"] == ( + "/tools/cargo", + "check", + "--manifest-path", + str(tmp_path / "Cargo.toml"), + ) + assert observed["cwd"] == tmp_path + environment = observed["environment"] + assert isinstance(environment, dict) + assert "CARGO_TARGET_DIR" in environment diff --git a/projects/openshell-middleware-init/uv.lock b/projects/openshell-middleware-init/uv.lock new file mode 100644 index 00000000..bfd92049 --- /dev/null +++ b/projects/openshell-middleware-init/uv.lock @@ -0,0 +1,402 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.15.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/d0/55fe630f4cf94e3fcba868240fad8c8cdd1f764e2a932f8926347e6ec4cd/coverage-7.15.2.tar.gz", hash = "sha256:3df60dc267f0a2ca23cb7a9ab1109c62b9335ffbf519fcfe167157c28c09b81d", size = 927741, upload-time = "2026-07-15T18:56:19.558Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/03/060ce69008ac97bbc01b1411b3e55b61f6f015659400b46749b662107831/coverage-7.15.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9b5bd92ff1ec22e535eab0de75fa6db021992791f461a2aceb7822c625a1187d", size = 221284, upload-time = "2026-07-15T18:53:29.52Z" }, + { url = "https://files.pythonhosted.org/packages/fc/a3/d936e8b53edd9684100a6aefaf3fcabaa54728fe33324436c8d279c047aa/coverage-7.15.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:44826758cfe73fcd0e6af5deb4ba6d5417cc1d13df3acb35c93484a11160f846", size = 221799, upload-time = "2026-07-15T18:53:31.708Z" }, + { url = "https://files.pythonhosted.org/packages/ae/a3/ca234b06aec7ee28226f11d39a696b4481fe5eddfce8e03bf39979bb8ffb/coverage-7.15.2-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:09f5c6ec5901f667bd97dd140b5b9a2586b10efec66f46fb1e6d8135f8b95bdf", size = 248544, upload-time = "2026-07-15T18:53:33.212Z" }, + { url = "https://files.pythonhosted.org/packages/2b/89/dda79527bb7573ba91828b2fb91b3105d87378d6a2749ca0c0924ce0addd/coverage-7.15.2-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1d16e3a7104ea84f03e614611b3edbf6fb6892554b3ab0fe7fbb3f2b2ef04376", size = 250374, upload-time = "2026-07-15T18:53:34.683Z" }, + { url = "https://files.pythonhosted.org/packages/67/c6/c33755a34572f81f49a8c0cdf6b622f35ccb3238b136e1909daf0cdd4319/coverage-7.15.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d46e62cb35d91e6e2589fda6d28074426b0e276422b5d2ebef2c6b11dc60dbfd", size = 252239, upload-time = "2026-07-15T18:53:36.205Z" }, + { url = "https://files.pythonhosted.org/packages/b9/6f/dc341741b375be53a5baeee5b4bf0f0e525d38caed428f7932d23bb7bcb1/coverage-7.15.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dfd3db045e95960ae3683059571e597fda7cc610106a8916f77c5839048c1deb", size = 254150, upload-time = "2026-07-15T18:53:37.863Z" }, + { url = "https://files.pythonhosted.org/packages/e9/8d/966a18a5b195cb4e77b14c53f5f3dce22b5da05e6de7fafd1e08f2d2067a/coverage-7.15.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:affd532502d34c0472d0cdb181325c89f1d2c44992fef0c17e88e7b1576259a1", size = 249234, upload-time = "2026-07-15T18:53:39.394Z" }, + { url = "https://files.pythonhosted.org/packages/c5/8b/8b2e367496ab48484d48e79984fec76cdc1b7cb5d3a00ee799a5602e3ec9/coverage-7.15.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d17d7512151fedfcc64c1821a8977fc9be0dbf495754669afcab7b57abc98ae9", size = 250276, upload-time = "2026-07-15T18:53:41.027Z" }, + { url = "https://files.pythonhosted.org/packages/63/92/1199318a200eb6c8c6ce0192c892c8710ac791abbe0f35099294620bbfda/coverage-7.15.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e26ff680768b8095e8874aabe0e9d3a47a2a9f176a8340d05f8604c56457c23a", size = 248283, upload-time = "2026-07-15T18:53:42.557Z" }, + { url = "https://files.pythonhosted.org/packages/56/da/be284a55c5619bda891a89c27dfd59324a2c6a14d755cf6aac6960ceebeb/coverage-7.15.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:7e8f27131dc7cd53de2c137dd207b3720919320b3c20d499dc30aa9ee6173287", size = 252093, upload-time = "2026-07-15T18:53:44.271Z" }, + { url = "https://files.pythonhosted.org/packages/d4/53/ee112da833ddd77b73c6d781a98029b45b584b136615b4900ed0569f887e/coverage-7.15.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:728a33676d4c3f0db977990a4bd421dcaa3be3e53b5b6273036fff6666008e89", size = 248552, upload-time = "2026-07-15T18:53:45.7Z" }, + { url = "https://files.pythonhosted.org/packages/82/6a/802cfc802e9113494c80bf3f284cd4d72faeb1f24e244f61046af364f2ca/coverage-7.15.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:29c052f7c83ccfcc5c577eaae025d2e4a9bb80daf03c0ac31c996e83b000ce88", size = 249154, upload-time = "2026-07-15T18:53:47.256Z" }, + { url = "https://files.pythonhosted.org/packages/2c/65/529808e91d651147edae408fd9e894abc3b8cad7f3e594bbc36719a3e13a/coverage-7.15.2-cp310-cp310-win32.whl", hash = "sha256:1268ac8fb9ddcd783d3948dbabaf80a5d53bfdaa0575e873e2139a692f797443", size = 223334, upload-time = "2026-07-15T18:53:48.768Z" }, + { url = "https://files.pythonhosted.org/packages/68/0f/0e1829d7001130876dfbc0b4e1c737ea7c155b809e3e4a98a0aa268e2369/coverage-7.15.2-cp310-cp310-win_amd64.whl", hash = "sha256:9f4432898c4bf2fba0435bbe35dd4437d7264565e5a88a21f5b49d8662a6b629", size = 223959, upload-time = "2026-07-15T18:53:50.429Z" }, + { url = "https://files.pythonhosted.org/packages/7d/3a/54536704f507d4573bf9161c4d0dd3dd59b6d85e48c664e901b6844d8e33/coverage-7.15.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2f1ec6f304b156669cfde653b4e9a953f5de87e247ea02ac599bce0ab2744036", size = 221414, upload-time = "2026-07-15T18:53:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/b6/d9/8ba925d29743e3577b21e4d8c11a702b76bc93c41e7fdfd1177af63d4b8d/coverage-7.15.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d3361879d736f469f45723c11ea1a5bbdaf1f6928f0e632c940378b5aa9b660", size = 221913, upload-time = "2026-07-15T18:53:53.682Z" }, + { url = "https://files.pythonhosted.org/packages/09/54/a855f3aa0187f2b431ade4e4791b77b56282cfb5d201c83ec26a31b5b36a/coverage-7.15.2-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c6a98d698f9e2c8008d0370ec7fc452ebfcc530002ae2d0061170d768b992589", size = 252332, upload-time = "2026-07-15T18:53:55.467Z" }, + { url = "https://files.pythonhosted.org/packages/8e/d3/13ac97b4370640ba3452fc8559b06cc2f479ce3ba4a0b632a73e44c38a7d/coverage-7.15.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d50dd325e18ec25bfcc10cd7f99b04df1ab9ec76b0918c260e60817ad0643dee", size = 254243, upload-time = "2026-07-15T18:53:57.055Z" }, + { url = "https://files.pythonhosted.org/packages/88/83/5eca144942d8d0659d3f55176517f4a59cdc65eefd17146a0770935a3ebd/coverage-7.15.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:67d7602480a47bdf5b675635403625553ebaa70d5a62a657c035149fd401cea0", size = 256352, upload-time = "2026-07-15T18:53:58.83Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ba/d3db2e01a50fc88cdb4c0f19542bcf6f61489e34dc9aa3538413e2459a38/coverage-7.15.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cee0f89f4767a6057c8fbf168f8135f18be651300496086bd873e3189fed0487", size = 258313, upload-time = "2026-07-15T18:54:00.497Z" }, + { url = "https://files.pythonhosted.org/packages/78/b3/aba83416e9177df28e5186d856c19158c59fc0e7e814aaa61a4a2354ad1b/coverage-7.15.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a29ec5305a7335aacee2d799e3422e91e1c8a12474986e2b3b07e315c91be82f", size = 252449, upload-time = "2026-07-15T18:54:02.456Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a5/4b00ecac0194431ab451b0f6710f8e2517d04cef60f821b14dec4637d575/coverage-7.15.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:48ccc6395958eda89093ecdc35644c86f23a8b23a7f4d44958812b721aad67c1", size = 254043, upload-time = "2026-07-15T18:54:04.072Z" }, + { url = "https://files.pythonhosted.org/packages/75/b6/cfa209b4313ee7f1b34da47efcd789ea51c024ad35af390e00f5a3c10a2e/coverage-7.15.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:81f382c5a94b434ec1f6da607edb904c76d7212e618cd4d1bc9f97bed4120ef5", size = 252107, upload-time = "2026-07-15T18:54:06.745Z" }, + { url = "https://files.pythonhosted.org/packages/36/67/e8cac5a6954038c98d7fe7eb9802afe7ab3ecb637bb7cc00e69b4148b56d/coverage-7.15.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bbc808daf4f5cd567af8075ecc72d21c6dfef9a254709a621a84c217c935ebc0", size = 255873, upload-time = "2026-07-15T18:54:08.48Z" }, + { url = "https://files.pythonhosted.org/packages/2c/92/395cca9f330a86c3fe3471d73e2c102116c4c58fdc619dbbc125c6e93a54/coverage-7.15.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a4c46b247b5d4b78f613bd89fea926d32b25c6cc61a50bd1e99ba310348f3dad", size = 251826, upload-time = "2026-07-15T18:54:10.083Z" }, + { url = "https://files.pythonhosted.org/packages/51/60/3e91b20295439652424f426b7086ec5bf4fbe3f604c73eda22b986c4fd6b/coverage-7.15.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:094dd37f3ef7b2da8b068b583d1f4c40f91c65197e16c52a71962d5d537fc5db", size = 252735, upload-time = "2026-07-15T18:54:11.878Z" }, + { url = "https://files.pythonhosted.org/packages/a5/eb/8c07839005e5e3c6b3877d3a6e2a80ce766589f31dd2b6882b78d59a7b8c/coverage-7.15.2-cp311-cp311-win32.whl", hash = "sha256:a63b9e190711134d581c4d703df5df09851b1acf99792c7aacbbe9f41f0283c9", size = 223500, upload-time = "2026-07-15T18:54:13.525Z" }, + { url = "https://files.pythonhosted.org/packages/2e/98/59d83c257cd59f0fbaf9d9ddb26b744a576760dfd1ae16e516408894a02b/coverage-7.15.2-cp311-cp311-win_amd64.whl", hash = "sha256:8bb9f4b4279187560796a4cdaca3b0a93dd97e48ee667df005f4ed9a97403688", size = 223973, upload-time = "2026-07-15T18:54:15.163Z" }, + { url = "https://files.pythonhosted.org/packages/ea/09/2d285c8bef5c4f695d120c1c96dc11715638aa8e134069f210bb6a62a9fe/coverage-7.15.2-cp311-cp311-win_arm64.whl", hash = "sha256:8c726b232659cbd2ae57ade46509eb068c9bd7a06df9fcbff6fe484870006934", size = 223519, upload-time = "2026-07-15T18:54:16.803Z" }, + { url = "https://files.pythonhosted.org/packages/6a/50/eb5bf42e531611a9f8d272556b1ed4de503f84a91413584094487cf69f8f/coverage-7.15.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1adac78e5abc7c5438f7a209c9ca69d06542f0bf481d728b6989ea80b813fdf9", size = 221587, upload-time = "2026-07-15T18:54:18.439Z" }, + { url = "https://files.pythonhosted.org/packages/06/d1/da99af464c335d4e023a6efcd7ec30f63b88a43c93745154ab74ffb31cea/coverage-7.15.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b868acc62aa5de3be7a9d05c2333bf8359ca987e43f9cb30ff8fbda6a024ab73", size = 221943, upload-time = "2026-07-15T18:54:20.062Z" }, + { url = "https://files.pythonhosted.org/packages/5b/8a/13c42723d61ca447eafa18732e8141dd6a63f2732e1c7e1502c182dd88d7/coverage-7.15.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6f6966fc30e6f06ca8f98fb0ce51eda6b111b3ee8d066a8b1ec9e77fa06ab55d", size = 253450, upload-time = "2026-07-15T18:54:21.765Z" }, + { url = "https://files.pythonhosted.org/packages/d7/29/99021303f98fbdcb63504b4d07bea4cc025b9b2dd907c4f07c85d50a0dab/coverage-7.15.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:68af907f595ab01a78f794932ff3bdf929c316d3000810d38dbc247129e26f8b", size = 256187, upload-time = "2026-07-15T18:54:23.4Z" }, + { url = "https://files.pythonhosted.org/packages/f9/a8/fd503715ed6ca9c5d742923aa5209257340b367a867b2ced0c7d4ba8a0b9/coverage-7.15.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:afa29e2eff3d5729267e2cb2fd4ce9d61c952932fb2694e34ccb5d9540c6a296", size = 257301, upload-time = "2026-07-15T18:54:25.183Z" }, + { url = "https://files.pythonhosted.org/packages/da/40/3f4b8fb409810036ebc2857d36adc0498c6e957b5df0290c5036b2e143f1/coverage-7.15.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bbf44513ceb1589e31948e20eafbde9deaface90e1a1afa5f5f77b4423d17ce6", size = 259562, upload-time = "2026-07-15T18:54:27.204Z" }, + { url = "https://files.pythonhosted.org/packages/0b/8a/9bdffbef47db77cce3d6b02a28f7e919b19f0106c4b080c2c2246040f885/coverage-7.15.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9deddf09eecb717b7f980414b43d90a5b22ff3967d2949ab29cb0aa83d9e9098", size = 253841, upload-time = "2026-07-15T18:54:29.134Z" }, + { url = "https://files.pythonhosted.org/packages/1b/1e/9031efde019d31a06646261fce6dfc5c3c74e951e27a71e5c9a424563178/coverage-7.15.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae901f7e55ba405c84ee1cab3d3e962e4e871e4a2bcb9c90911adbd69b42ac5a", size = 255221, upload-time = "2026-07-15T18:54:31.142Z" }, + { url = "https://files.pythonhosted.org/packages/56/db/787acde872389fc84a9ef9d8cd1ccc658e391ab4cb5b28092a714426a394/coverage-7.15.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a0f47002c6eeb7c280228467a4cb0cc15ca2103a8421b986b2d3ec04a0f9bd8b", size = 253366, upload-time = "2026-07-15T18:54:32.886Z" }, + { url = "https://files.pythonhosted.org/packages/2f/9b/6f57bc4b93c842eef1695f8cdaf2318e35e7ba54f5ba80d84be213ab7858/coverage-7.15.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd7a5beb7af3e864a13b1f0fb26efd3695da43ef0daf71e586adfffaf34d5b2", size = 257434, upload-time = "2026-07-15T18:54:34.7Z" }, + { url = "https://files.pythonhosted.org/packages/88/26/b3186a21b2acc83e451118978905c81c7072c3333707804db09a78c096a2/coverage-7.15.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:97a5c5457a9fb1d6c4e06cfb5dc835871fbfb6a6a51addc9e925bdeff5ef7440", size = 252935, upload-time = "2026-07-15T18:54:36.548Z" }, + { url = "https://files.pythonhosted.org/packages/20/c2/c9f3376b2e717ea69ed7a6e9a5fcab968fb0b290db6cf4bd9a1fc7541b75/coverage-7.15.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0901cfe6c13bcd2302da4f83e884555d2a22bda6e4c476f09ef204ba20ca536e", size = 254807, upload-time = "2026-07-15T18:54:38.296Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e1/dfc15401f4a8aaeb486e1ba3e9e3c40522a6e38bd0ecf0b3f29cb8082957/coverage-7.15.2-cp312-cp312-win32.whl", hash = "sha256:b171bdd71cb7ff792bf32e376173b0ace7e7963e7e57c58dfc42063a6a7174cd", size = 223641, upload-time = "2026-07-15T18:54:40.103Z" }, + { url = "https://files.pythonhosted.org/packages/91/40/81b6d809d320cd366ec5bdf8176575e897dcb8efe7fb4b489ef9e93e4d13/coverage-7.15.2-cp312-cp312-win_amd64.whl", hash = "sha256:582edc45c2040543fef83341be23c43024a3ab3ae0c2d8bc498a06282905ad40", size = 224172, upload-time = "2026-07-15T18:54:41.882Z" }, + { url = "https://files.pythonhosted.org/packages/ef/28/9f14ec438149f7de557f45518f09b4a7917b795cc37083aa7db482693f8c/coverage-7.15.2-cp312-cp312-win_arm64.whl", hash = "sha256:a638db90c61cd219aeee65e83a24fdaa57269a741ae0cf773309208ac862cee3", size = 223556, upload-time = "2026-07-15T18:54:43.674Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d5/f8c838e6b7282976f7c918884b792df7a0c42c5bba5d99c60ad2d221d56d/coverage-7.15.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1121caa19159a38b5463eaae4b1e1fde81e525b15ecc5e000cd5b1a108f743a8", size = 221606, upload-time = "2026-07-15T18:54:45.448Z" }, + { url = "https://files.pythonhosted.org/packages/bf/37/97c926376364f66298cc44893b89cdf17b8bc406376497c4061ae4b8a8ff/coverage-7.15.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a300c6934e0989c327b9e8a1e110329da4641149f872bbe9f70168be66da76c1", size = 221982, upload-time = "2026-07-15T18:54:47.341Z" }, + { url = "https://files.pythonhosted.org/packages/b7/30/a36050a6e83c2135ee0776f452ca3948224befc6d7f26acecc082d0c106a/coverage-7.15.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2617f8799d268fabdeef42a7e89ac3a23e1deee9025427db2df970f99a89a578", size = 252972, upload-time = "2026-07-15T18:54:49.2Z" }, + { url = "https://files.pythonhosted.org/packages/31/d3/06b5f1daf95f0f15ab05bd75f26ba5f3c8b33d0bb72f3aaa3cf41d1bad3a/coverage-7.15.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7dc2950a2992cd676d35c20ae63522836deeb034f08874699d14068710af3dc1", size = 255569, upload-time = "2026-07-15T18:54:51.098Z" }, + { url = "https://files.pythonhosted.org/packages/81/1c/9afb3f8de2b8d36960391c48559a2e3ff96594b58099f115921549ea8d0d/coverage-7.15.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e36686f7a442185db2400b3df171aac520869faf9deb59df687d28659eda2a6", size = 256806, upload-time = "2026-07-15T18:54:53.145Z" }, + { url = "https://files.pythonhosted.org/packages/64/d8/b989f96061a5e32d82fddd1b1b9ff48a7c8f8ae7606f0e80fd9de54b1e33/coverage-7.15.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d29ca7bd67af6e12e74632d65f026eabc1364da5c254494cd914446a28a3ef7", size = 258936, upload-time = "2026-07-15T18:54:55.015Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fa/f99771f5110457c7b511c1935ca49ddf288218eaa84322e028b9334146ae/coverage-7.15.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:db9c8438057e5b0f6a22a0af99c0c1d26b57fbbdbd1be5861ddb8f897fcc3a2d", size = 253178, upload-time = "2026-07-15T18:54:57.527Z" }, + { url = "https://files.pythonhosted.org/packages/f6/96/c098a6044d119c751ceede7be91035fa8310170ec24a6523aff72f0a5793/coverage-7.15.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:63022c4c8dec1d0342f05c3ede99842fe3d007689acc45e86f123a1746e4a026", size = 254934, upload-time = "2026-07-15T18:54:59.41Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a2/1457b3a7a50c8d77500103b97a046db863e2f59a1cf6d2f814595f349885/coverage-7.15.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6c0be82b4d4aa5b2704e08518e2252f3e3d110164bcca826816801052e48a7aa", size = 252898, upload-time = "2026-07-15T18:55:01.338Z" }, + { url = "https://files.pythonhosted.org/packages/6c/0e/76958874c471ecfcdde0d2b2747bb2c61bdbf34a40636f4ce9db9923e643/coverage-7.15.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4510fb9cdf6bb02dfa6af0be4a534b8102d086e22e4a33f8836df663da3d660d", size = 257056, upload-time = "2026-07-15T18:55:03.243Z" }, + { url = "https://files.pythonhosted.org/packages/7c/7c/3d7c4e3bf58baa40327dc7edc2272b17cf02299366d52763db1b0ca1556a/coverage-7.15.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:42ec3d989421b174a2ab607c1539f24127ad362757b7f1c0c0d7a2993f7eb37b", size = 252718, upload-time = "2026-07-15T18:55:05.029Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b8/1cecffed9ce14fb25be9ba42d37b6bb61485c9a3ddd43cd3dde36b6087d8/coverage-7.15.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8f91bce78e32343af184c3b7fa28fcf5a9e2641f4b6623d392038f804939188", size = 254490, upload-time = "2026-07-15T18:55:06.889Z" }, + { url = "https://files.pythonhosted.org/packages/6c/2c/42984561bc7f4c045dca67516a0c50ee5ef8d84352dbeb5559dc86c4823e/coverage-7.15.2-cp313-cp313-win32.whl", hash = "sha256:434e68d531858205895eb0d74b73d20b84260de426387d53c422a5acda2cf050", size = 223647, upload-time = "2026-07-15T18:55:08.941Z" }, + { url = "https://files.pythonhosted.org/packages/41/9f/39c7c9245efc583beddf89a87683574e663ed93637f3afb6cd7b88405676/coverage-7.15.2-cp313-cp313-win_amd64.whl", hash = "sha256:26c3b04a6377fd7c09800921fa934e3a17c0020439cd59df73e73ae1d4b6a78c", size = 224190, upload-time = "2026-07-15T18:55:10.789Z" }, + { url = "https://files.pythonhosted.org/packages/c7/de/3a2883cf8a213659280ef4b403059e17a9acaeb7fc7fd4105e1226ff2e6d/coverage-7.15.2-cp313-cp313-win_arm64.whl", hash = "sha256:3ed010aa1b69cda8e827aabfca9866216c980e2dca82ab9a78c5f83689964c8b", size = 223583, upload-time = "2026-07-15T18:55:12.678Z" }, + { url = "https://files.pythonhosted.org/packages/81/5f/aed265fd7a3551a394f36dfe41868aee709b7f95db4052205b4ad1563ac3/coverage-7.15.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:40f633c5c5fc783732f6312280122e859538fa24461235597c13d803ea9a108a", size = 221650, upload-time = "2026-07-15T18:55:14.527Z" }, + { url = "https://files.pythonhosted.org/packages/6b/2c/222ba12a545189017120f8eddfc1a0bd4616b47d5d4a8d99421edb2fe4c6/coverage-7.15.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:075560438765b7a2ef43bf7aa7758661b53d889df47f062a31bda6c1ade553a2", size = 221988, upload-time = "2026-07-15T18:55:16.674Z" }, + { url = "https://files.pythonhosted.org/packages/aa/38/304b5877ab46e6c290b4292cfcf3fe28245f0e5597cad7f6acc91fc7e0a4/coverage-7.15.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:25fd15dd40a0a2c51a500d664ca29053c09c3259d998407bf982b6e114696138", size = 253029, upload-time = "2026-07-15T18:55:18.856Z" }, + { url = "https://files.pythonhosted.org/packages/6c/58/821b533b8db9e44cf1d8a97bd525149ced40dde1d0093da02cb78e715244/coverage-7.15.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9a6367e4aff723e8ee8190836836124284e8fcd4265e307c844010cfa074f3f", size = 255536, upload-time = "2026-07-15T18:55:21.027Z" }, + { url = "https://files.pythonhosted.org/packages/f1/f2/7aa06604c389d32ea7f0a6a988359a7eafc3cd3f8e7bc2e88cd2fdf0b877/coverage-7.15.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9854ca62c152874b2060772503535be2e8f53f70b8aaa7686b094888d872f984", size = 256881, upload-time = "2026-07-15T18:55:23.125Z" }, + { url = "https://files.pythonhosted.org/packages/a2/4f/1ef342339c7916d0096bc5888cc0f653882cc7bc8f897d5cb89143287c9b/coverage-7.15.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:913b6c56e110da40e035bbd168353bf7aaa2544a5eaccea5d98a4629aac156c7", size = 259196, upload-time = "2026-07-15T18:55:25.099Z" }, + { url = "https://files.pythonhosted.org/packages/fe/f4/7ed055d7a9c5ec13b161773a115a5ccc6b0081d568c31fad830806306cc7/coverage-7.15.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aaccad4129d735a8a4d526f26929894c9a4e8ef7034566f210b176749d6906e3", size = 253036, upload-time = "2026-07-15T18:55:27.018Z" }, + { url = "https://files.pythonhosted.org/packages/14/79/ea82cca18c242a3a38b6c017da39726aa62dcb64aa635abf79b92009975c/coverage-7.15.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a164b50081fc7357331c4024ef4d17b78ba325f8380d05f5a69599a7e05257ee", size = 254887, upload-time = "2026-07-15T18:55:29.084Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ba/a136db3c0d9562b00e10b72540dbf3a33cd3bc5b95060c9308e247494623/coverage-7.15.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:bfd341ccf78128e72c094bc70cc25b3ef309c33c7c2c66ba3ed4309549e02de1", size = 252852, upload-time = "2026-07-15T18:55:31.184Z" }, + { url = "https://files.pythonhosted.org/packages/17/17/ea334246b16b7d059953fad6fdefa11e33c68efbd3fe37b1098120a1fac2/coverage-7.15.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1473b3ba8e7ee0f076117b1a72c23f579a2b9e2bb742f48a8d86ea27ca93f91a", size = 257128, upload-time = "2026-07-15T18:55:33.163Z" }, + { url = "https://files.pythonhosted.org/packages/ed/c3/074fb66d46d607855f710876b117cbda562c5ab08363528e78820449f937/coverage-7.15.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:17c432b5f73ad52ef46fb06019f6fa7c66ce381961cf0f7dfd1d3a4bd3a98145", size = 252668, upload-time = "2026-07-15T18:55:35.063Z" }, + { url = "https://files.pythonhosted.org/packages/e1/c1/f620850ada9b36435921c9a3a8057013422b1d964eb4bf37fe138724d192/coverage-7.15.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:77f0ef5011df53a4bd1b35211ab122287f8d9b8d7aa1c4553e5c2deb24b1d446", size = 254325, upload-time = "2026-07-15T18:55:37.125Z" }, + { url = "https://files.pythonhosted.org/packages/cc/31/a729ca3689404493af82ef8e6ff70bd88bdda8da89aeef6ca9b387aeb2b4/coverage-7.15.2-cp314-cp314-win32.whl", hash = "sha256:f653e5d7248c1191ec988a85c72edeab46c3ff44f90639a4ed4874ec0be90243", size = 223844, upload-time = "2026-07-15T18:55:39.078Z" }, + { url = "https://files.pythonhosted.org/packages/c6/83/5d809dc808fb1698c671f3e372259bb9158e64b7ea526fc6ab7de64de9fe/coverage-7.15.2-cp314-cp314-win_amd64.whl", hash = "sha256:9911f31aad8906abe337c271343485cf20df5e70df5d2f57f9f136e7b55f26bc", size = 224331, upload-time = "2026-07-15T18:55:41.346Z" }, + { url = "https://files.pythonhosted.org/packages/16/4e/35e488548e952795829e129995c4174df33bf432b591d1aa42c8d9e4e7ad/coverage-7.15.2-cp314-cp314-win_arm64.whl", hash = "sha256:e38def96ad59853824c97953fdcd2c320a84ba3ce99b417db78af8bb6c3db635", size = 223760, upload-time = "2026-07-15T18:55:43.518Z" }, + { url = "https://files.pythonhosted.org/packages/ed/49/dd2c86cd6374038f6e415fb5bfb86db5218553209c081384a020369dee79/coverage-7.15.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:835ec4e20b45f0a7f63ed78f94065aca00de033403df8377bfe8b9c6abc0a7be", size = 222384, upload-time = "2026-07-15T18:55:45.569Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/173ff17a1c0808e5a438f549f6f145d5ac7528f2791310b63523e3200ac7/coverage-7.15.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7466cc7ab6dc0db871d264bf99e8779f0917ee63d40730af0552f71535a6e072", size = 222647, upload-time = "2026-07-15T18:55:47.544Z" }, + { url = "https://files.pythonhosted.org/packages/84/f8/b8cba872162356fb44ac79c10309d987206a4461e32072fc29228dad7331/coverage-7.15.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e370c12133095ff18432de8c044962be85a5a96d90c6fcbce8e17e76236d2328", size = 264013, upload-time = "2026-07-15T18:55:49.768Z" }, + { url = "https://files.pythonhosted.org/packages/ee/67/a807a7586d0b8cae485308ddd55756f0806c92f8e0b411bacbf23c48edf3/coverage-7.15.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fe41909c9515c3bfdb5f02c4d1f857dba322d9a9a1178069b91eea77889df63a", size = 266135, upload-time = "2026-07-15T18:55:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/ce/67/cd78771dc985f7e4ebdcc82b1a96d9a932af9e806f01f2f91a89f4c72e80/coverage-7.15.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6aa28cfb6488e5453b5b762d65f73aa586380f6693a04d58078ce228a29b06c0", size = 268555, upload-time = "2026-07-15T18:55:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/18/3e/10134cf81275188c58568f324fc74aedff32c63ca4d5bbc513a91944a6f0/coverage-7.15.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcc0aae933921d03096f53b0b03eeb702129fd406dee59f08d2efacc68681fa5", size = 269674, upload-time = "2026-07-15T18:55:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/75/4a/771b77de446cba985dc414bbc5844bd21604da05dbc044286df8318a48a7/coverage-7.15.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7c63387e21ab21f512c69c9756a8c7dadd322c7275edb064064433c9a09c3743", size = 263101, upload-time = "2026-07-15T18:55:58.107Z" }, + { url = "https://files.pythonhosted.org/packages/5f/b5/70a7011da15f4071943361183aefa27847f3e3aec4fd335f1cb3d3a622b1/coverage-7.15.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e55510bc98ae943cece9e667a6c0fe94c6a92913720dea34243657a17993d0c", size = 266007, upload-time = "2026-07-15T18:56:00.468Z" }, + { url = "https://files.pythonhosted.org/packages/b4/0d/f9547e804ce7ad49646ffeffac26699510efbe6c0f751b66fdc960c4e825/coverage-7.15.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2ff08701be2d1556fc78b326c80a3e8042da09352ecb3819105f8e386c8a3071", size = 263611, upload-time = "2026-07-15T18:56:02.615Z" }, + { url = "https://files.pythonhosted.org/packages/ac/59/f576a396659c0efd351f5c1544f67c3560e89c7761cabf7f65e412beeda5/coverage-7.15.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:38c9518b7103826c403a461544e3c2e77151e8676d06eaed85911a97e962584a", size = 267344, upload-time = "2026-07-15T18:56:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/7c/5d/c2e4fce3579c0cb635024293f1a32bbe26df101b3e3a69f22243d1352b6c/coverage-7.15.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:dee88b1ed88587abd8c0269a1fc1f4cc77f7750d1dfde2869e2a123af420e67d", size = 262456, upload-time = "2026-07-15T18:56:06.641Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/956287d69436b66094bc4b57ac2da71e43bfd2a5524e958900b9f582fcf8/coverage-7.15.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fbeeeecea279727f8ac16c8e1133ddfeee793e985c86ae343d6a5ce744eef8c", size = 264771, upload-time = "2026-07-15T18:56:08.795Z" }, + { url = "https://files.pythonhosted.org/packages/2c/5a/6f979530c2734c575de77cf58f5f28d51f7123a94b5030fd9156fe5f363c/coverage-7.15.2-cp314-cp314t-win32.whl", hash = "sha256:cb0fddaa6884be6aae36ced9544b5e90f7d5f03845a2853bf47a14953a4e8688", size = 224151, upload-time = "2026-07-15T18:56:10.856Z" }, + { url = "https://files.pythonhosted.org/packages/54/7e/27f6b2a74d484742f4017553e710b01e396b23d809df3e95ca0bb9a2824b/coverage-7.15.2-cp314-cp314t-win_amd64.whl", hash = "sha256:77f091ea3a9cc611cd29f433565476bc1936c084ac8eee00ea0e7e70c27e4199", size = 224981, upload-time = "2026-07-15T18:56:12.928Z" }, + { url = "https://files.pythonhosted.org/packages/b1/48/284863423aa474240f6842bd00d680da22f4e6ea2e466618ef7c9c9e69a9/coverage-7.15.2-cp314-cp314t-win_arm64.whl", hash = "sha256:6fc448c377d6eeb00a47c673494bd9bae29280ca53987e1869e67ebedfe20658", size = 224294, upload-time = "2026-07-15T18:56:15.156Z" }, + { url = "https://files.pythonhosted.org/packages/ec/82/32e3bd191d498e64f6f911ad55d14006a0861e54869d2d32452326399e65/coverage-7.15.2-py3-none-any.whl", hash = "sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c", size = 213375, upload-time = "2026-07-15T18:56:17.305Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "openshell-middleware-init" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "typer" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ruff" }, + { name = "ty" }, +] + +[package.metadata] +requires-dist = [{ name = "typer", specifier = ">=0.16,<1" }] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8,<10" }, + { name = "pytest-cov", specifier = ">=5,<8" }, + { name = "ruff", specifier = ">=0.12,<1" }, + { name = "ty", specifier = ">=0.0.53" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/06/ae069393fc66e8ff33036d4b368003833bf6e88ccf182e17e7a2f1c754fd/ruff-0.15.22.tar.gz", hash = "sha256:3f15175b1fb580126f58285a5dae6b2ea89000136d980c64499211f116b54809", size = 4785063, upload-time = "2026-07-16T15:14:13.244Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/18/ee54b7ae1e121be7a28ea6da4b67564ebb0530e183a54415ab7e3bcd2c4e/ruff-0.15.22-py3-none-linux_armv6l.whl", hash = "sha256:44423e73493737f5e7c5b41d475483898ff37afcdae38bc3da5085e29af1c2d8", size = 10781258, upload-time = "2026-07-16T15:13:19.452Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d2/2520cb14761ddbeaf57642a76942fc36adcbdbe53b4532241995f6fc485c/ruff-0.15.22-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b82c6482946e9eda7ff2e091d25b8bad3f718684e1916d41bd56873cee05b697", size = 10999477, upload-time = "2026-07-16T15:13:23.318Z" }, + { url = "https://files.pythonhosted.org/packages/c9/10/74e53572aa758dfaa678c2a2646b5c5515d884b7ca56be4d2ce03ca4b560/ruff-0.15.22-py3-none-macosx_11_0_arm64.whl", hash = "sha256:11c1c715af53a09f714e011106bffc419751ec8232fcb5da42173284ea3fec6f", size = 10466716, upload-time = "2026-07-16T15:13:26.162Z" }, + { url = "https://files.pythonhosted.org/packages/1e/cc/44eaaf0844e028182f2d0a8f2190d0f359159aed0a9e5ab861d892f1ae2a/ruff-0.15.22-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:742a29cf29bddb7c8327895d6a10e0e6c5b38a96dd407af9b5d0857f809c0576", size = 10892644, upload-time = "2026-07-16T15:13:29.229Z" }, + { url = "https://files.pythonhosted.org/packages/9f/21/8edf559014d2b0f82beea19cfb713993ad802ccda16868769979c6090a84/ruff-0.15.22-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72af58b951b0ae395935ae79763dc349bc0eb706319d28f7a33ad2cfb3cfc178", size = 10576719, upload-time = "2026-07-16T15:13:32.35Z" }, + { url = "https://files.pythonhosted.org/packages/bf/1e/3a13abd392a3b50b62e5938a831f9ab6e588358cacad5c18545b716d2182/ruff-0.15.22-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62d425005c1835eb24e2ee4161cb90e8db263415f4a71c8c72c33abaa6c0c224", size = 11376494, upload-time = "2026-07-16T15:13:35.958Z" }, + { url = "https://files.pythonhosted.org/packages/bf/3e/422d3d95bcf04dd78e1aeac22184d4f9a8fb2c01865d39d44618484a0317/ruff-0.15.22-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8b9b3f8779a4f08c969defc3c8c35abffaa757e601ed5ae66d6d1db6519969a", size = 12208370, upload-time = "2026-07-16T15:13:39.185Z" }, + { url = "https://files.pythonhosted.org/packages/1e/91/5d065a0e0a02bf4813f5119ad278462eed081d2b832eb7c021ade0ec9e65/ruff-0.15.22-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1e0dd1b2e4d3d585f897a0d137cbf4eaf6223bef4e8ce34d6bb12556c5f9249e", size = 11581098, upload-time = "2026-07-16T15:13:42.132Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f9/a0d4871d12fae702eb1f41b686caf05f1f8b124dc6db6f784f53d74918fa/ruff-0.15.22-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:365523eb91d9224e1bcb03b022fbf0facb8f9e23792a2c53d9d4b3924bdbdebb", size = 11399422, upload-time = "2026-07-16T15:13:45.2Z" }, + { url = "https://files.pythonhosted.org/packages/18/80/c843a5176cddbceb0b7e8dd41cf9993490796c1c469348d384f5a5c13c56/ruff-0.15.22-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:fabfd168afdf29fee5be98b831efa9683c94d7c5a3b58b9ce5a2e38444589a74", size = 11381683, upload-time = "2026-07-16T15:13:48.46Z" }, + { url = "https://files.pythonhosted.org/packages/d4/00/8485de0ae92239438a36cfc51350db9b9e85c9ebdfaea91b18e422706662/ruff-0.15.22-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:225dbf095a87f1d9f90f5fd7924d2613ee452a75a4308c63a8f50f761787aa7c", size = 10850295, upload-time = "2026-07-16T15:13:51.655Z" }, + { url = "https://files.pythonhosted.org/packages/fa/91/24977ec2ec72eaf15e4394ace2959fdff2dd1e14f03e005e838023407169/ruff-0.15.22-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1877d63b9d24ed278744f1523fd11b85540566d54641f97c566d7d9dc5ca5296", size = 10579640, upload-time = "2026-07-16T15:13:54.79Z" }, + { url = "https://files.pythonhosted.org/packages/9c/47/9b51216951974df1f263ac19da550d34252e0ed7218c25f10c5ef9ed7517/ruff-0.15.22-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a1606c510bd7215680d32efab38965f7cdec3ef69f5170a3f4791404ffdd5262", size = 11105077, upload-time = "2026-07-16T15:13:57.915Z" }, + { url = "https://files.pythonhosted.org/packages/c2/47/20e9d4a3b8016778acea5fc32bb50d35d207500a17ddb529ffa6996feef8/ruff-0.15.22-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:630479b18625f5ffc373f77603a22a9f8ac0acd7ff0501178b5db28ec71e9c64", size = 11490980, upload-time = "2026-07-16T15:14:01.032Z" }, + { url = "https://files.pythonhosted.org/packages/4d/76/3f72d8fc38c1cb77b38c56a70da9d0c17700cc1cc50f9649c9d3c8f5ba71/ruff-0.15.22-py3-none-win32.whl", hash = "sha256:e5ba0e4a13fd14abbed2a77b517a3911290c6c6c59ef67784328d1668fab76cf", size = 10789165, upload-time = "2026-07-16T15:14:04.16Z" }, + { url = "https://files.pythonhosted.org/packages/cb/46/4965251734c2b6fcdca1b1b187d20bcac3af0ee5b083b89c910bb961ce3a/ruff-0.15.22-py3-none-win_amd64.whl", hash = "sha256:9be63ba1eb936acd2d1342fb8337c356353706fce233b2a15a09a97037e6acde", size = 11938297, upload-time = "2026-07-16T15:14:07.316Z" }, + { url = "https://files.pythonhosted.org/packages/57/c9/e69b1ff4c8b69093ef08b8919ab767af0569666865b39c30a8795d88d3c6/ruff-0.15.22-py3-none-win_arm64.whl", hash = "sha256:e1168075b72158510839f250027659cdd78476f40507dd517892304c41318661", size = 11298172, upload-time = "2026-07-16T15:14:10.51Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "ty" +version = "0.0.62" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/2c/ecf9d6a7456e64ea9114d21e68edaea77ecda9151cc89dfd3ed0b673e4a9/ty-0.0.62.tar.gz", hash = "sha256:145b6feba4d5f38b6d595eb41f7a8ec1c970a0a83b79a70680e9e3b787a3e381", size = 6271717, upload-time = "2026-07-22T01:02:43.732Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/ab/cb629d65b4437b298214a095488a8d84ec349f225ed84da13763adfce08b/ty-0.0.62-py3-none-linux_armv6l.whl", hash = "sha256:3db167f0aef29e7c34d47414c9ce160fd045e9b2d6a0edd08d0331b4eaa3b9ad", size = 12045218, upload-time = "2026-07-22T01:02:04.497Z" }, + { url = "https://files.pythonhosted.org/packages/b7/c9/f6555aa3b0c46bf2a38891a2048c45616e836a6aab96dff847ab01cc83b3/ty-0.0.62-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:aab3f1d6ca4c9ad1869e57164478694d9828ad1dfcadc192ece7215a20859197", size = 11688585, upload-time = "2026-07-22T01:02:06.795Z" }, + { url = "https://files.pythonhosted.org/packages/56/25/9f4a322c64ac8a4f2d1593749bb5932c5c964e557fe7788116fa029b2463/ty-0.0.62-py3-none-macosx_11_0_arm64.whl", hash = "sha256:69ce10b241f487b69ccdc36ca4ac4f4ba683172d24594b5d7296a66f7e26d15c", size = 11204179, upload-time = "2026-07-22T01:02:09.109Z" }, + { url = "https://files.pythonhosted.org/packages/ef/db/d9cf7d5343ff896c2d780caec3dcd0ee77493d8a4140b8aa49e15824abff/ty-0.0.62-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fd736420acb009d00a2379e47be4085dd3e3cdbe5305bdf5acce0dd9082f1bbb", size = 11745874, upload-time = "2026-07-22T01:02:11.507Z" }, + { url = "https://files.pythonhosted.org/packages/4b/23/a575008c2ccdaacaf1c358be52087db449ea73a78c91c8afb30084d64cfc/ty-0.0.62-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bca9a80e2e5fb72e88e31cf662aae3bd8d7e9e0005ab517a89c368267c992387", size = 11875688, upload-time = "2026-07-22T01:02:14.072Z" }, + { url = "https://files.pythonhosted.org/packages/df/92/43a6250b52a704169b6d864665f286f71588d95ff645941cc0e4ca026ab7/ty-0.0.62-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9f8d2a35940bbdea1d28c36c83d3eaba08bf47990486a066b9cb3ae4fbb8625e", size = 12484062, upload-time = "2026-07-22T01:02:16.081Z" }, + { url = "https://files.pythonhosted.org/packages/92/34/35c698baa067a2543a32cd7237ad9bdc63dec1ce8b5b62b4686b4485fec4/ty-0.0.62-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f8257481a517f05e0621ef3b4ef4089a38ac03f17aa800c9f672df19fd03b3d", size = 13007869, upload-time = "2026-07-22T01:02:18.145Z" }, + { url = "https://files.pythonhosted.org/packages/2a/2c/e9dd785d62bcfeb9285cc030495288640f1942bf3228a791a3a30d72f7b4/ty-0.0.62-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0b907322a2cecd3e25902878997d08de57cf0efbb27833f8b3378fbd4f218590", size = 12675651, upload-time = "2026-07-22T01:02:20.299Z" }, + { url = "https://files.pythonhosted.org/packages/36/86/5650d8b70bd80946afdb3aaefedd280a698b82d9066f18f8a47f58642230/ty-0.0.62-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac8b70f0b5494feed0a3cf47af579141e4c2e88faffcaeebcb4176e914b54b6a", size = 12324208, upload-time = "2026-07-22T01:02:22.671Z" }, + { url = "https://files.pythonhosted.org/packages/38/2d/278a17a0f47a90804a744e7d7a598c47fbc15a357ad5aaff563fee3bc65a/ty-0.0.62-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:059790546f9304a750f28ded9b964a7a27dec88e725d329a4b9ab5d944e83dd6", size = 12616124, upload-time = "2026-07-22T01:02:24.8Z" }, + { url = "https://files.pythonhosted.org/packages/1a/51/62741847bd646f8702b9ce61de496804a8ac52055afab8dbb94aaf64f431/ty-0.0.62-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c9e83bc6d59e2b37a1d8923ffb7e2455fd35ad35821a3dc69c8459ca9cd28724", size = 11695150, upload-time = "2026-07-22T01:02:27.17Z" }, + { url = "https://files.pythonhosted.org/packages/0c/0d/74bd23f94a527bdc32d8366f51db157f75a3074e320c567a2eae83bd6d21/ty-0.0.62-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f84ac4b606c3b4275a17f7b2f60cb0ca8216972bb7af2f515383ee6e4b1fb7f1", size = 11884562, upload-time = "2026-07-22T01:02:29.09Z" }, + { url = "https://files.pythonhosted.org/packages/dd/0e/2f42cc81436b5dc0070b4e353c74cbe94ce91ef5828753db921088dec500/ty-0.0.62-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1c05f8f9cd720fa42744574bf3bda9b1b676f7876e33324b03b21aad81f651df", size = 12071773, upload-time = "2026-07-22T01:02:31.292Z" }, + { url = "https://files.pythonhosted.org/packages/42/24/0bd3c0069524bf7d5e610d92dd0698414372015461507280c8787c9cd25b/ty-0.0.62-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:88bef93008228890e990fe9e87f0504b8dea9f9f24534eb3a5ee9d8ab9e5cd4f", size = 12425247, upload-time = "2026-07-22T01:02:34.474Z" }, + { url = "https://files.pythonhosted.org/packages/3f/27/08505fa0d2bef8fe4107753bb02ed2da4e323103ff5d423b9cc9eea4346e/ty-0.0.62-py3-none-win32.whl", hash = "sha256:254690c758e9d0ee2c8dfe07320d4e9d54709d5e95fa588ecf5a96391dc8d8d4", size = 11381070, upload-time = "2026-07-22T01:02:36.625Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c1/609840f13d1c48e88e5b431fe789650da14cb25da0df2979fe08ce422e73/ty-0.0.62-py3-none-win_amd64.whl", hash = "sha256:d88c2594e6a33c5f859c1d9016ad6137ec304c5d51422ce48894d2b9393956a7", size = 12415015, upload-time = "2026-07-22T01:02:39.193Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a8/43f838fa38922f175a223814120b5faeb1bb6e5ef1533e45f543c0f510b8/ty-0.0.62-py3-none-win_arm64.whl", hash = "sha256:11b5e7df21890bef3eda49ae15a0c4d12554917ec4ea90ea985caa33241af627", size = 11773787, upload-time = "2026-07-22T01:02:41.564Z" }, +] + +[[package]] +name = "typer" +version = "0.27.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/37/78/fda3361b56efc27944f24225f6ecd13d96d6fcfe37bd0eb34e2f4c63f9fc/typer-0.27.0.tar.gz", hash = "sha256:629bd12ea5d13a17148125d9a264f949eb171fb3f120f9b04d85873cab054fa5", size = 203430, upload-time = "2026-07-15T19:21:07.007Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/03/26a383c9e58c213199d1aad1c3d353cfc22d4444ec6d2c0bf8ad02523843/typer-0.27.0-py3-none-any.whl", hash = "sha256:6f4b27631e47f077871b7dc30e933ec0131c1390fbe0e387ea5574b5bac9ccf1", size = 122716, upload-time = "2026-07-15T19:21:05.553Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] From 2b2f7b6e5613f745350af775c95008fbac9adf01 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 22 Jul 2026 15:11:15 +0000 Subject: [PATCH 02/15] Address middleware initializer review findings --- projects/openshell-middleware-init/README.md | 21 ++ .../openshell_middleware_init/generator.py | 182 ++++++++++++++--- .../templates/python/README.md | 16 +- .../templates/python/src/package/server.py | 18 +- .../templates/python/tests/test_server.py | 41 ++++ .../templates/rust/Cargo.toml | 1 + .../templates/rust/README.md | 16 +- .../templates/rust/src/lib.rs | 54 ++++- .../templates/rust/src/main.rs | 6 +- .../tests/test_generator.py | 186 +++++++++++++++++- 10 files changed, 490 insertions(+), 51 deletions(-) diff --git a/projects/openshell-middleware-init/README.md b/projects/openshell-middleware-init/README.md index a94ed317..0bd639aa 100644 --- a/projects/openshell-middleware-init/README.md +++ b/projects/openshell-middleware-init/README.md @@ -75,6 +75,27 @@ Unlike the original `middleware_dev_setup` spike, this project initializer does not install or replace OpenShell. Install the desired OpenShell release through its official installer separately. +### Recover a stale reservation + +A process killed without cleanup can leave +`..openshell-middleware-init.lock` and its hidden staging directory. The +initializer deliberately leaves ambiguous state in place instead of guessing +that it is stale. + +1. Read the reservation's `metadata.json`. It records the hostname, PID, start + time, target version, final output, and staging output. +2. On the recorded host, confirm that the PID is no longer an + `openshell-middleware-init` process. Account for PID reuse by comparing the + process start time and command. Confirm that the final output still does not + exist. +3. Inspect the recorded staging directory and preserve anything needed for + diagnosis. Remove it only after confirming the initializer is no longer + active. +4. Remove only `owner` and `metadata.json` from the reservation, then remove the + empty reservation directory with `rmdir`. If it contains any other entry, + stop and investigate rather than deleting recursively. +5. Run the initializer again. + ## Requirements - All generation: network access to GitHub and the selected OpenShell release. diff --git a/projects/openshell-middleware-init/src/openshell_middleware_init/generator.py b/projects/openshell-middleware-init/src/openshell_middleware_init/generator.py index 3a1f663a..82358bc3 100644 --- a/projects/openshell-middleware-init/src/openshell_middleware_init/generator.py +++ b/projects/openshell-middleware-init/src/openshell_middleware_init/generator.py @@ -2,18 +2,25 @@ from __future__ import annotations +import ctypes +import errno import hashlib import json import os import re import secrets import shutil +import socket +import stat import subprocess +import sys import tempfile import urllib.error import urllib.request from collections.abc import Callable, Mapping, Sequence +from contextlib import suppress from dataclasses import dataclass +from datetime import datetime, timezone from importlib.resources import files from pathlib import Path @@ -42,6 +49,19 @@ class InitializationResult: run_command: str +@dataclass(frozen=True) +class OutputReservation: + """Identity and recovery data for an output-path reservation.""" + + path: Path + token: str + device: int + inode: int + destination: Path + version: str + started_at: str + + @dataclass(frozen=True) class TemplateContext: """Normalized names used while rendering a project.""" @@ -88,7 +108,7 @@ def initialize_project( destination.parent.mkdir(parents=True, exist_ok=True) lock_path = destination.parent / f".{destination.name}.openshell-middleware-init.lock" lock_token = secrets.token_hex(16) - _acquire_lock(lock_path, lock_token, destination, version) + reservation = _acquire_lock(lock_path, lock_token, destination, version) staging_path: Path | None = None try: _validate_destination(destination) @@ -98,6 +118,7 @@ def initialize_project( dir=destination.parent, ) ) + _write_reservation_metadata(reservation, staging_path) proto, proto_url = downloader(version) _validate_proto(proto, version) _render_project(staging_path, language, context) @@ -113,9 +134,8 @@ def initialize_project( python_package=context.package_name if language == "python" else None, ) runner(language, staging_path, context.package_name) - _verify_lock(lock_path, lock_token) - _validate_destination(destination) - staging_path.replace(destination) + _verify_lock(reservation) + _publish_no_replace(staging_path, destination) staging_path = None except InitializationError: raise @@ -124,7 +144,7 @@ def initialize_project( finally: if staging_path is not None: shutil.rmtree(staging_path, ignore_errors=True) - _release_lock(lock_path, lock_token) + _release_lock(reservation) return InitializationResult( destination=destination, @@ -133,7 +153,7 @@ def initialize_project( run_command=( f"uv run {context.distribution_name}" if language == "python" - else "cargo run -- 127.0.0.1:50051" + else "cargo run -- 0.0.0.0:50051" ), ) @@ -235,48 +255,156 @@ def _validate_destination(destination: Path) -> None: raise InitializationError(f"invalid output path: {destination}") -def _acquire_lock(lock_path: Path, token: str, destination: Path, version: str) -> None: +def _acquire_lock( + lock_path: Path, token: str, destination: Path, version: str +) -> OutputReservation: try: - lock_path.mkdir() + lock_path.mkdir(mode=0o700) except FileExistsError as error: raise InitializationError( f"output path is reserved by another initializer: {destination}; " - f"inspect {lock_path} before removing a stale reservation" + f"inspect {lock_path / 'metadata.json'} and follow the stale-reservation " + "recovery steps in the openshell-middleware-init README" ) from error + lock_stat = lock_path.stat(follow_symlinks=False) + reservation = OutputReservation( + path=lock_path, + token=token, + device=lock_stat.st_dev, + inode=lock_stat.st_ino, + destination=destination, + version=version, + started_at=datetime.now(timezone.utc).isoformat(), + ) try: (lock_path / "owner").write_text(token) - (lock_path / "metadata.json").write_text( - json.dumps( - { - "pid": os.getpid(), - "target_version": version, - "final_output": str(destination), - }, - indent=2, - ) - + "\n" - ) + _write_reservation_metadata(reservation, None) except OSError: - shutil.rmtree(lock_path, ignore_errors=True) + _remove_reservation_files(lock_path) raise + return reservation + + +def _write_reservation_metadata(reservation: OutputReservation, staging_path: Path | None) -> None: + (reservation.path / "metadata.json").write_text( + json.dumps( + { + "pid": os.getpid(), + "host": socket.gethostname(), + "started_at": reservation.started_at, + "target_version": reservation.version, + "final_output": str(reservation.destination), + "staging_output": str(staging_path) if staging_path is not None else None, + }, + indent=2, + ) + + "\n" + ) -def _verify_lock(lock_path: Path, token: str) -> None: +def _verify_lock(reservation: OutputReservation) -> None: try: - recorded = (lock_path / "owner").read_text() + lock_stat = reservation.path.stat(follow_symlinks=False) + if ( + not stat.S_ISDIR(lock_stat.st_mode) + or lock_stat.st_dev != reservation.device + or lock_stat.st_ino != reservation.inode + ): + raise OSError("reservation identity changed") + flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) + descriptor = os.open(reservation.path / "owner", flags) + with os.fdopen(descriptor) as owner: + owner_stat = os.fstat(owner.fileno()) + if not stat.S_ISREG(owner_stat.st_mode): + raise OSError("reservation owner is not a regular file") + recorded = owner.read() except OSError as error: raise InitializationError("output reservation was lost; refusing to publish") from error - if recorded != token: + if recorded != reservation.token: raise InitializationError("output reservation ownership changed; refusing to publish") -def _release_lock(lock_path: Path, token: str) -> None: +def _remove_reservation_files(lock_path: Path) -> None: + known_names = {"owner", "metadata.json"} try: - if (lock_path / "owner").read_text() != token: + lock_stat = lock_path.stat(follow_symlinks=False) + if not stat.S_ISDIR(lock_stat.st_mode): + return + if {entry.name for entry in lock_path.iterdir()} - known_names: return except OSError: return - shutil.rmtree(lock_path, ignore_errors=True) + for name in known_names: + try: + (lock_path / name).unlink() + except FileNotFoundError: + pass + except OSError: + return + with suppress(OSError): + lock_path.rmdir() + + +def _release_lock(reservation: OutputReservation) -> None: + try: + _verify_lock(reservation) + except InitializationError: + return + _remove_reservation_files(reservation.path) + + +def _publish_no_replace(source: Path, destination: Path) -> None: + """Atomically publish ``source`` without replacing any destination entry.""" + source_bytes = os.fsencode(source) + destination_bytes = os.fsencode(destination) + if sys.platform.startswith("linux"): + library = ctypes.CDLL(None, use_errno=True) + try: + rename = library.renameat2 + except AttributeError as error: # pragma: no cover - old Linux libc + raise InitializationError( + "this Linux runtime cannot publish atomically without replacing an output" + ) from error + rename.argtypes = ( + ctypes.c_int, + ctypes.c_char_p, + ctypes.c_int, + ctypes.c_char_p, + ctypes.c_uint, + ) + rename.restype = ctypes.c_int + result = rename(-100, source_bytes, -100, destination_bytes, 1) + elif sys.platform == "darwin": # pragma: no cover - platform-specific + library = ctypes.CDLL(None, use_errno=True) + rename = library.renamex_np + rename.argtypes = (ctypes.c_char_p, ctypes.c_char_p, ctypes.c_uint) + rename.restype = ctypes.c_int + result = rename(source_bytes, destination_bytes, 0x00000004) + elif os.name == "nt": # pragma: no cover - platform-specific + try: + source.rename(destination) + except FileExistsError as error: + raise InitializationError( + f"output path appeared during setup; refusing to overwrite it: {destination}" + ) from error + return + else: # pragma: no cover - unsupported platform + raise InitializationError( + "this platform cannot publish atomically without replacing an output" + ) + + if result == 0: + return + error_number = ctypes.get_errno() + if error_number in {errno.EEXIST, errno.ENOTEMPTY}: + raise InitializationError( + f"output path appeared during setup; refusing to overwrite it: {destination}" + ) + if error_number in {errno.EINVAL, errno.ENOSYS, errno.EOPNOTSUPP}: + raise InitializationError( + "the output filesystem does not support atomic no-replace publication" + ) + raise OSError(error_number, os.strerror(error_number), destination) def _render_project(destination: Path, language: str, context: TemplateContext) -> None: diff --git a/projects/openshell-middleware-init/src/openshell_middleware_init/templates/python/README.md b/projects/openshell-middleware-init/src/openshell_middleware_init/templates/python/README.md index 9195f960..b275c513 100644 --- a/projects/openshell-middleware-init/src/openshell_middleware_init/templates/python/README.md +++ b/projects/openshell-middleware-init/src/openshell_middleware_init/templates/python/README.md @@ -17,10 +17,11 @@ uv run pytest uv build ``` -Start the middleware on loopback: +Start the middleware on all host interfaces so containerized supervisors can +reach it: ```sh -uv run __DISTRIBUTION_NAME__ --listen 127.0.0.1:50051 +uv run __DISTRIBUTION_NAME__ --listen 0.0.0.0:50051 ``` The server implementation is in `src/__PACKAGE_NAME__/server.py`. Extend @@ -35,14 +36,17 @@ Register the running service in the gateway configuration: ```toml [[openshell.supervisor.middleware]] name = "__SERVICE_NAME__" -grpc_endpoint = "http://127.0.0.1:50051" +grpc_endpoint = "http://:50051" max_body_bytes = 4194304 timeout = "500ms" ``` -Then reference `__SERVICE_NAME__` from a sandbox policy's middleware stage. -Review the supervisor middleware documentation for the policy syntax supported -by your pinned OpenShell release. +Replace `` with a host IP or DNS name reachable from +both the gateway and sandbox supervisors; loopback works only when every process +shares the middleware's network namespace. The development server is insecure, +so restrict port exposure to trusted networks. Then reference `__SERVICE_NAME__` +from a sandbox policy's middleware stage. Review the supervisor middleware +documentation for the policy syntax supported by your pinned OpenShell release. ## Version-matched generated files diff --git a/projects/openshell-middleware-init/src/openshell_middleware_init/templates/python/src/package/server.py b/projects/openshell-middleware-init/src/openshell_middleware_init/templates/python/src/package/server.py index bf514f3b..01b9e437 100644 --- a/projects/openshell-middleware-init/src/openshell_middleware_init/templates/python/src/package/server.py +++ b/projects/openshell-middleware-init/src/openshell_middleware_init/templates/python/src/package/server.py @@ -14,6 +14,7 @@ SERVICE_NAME = "__SERVICE_NAME__" SERVICE_VERSION = "0.1.0" MAX_BODY_BYTES = 4 * 1024 * 1024 +MAX_MESSAGE_BYTES = MAX_BODY_BYTES + 1024 * 1024 def build_manifest() -> pb2.MiddlewareManifest: @@ -76,10 +77,21 @@ async def EvaluateHttpRequest( return evaluate_http_request(request) +def create_server() -> grpc.aio.Server: + """Create an unstarted server that accepts a maximum-sized body envelope.""" + server = grpc.aio.server( + options=( + ("grpc.max_receive_message_length", MAX_MESSAGE_BYTES), + ("grpc.max_send_message_length", MAX_MESSAGE_BYTES), + ) + ) + pb2_grpc.add_SupervisorMiddlewareServicer_to_server(Middleware(), server) + return server + + async def serve(listen: str) -> None: """Serve the middleware until termination.""" - server = grpc.aio.server() - pb2_grpc.add_SupervisorMiddlewareServicer_to_server(Middleware(), server) + server = create_server() if server.add_insecure_port(listen) == 0: raise RuntimeError(f"could not bind middleware server to {listen}") await server.start() @@ -92,7 +104,7 @@ async def serve(listen: str) -> None: def main(argv: Sequence[str] | None = None) -> None: """Run the middleware server.""" parser = argparse.ArgumentParser(description="Run the __PROJECT_NAME__ middleware") - parser.add_argument("--listen", default="127.0.0.1:50051") + parser.add_argument("--listen", default="0.0.0.0:50051") arguments = parser.parse_args(argv) asyncio.run(serve(arguments.listen)) diff --git a/projects/openshell-middleware-init/src/openshell_middleware_init/templates/python/tests/test_server.py b/projects/openshell-middleware-init/src/openshell_middleware_init/templates/python/tests/test_server.py index 4aad6cca..92d8926c 100644 --- a/projects/openshell-middleware-init/src/openshell_middleware_init/templates/python/tests/test_server.py +++ b/projects/openshell-middleware-init/src/openshell_middleware_init/templates/python/tests/test_server.py @@ -1,8 +1,15 @@ +import asyncio + +import grpc + from __PACKAGE_NAME__.bindings import supervisor_middleware_pb2 as pb2 +from __PACKAGE_NAME__.bindings import supervisor_middleware_pb2_grpc as pb2_grpc from __PACKAGE_NAME__.server import ( MAX_BODY_BYTES, + MAX_MESSAGE_BYTES, SERVICE_NAME, build_manifest, + create_server, evaluate_http_request, validate_config, ) @@ -42,3 +49,37 @@ def test_unsupported_phase_is_denied() -> None: assert response.decision == pb2.DECISION_DENY assert response.reason_code == "unsupported_phase" + + +def test_transport_accepts_maximum_body_inside_full_envelope() -> None: + async def exercise() -> None: + server = create_server() + port = server.add_insecure_port("127.0.0.1:0") + assert port != 0 + await server.start() + options = ( + ("grpc.max_send_message_length", MAX_MESSAGE_BYTES), + ("grpc.max_receive_message_length", MAX_MESSAGE_BYTES), + ) + try: + async with grpc.aio.insecure_channel(f"127.0.0.1:{port}", options=options) as channel: + stub = pb2_grpc.SupervisorMiddlewareStub(channel) + response = await stub.EvaluateHttpRequest( + pb2.HttpRequestEvaluation( + phase=pb2.SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS, + context=pb2.RequestContext( + request_id="max-body-request", + sandbox_id="max-body-sandbox", + ), + headers=[ + pb2.HttpHeader(name="content-type", value="application/octet-stream") + ], + body=b"x" * MAX_BODY_BYTES, + middleware_name=SERVICE_NAME, + ) + ) + finally: + await server.stop(grace=0) + assert response.decision == pb2.DECISION_ALLOW + + asyncio.run(exercise()) diff --git a/projects/openshell-middleware-init/src/openshell_middleware_init/templates/rust/Cargo.toml b/projects/openshell-middleware-init/src/openshell_middleware_init/templates/rust/Cargo.toml index c565ea5b..e1efe4cb 100644 --- a/projects/openshell-middleware-init/src/openshell_middleware_init/templates/rust/Cargo.toml +++ b/projects/openshell-middleware-init/src/openshell_middleware_init/templates/rust/Cargo.toml @@ -9,6 +9,7 @@ publish = false prost = "0.14" prost-types = "0.14" tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal"] } +tokio-stream = { version = "0.1", features = ["net"] } tonic = "0.14" tonic-prost = "0.14" diff --git a/projects/openshell-middleware-init/src/openshell_middleware_init/templates/rust/README.md b/projects/openshell-middleware-init/src/openshell_middleware_init/templates/rust/README.md index 409491ea..68b18f40 100644 --- a/projects/openshell-middleware-init/src/openshell_middleware_init/templates/rust/README.md +++ b/projects/openshell-middleware-init/src/openshell_middleware_init/templates/rust/README.md @@ -14,10 +14,11 @@ cargo clippy --all-targets --all-features -- -D warnings cargo test --locked ``` -Start the middleware on loopback: +Start the middleware on all host interfaces so containerized supervisors can +reach it: ```sh -cargo run --locked -- 127.0.0.1:50051 +cargo run --locked -- 0.0.0.0:50051 ``` The service implementation is in `src/lib.rs`. Extend `validate_config` and @@ -32,14 +33,17 @@ Register the running service in the gateway configuration: ```toml [[openshell.supervisor.middleware]] name = "__SERVICE_NAME__" -grpc_endpoint = "http://127.0.0.1:50051" +grpc_endpoint = "http://:50051" max_body_bytes = 4194304 timeout = "500ms" ``` -Then reference `__SERVICE_NAME__` from a sandbox policy's middleware stage. -Review the supervisor middleware documentation for the policy syntax supported -by your pinned OpenShell release. +Replace `` with a host IP or DNS name reachable from +both the gateway and sandbox supervisors; loopback works only when every process +shares the middleware's network namespace. The development server is insecure, +so restrict port exposure to trusted networks. Then reference `__SERVICE_NAME__` +from a sandbox policy's middleware stage. Review the supervisor middleware +documentation for the policy syntax supported by your pinned OpenShell release. ## Version-matched generated files diff --git a/projects/openshell-middleware-init/src/openshell_middleware_init/templates/rust/src/lib.rs b/projects/openshell-middleware-init/src/openshell_middleware_init/templates/rust/src/lib.rs index dafc70b4..51f6c954 100644 --- a/projects/openshell-middleware-init/src/openshell_middleware_init/templates/rust/src/lib.rs +++ b/projects/openshell-middleware-init/src/openshell_middleware_init/templates/rust/src/lib.rs @@ -6,15 +6,22 @@ pub mod pb { tonic::include_proto!("openshell.middleware.v1"); } -use pb::supervisor_middleware_server::SupervisorMiddleware; +use pb::supervisor_middleware_server::{SupervisorMiddleware, SupervisorMiddlewareServer}; pub const SERVICE_NAME: &str = "__SERVICE_NAME__"; pub const SERVICE_VERSION: &str = "0.1.0"; pub const MAX_BODY_BYTES: u64 = 4 * 1024 * 1024; +pub const MAX_MESSAGE_BYTES: usize = MAX_BODY_BYTES as usize + 1024 * 1024; #[derive(Debug, Default)] pub struct Middleware; +pub fn middleware_service() -> SupervisorMiddlewareServer { + SupervisorMiddlewareServer::new(Middleware) + .max_decoding_message_size(MAX_MESSAGE_BYTES) + .max_encoding_message_size(MAX_MESSAGE_BYTES) +} + pub fn build_manifest() -> pb::MiddlewareManifest { pb::MiddlewareManifest { name: SERVICE_NAME.to_owned(), @@ -77,6 +84,10 @@ impl SupervisorMiddleware for Middleware { #[cfg(test)] mod tests { use super::*; + use pb::supervisor_middleware_client::SupervisorMiddlewareClient; + use tokio::net::TcpListener; + use tokio_stream::wrappers::TcpListenerStream; + use tonic::transport::Server; #[test] fn manifest_advertises_pre_credentials_http() { @@ -103,4 +114,45 @@ mod tests { assert_eq!(response.decision, pb::Decision::Deny as i32); assert_eq!(response.reason_code, "unsupported_phase"); } + + #[tokio::test] + async fn transport_accepts_maximum_body_inside_full_envelope() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + Server::builder() + .add_service(middleware_service()) + .serve_with_incoming(TcpListenerStream::new(listener)) + .await + .unwrap(); + }); + let mut client = SupervisorMiddlewareClient::connect(format!("http://{address}")) + .await + .unwrap() + .max_encoding_message_size(MAX_MESSAGE_BYTES) + .max_decoding_message_size(MAX_MESSAGE_BYTES); + + let response = client + .evaluate_http_request(pb::HttpRequestEvaluation { + phase: pb::SupervisorMiddlewarePhase::PreCredentials as i32, + context: Some(pb::RequestContext { + request_id: "max-body-request".to_owned(), + sandbox_id: "max-body-sandbox".to_owned(), + ..Default::default() + }), + headers: vec![pb::HttpHeader { + name: "content-type".to_owned(), + value: "application/octet-stream".to_owned(), + }], + body: vec![b'x'; MAX_BODY_BYTES as usize], + middleware_name: SERVICE_NAME.to_owned(), + ..Default::default() + }) + .await + .unwrap() + .into_inner(); + + assert_eq!(response.decision, pb::Decision::Allow as i32); + server.abort(); + } } diff --git a/projects/openshell-middleware-init/src/openshell_middleware_init/templates/rust/src/main.rs b/projects/openshell-middleware-init/src/openshell_middleware_init/templates/rust/src/main.rs index 653b58b4..81ecd4e6 100644 --- a/projects/openshell-middleware-init/src/openshell_middleware_init/templates/rust/src/main.rs +++ b/projects/openshell-middleware-init/src/openshell_middleware_init/templates/rust/src/main.rs @@ -1,18 +1,18 @@ use std::{env, error::Error, net::SocketAddr}; -use __PACKAGE_NAME__::{Middleware, pb::supervisor_middleware_server::SupervisorMiddlewareServer}; +use __PACKAGE_NAME__::middleware_service; use tonic::transport::Server; #[tokio::main] async fn main() -> Result<(), Box> { let listen = env::args() .nth(1) - .unwrap_or_else(|| "127.0.0.1:50051".to_owned()); + .unwrap_or_else(|| "0.0.0.0:50051".to_owned()); let address: SocketAddr = listen.parse()?; println!("serving __SERVICE_NAME__ on {address}"); Server::builder() - .add_service(SupervisorMiddlewareServer::new(Middleware)) + .add_service(middleware_service()) .serve(address) .await?; Ok(()) diff --git a/projects/openshell-middleware-init/tests/test_generator.py b/projects/openshell-middleware-init/tests/test_generator.py index b956f5ca..b1c223ae 100644 --- a/projects/openshell-middleware-init/tests/test_generator.py +++ b/projects/openshell-middleware-init/tests/test_generator.py @@ -1,5 +1,7 @@ from __future__ import annotations +import ctypes +import errno import json import os import subprocess @@ -73,7 +75,7 @@ def test_generates_rust_project_with_normalized_crate_name(tmp_path: Path) -> No command_runner=no_op_runner, ) - assert result.run_command == "cargo run -- 127.0.0.1:50051" + assert result.run_command == "cargo run -- 0.0.0.0:50051" assert 'name = "request-audit"' in (destination / "Cargo.toml").read_text() assert "use request_audit::" in (destination / "src/main.rs").read_text() manifest = json.loads((destination / "middleware-dev-manifest.json").read_text()) @@ -97,6 +99,21 @@ def test_python_package_name_can_be_overridden(tmp_path: Path) -> None: assert (destination / "src/custom_package/server.py").is_file() +def test_numeric_project_name_gets_importable_python_package(tmp_path: Path) -> None: + destination = tmp_path / "123" + + initialize_project( + name="123", + language="python", + requested_version="v0.0.86", + destination=destination, + download_proto=local_proto, + command_runner=no_op_runner, + ) + + assert (destination / "src/middleware_123/server.py").is_file() + + @pytest.mark.parametrize( ("name", "language", "package_name", "message"), [ @@ -168,6 +185,33 @@ def test_refuses_a_reserved_destination(tmp_path: Path) -> None: ) +def test_concurrent_destination_is_not_replaced( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + destination = tmp_path / "contended" + publish_no_replace = generator._publish_no_replace + + def collide_before_publish(source: Path, final_output: Path) -> None: + final_output.mkdir() + (final_output / "owned-by-other-process").write_text("keep me\n") + publish_no_replace(source, final_output) + + monkeypatch.setattr(generator, "_publish_no_replace", collide_before_publish) + + with pytest.raises(InitializationError, match="appeared during setup"): + initialize_project( + name="contended", + language="python", + requested_version="v0.0.86", + destination=destination, + download_proto=local_proto, + command_runner=no_op_runner, + ) + + assert (destination / "owned-by-other-process").read_text() == "keep me\n" + assert not (tmp_path / ".contended.openshell-middleware-init.lock").exists() + + def test_failure_cleans_staging_and_owned_reservation(tmp_path: Path) -> None: destination = tmp_path / "failing" @@ -312,19 +356,151 @@ def fail(*args: object, **kwargs: object) -> None: def test_lock_verification_detects_loss_and_changed_owner(tmp_path: Path) -> None: lock = tmp_path / "lock" + missing = generator.OutputReservation( + path=lock, + token="mine", + device=0, + inode=0, + destination=tmp_path / "output", + version="v0.0.86", + started_at="2026-07-22T00:00:00+00:00", + ) with pytest.raises(InitializationError, match="reservation was lost"): - generator._verify_lock(lock, "mine") + generator._verify_lock(missing) - lock.mkdir() + reservation = generator._acquire_lock(lock, "mine", tmp_path / "output", "v0.0.86") (lock / "owner").write_text("theirs") with pytest.raises(InitializationError, match="ownership changed"): - generator._verify_lock(lock, "mine") + generator._verify_lock(reservation) - generator._release_lock(lock, "mine") + generator._release_lock(reservation) assert lock.exists() +def test_reservation_metadata_supports_safe_recovery(tmp_path: Path) -> None: + lock = tmp_path / "lock" + destination = tmp_path / "output" + staging = tmp_path / ".output.staging" + reservation = generator._acquire_lock(lock, "mine", destination, "v0.0.86") + + generator._write_reservation_metadata(reservation, staging) + + metadata = json.loads((lock / "metadata.json").read_text()) + assert metadata["pid"] == os.getpid() + assert metadata["host"] + assert metadata["started_at"] + assert metadata["final_output"] == str(destination) + assert metadata["staging_output"] == str(staging) + + +def test_reservation_cleanup_leaves_unrecognized_contents(tmp_path: Path) -> None: + lock = tmp_path / "lock" + reservation = generator._acquire_lock(lock, "mine", tmp_path / "output", "v0.0.86") + (lock / "unexpected").write_text("not ours\n") + + generator._release_lock(reservation) + + assert lock.is_dir() + assert (lock / "unexpected").read_text() == "not ours\n" + assert (lock / "owner").read_text() == "mine" + assert (lock / "metadata.json").is_file() + + +def test_reservation_verification_rejects_changed_directory_identity(tmp_path: Path) -> None: + reservation = generator._acquire_lock(tmp_path / "lock", "mine", tmp_path / "output", "v0.0.86") + changed_identity = generator.OutputReservation( + path=reservation.path, + token=reservation.token, + device=reservation.device, + inode=reservation.inode + 1, + destination=reservation.destination, + version=reservation.version, + started_at=reservation.started_at, + ) + + with pytest.raises(InitializationError, match="reservation was lost"): + generator._verify_lock(changed_identity) + + +def test_reservation_verification_rejects_non_file_owner(tmp_path: Path) -> None: + reservation = generator._acquire_lock(tmp_path / "lock", "mine", tmp_path / "output", "v0.0.86") + (reservation.path / "owner").unlink() + (reservation.path / "owner").mkdir() + + with pytest.raises(InitializationError, match="reservation was lost"): + generator._verify_lock(reservation) + + +def test_acquisition_failure_removes_only_known_reservation_files( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + lock = tmp_path / "lock" + + def fail_metadata(reservation: generator.OutputReservation, staging_path: Path | None) -> None: + del reservation, staging_path + raise OSError("metadata failed") + + monkeypatch.setattr(generator, "_write_reservation_metadata", fail_metadata) + + with pytest.raises(OSError, match="metadata failed"): + generator._acquire_lock(lock, "mine", tmp_path / "output", "v0.0.86") + + assert not lock.exists() + + +def test_publish_no_replace_moves_into_absent_destination(tmp_path: Path) -> None: + source = tmp_path / "source" + destination = tmp_path / "destination" + source.mkdir() + (source / "generated").write_text("ready\n") + + generator._publish_no_replace(source, destination) + + assert not source.exists() + assert (destination / "generated").read_text() == "ready\n" + + +class FakeRename: + def __init__(self, error_number: int) -> None: + self.error_number = error_number + self.argtypes: object = None + self.restype: object = None + + def __call__(self, *arguments: object) -> int: + del arguments + ctypes.set_errno(self.error_number) + return -1 + + +class FakeLibrary: + def __init__(self, error_number: int) -> None: + self.renameat2 = FakeRename(error_number) + + +@pytest.mark.parametrize("error_number", [errno.EINVAL, errno.ENOSYS, errno.EOPNOTSUPP]) +def test_publish_reports_filesystem_without_no_replace_support( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + error_number: int, +) -> None: + monkeypatch.setattr(generator.ctypes, "CDLL", lambda *args, **kwargs: FakeLibrary(error_number)) + + with pytest.raises(InitializationError, match="does not support"): + generator._publish_no_replace(tmp_path / "source", tmp_path / "destination") + + +def test_publish_translates_unexpected_rename_error( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setattr(generator.ctypes, "CDLL", lambda *args, **kwargs: FakeLibrary(errno.EPERM)) + + with pytest.raises(OSError) as exception_info: + generator._publish_no_replace(tmp_path / "source", tmp_path / "destination") + + assert exception_info.value.errno == errno.EPERM + + def test_prepare_project_dispatches_by_language( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: From b6561377e5c1edf39f44571b3069b9fd015ff217 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 22 Jul 2026 15:15:18 +0000 Subject: [PATCH 03/15] Bind reservation cleanup to ownership --- .../openshell_middleware_init/generator.py | 83 ++++++++++++++----- .../templates/python/README.md | 21 +++-- .../templates/python/src/package/server.py | 2 +- .../templates/rust/README.md | 21 +++-- .../templates/rust/src/main.rs | 2 +- .../tests/test_generator.py | 32 ++++++- 6 files changed, 125 insertions(+), 36 deletions(-) diff --git a/projects/openshell-middleware-init/src/openshell_middleware_init/generator.py b/projects/openshell-middleware-init/src/openshell_middleware_init/generator.py index 82358bc3..8ce084ce 100644 --- a/projects/openshell-middleware-init/src/openshell_middleware_init/generator.py +++ b/projects/openshell-middleware-init/src/openshell_middleware_init/generator.py @@ -55,6 +55,7 @@ class OutputReservation: path: Path token: str + directory_fd: int device: int inode: int destination: Path @@ -153,7 +154,7 @@ def initialize_project( run_command=( f"uv run {context.distribution_name}" if language == "python" - else "cargo run -- 0.0.0.0:50051" + else "cargo run -- 127.0.0.1:50051" ), ) @@ -266,10 +267,21 @@ def _acquire_lock( f"inspect {lock_path / 'metadata.json'} and follow the stale-reservation " "recovery steps in the openshell-middleware-init README" ) from error - lock_stat = lock_path.stat(follow_symlinks=False) + directory_fd = -1 + try: + directory_fd = os.open( + lock_path, + os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0), + ) + lock_stat = os.fstat(directory_fd) + except OSError: + with suppress(OSError): + lock_path.rmdir() + raise reservation = OutputReservation( path=lock_path, token=token, + directory_fd=directory_fd, device=lock_stat.st_dev, inode=lock_stat.st_ino, destination=destination, @@ -277,16 +289,29 @@ def _acquire_lock( started_at=datetime.now(timezone.utc).isoformat(), ) try: - (lock_path / "owner").write_text(token) + _write_reservation_file(reservation, "owner", token) _write_reservation_metadata(reservation, None) except OSError: - _remove_reservation_files(lock_path) + _cleanup_reservation(reservation) raise return reservation +def _write_reservation_file(reservation: OutputReservation, name: str, content: str) -> None: + descriptor = os.open( + name, + os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0), + 0o600, + dir_fd=reservation.directory_fd, + ) + with os.fdopen(descriptor, "w") as reservation_file: + reservation_file.write(content) + + def _write_reservation_metadata(reservation: OutputReservation, staging_path: Path | None) -> None: - (reservation.path / "metadata.json").write_text( + _write_reservation_file( + reservation, + "metadata.json", json.dumps( { "pid": os.getpid(), @@ -298,21 +323,25 @@ def _write_reservation_metadata(reservation: OutputReservation, staging_path: Pa }, indent=2, ) - + "\n" + + "\n", ) def _verify_lock(reservation: OutputReservation) -> None: try: - lock_stat = reservation.path.stat(follow_symlinks=False) + lock_stat = os.fstat(reservation.directory_fd) + path_stat = reservation.path.stat(follow_symlinks=False) if ( not stat.S_ISDIR(lock_stat.st_mode) or lock_stat.st_dev != reservation.device or lock_stat.st_ino != reservation.inode + or not stat.S_ISDIR(path_stat.st_mode) + or path_stat.st_dev != reservation.device + or path_stat.st_ino != reservation.inode ): raise OSError("reservation identity changed") flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) - descriptor = os.open(reservation.path / "owner", flags) + descriptor = os.open("owner", flags, dir_fd=reservation.directory_fd) with os.fdopen(descriptor) as owner: owner_stat = os.fstat(owner.fileno()) if not stat.S_ISREG(owner_stat.st_mode): @@ -324,33 +353,49 @@ def _verify_lock(reservation: OutputReservation) -> None: raise InitializationError("output reservation ownership changed; refusing to publish") -def _remove_reservation_files(lock_path: Path) -> None: +def _remove_reservation_files(reservation: OutputReservation) -> bool: known_names = {"owner", "metadata.json"} try: - lock_stat = lock_path.stat(follow_symlinks=False) - if not stat.S_ISDIR(lock_stat.st_mode): - return - if {entry.name for entry in lock_path.iterdir()} - known_names: - return + if set(os.listdir(reservation.directory_fd)) - known_names: + return False except OSError: - return + return False for name in known_names: try: - (lock_path / name).unlink() + os.unlink(name, dir_fd=reservation.directory_fd) except FileNotFoundError: pass except OSError: - return + return False + return True + + +def _cleanup_reservation(reservation: OutputReservation) -> None: + files_removed = _remove_reservation_files(reservation) + try: + path_stat = reservation.path.stat(follow_symlinks=False) + path_is_same = ( + stat.S_ISDIR(path_stat.st_mode) + and path_stat.st_dev == reservation.device + and path_stat.st_ino == reservation.inode + ) + except OSError: + path_is_same = False with suppress(OSError): - lock_path.rmdir() + os.close(reservation.directory_fd) + if files_removed and path_is_same: + with suppress(OSError): + reservation.path.rmdir() def _release_lock(reservation: OutputReservation) -> None: try: _verify_lock(reservation) except InitializationError: + with suppress(OSError): + os.close(reservation.directory_fd) return - _remove_reservation_files(reservation.path) + _cleanup_reservation(reservation) def _publish_no_replace(source: Path, destination: Path) -> None: diff --git a/projects/openshell-middleware-init/src/openshell_middleware_init/templates/python/README.md b/projects/openshell-middleware-init/src/openshell_middleware_init/templates/python/README.md index b275c513..86ee41ba 100644 --- a/projects/openshell-middleware-init/src/openshell_middleware_init/templates/python/README.md +++ b/projects/openshell-middleware-init/src/openshell_middleware_init/templates/python/README.md @@ -17,11 +17,10 @@ uv run pytest uv build ``` -Start the middleware on all host interfaces so containerized supervisors can -reach it: +Start the middleware on loopback for local development: ```sh -uv run __DISTRIBUTION_NAME__ --listen 0.0.0.0:50051 +uv run __DISTRIBUTION_NAME__ --listen 127.0.0.1:50051 ``` The server implementation is in `src/__PACKAGE_NAME__/server.py`. Extend @@ -31,6 +30,13 @@ domain logic into separate modules. ## Connect OpenShell +When the gateway or supervisors use another network namespace, explicitly bind +the development server to a reachable interface: + +```sh +uv run __DISTRIBUTION_NAME__ --listen 0.0.0.0:50051 +``` + Register the running service in the gateway configuration: ```toml @@ -43,10 +49,11 @@ timeout = "500ms" Replace `` with a host IP or DNS name reachable from both the gateway and sandbox supervisors; loopback works only when every process -shares the middleware's network namespace. The development server is insecure, -so restrict port exposure to trusted networks. Then reference `__SERVICE_NAME__` -from a sandbox policy's middleware stage. Review the supervisor middleware -documentation for the policy syntax supported by your pinned OpenShell release. +shares the middleware's network namespace. Binding outside loopback is an +explicit opt-in because the development server is unauthenticated and insecure; +restrict port exposure to trusted networks. Then reference `__SERVICE_NAME__` from +a sandbox policy's middleware stage. Review the supervisor middleware documentation +for the policy syntax supported by your pinned OpenShell release. ## Version-matched generated files diff --git a/projects/openshell-middleware-init/src/openshell_middleware_init/templates/python/src/package/server.py b/projects/openshell-middleware-init/src/openshell_middleware_init/templates/python/src/package/server.py index 01b9e437..c5b74249 100644 --- a/projects/openshell-middleware-init/src/openshell_middleware_init/templates/python/src/package/server.py +++ b/projects/openshell-middleware-init/src/openshell_middleware_init/templates/python/src/package/server.py @@ -104,7 +104,7 @@ async def serve(listen: str) -> None: def main(argv: Sequence[str] | None = None) -> None: """Run the middleware server.""" parser = argparse.ArgumentParser(description="Run the __PROJECT_NAME__ middleware") - parser.add_argument("--listen", default="0.0.0.0:50051") + parser.add_argument("--listen", default="127.0.0.1:50051") arguments = parser.parse_args(argv) asyncio.run(serve(arguments.listen)) diff --git a/projects/openshell-middleware-init/src/openshell_middleware_init/templates/rust/README.md b/projects/openshell-middleware-init/src/openshell_middleware_init/templates/rust/README.md index 68b18f40..8400f056 100644 --- a/projects/openshell-middleware-init/src/openshell_middleware_init/templates/rust/README.md +++ b/projects/openshell-middleware-init/src/openshell_middleware_init/templates/rust/README.md @@ -14,11 +14,10 @@ cargo clippy --all-targets --all-features -- -D warnings cargo test --locked ``` -Start the middleware on all host interfaces so containerized supervisors can -reach it: +Start the middleware on loopback for local development: ```sh -cargo run --locked -- 0.0.0.0:50051 +cargo run --locked -- 127.0.0.1:50051 ``` The service implementation is in `src/lib.rs`. Extend `validate_config` and @@ -28,6 +27,13 @@ modules. ## Connect OpenShell +When the gateway or supervisors use another network namespace, explicitly bind +the development server to a reachable interface: + +```sh +cargo run --locked -- 0.0.0.0:50051 +``` + Register the running service in the gateway configuration: ```toml @@ -40,10 +46,11 @@ timeout = "500ms" Replace `` with a host IP or DNS name reachable from both the gateway and sandbox supervisors; loopback works only when every process -shares the middleware's network namespace. The development server is insecure, -so restrict port exposure to trusted networks. Then reference `__SERVICE_NAME__` -from a sandbox policy's middleware stage. Review the supervisor middleware -documentation for the policy syntax supported by your pinned OpenShell release. +shares the middleware's network namespace. Binding outside loopback is an +explicit opt-in because the development server is unauthenticated and insecure; +restrict port exposure to trusted networks. Then reference `__SERVICE_NAME__` from +a sandbox policy's middleware stage. Review the supervisor middleware documentation +for the policy syntax supported by your pinned OpenShell release. ## Version-matched generated files diff --git a/projects/openshell-middleware-init/src/openshell_middleware_init/templates/rust/src/main.rs b/projects/openshell-middleware-init/src/openshell_middleware_init/templates/rust/src/main.rs index 81ecd4e6..3ea801c2 100644 --- a/projects/openshell-middleware-init/src/openshell_middleware_init/templates/rust/src/main.rs +++ b/projects/openshell-middleware-init/src/openshell_middleware_init/templates/rust/src/main.rs @@ -7,7 +7,7 @@ use tonic::transport::Server; async fn main() -> Result<(), Box> { let listen = env::args() .nth(1) - .unwrap_or_else(|| "0.0.0.0:50051".to_owned()); + .unwrap_or_else(|| "127.0.0.1:50051".to_owned()); let address: SocketAddr = listen.parse()?; println!("serving __SERVICE_NAME__ on {address}"); diff --git a/projects/openshell-middleware-init/tests/test_generator.py b/projects/openshell-middleware-init/tests/test_generator.py index b1c223ae..fb6fae46 100644 --- a/projects/openshell-middleware-init/tests/test_generator.py +++ b/projects/openshell-middleware-init/tests/test_generator.py @@ -75,7 +75,7 @@ def test_generates_rust_project_with_normalized_crate_name(tmp_path: Path) -> No command_runner=no_op_runner, ) - assert result.run_command == "cargo run -- 0.0.0.0:50051" + assert result.run_command == "cargo run -- 127.0.0.1:50051" assert 'name = "request-audit"' in (destination / "Cargo.toml").read_text() assert "use request_audit::" in (destination / "src/main.rs").read_text() manifest = json.loads((destination / "middleware-dev-manifest.json").read_text()) @@ -359,6 +359,7 @@ def test_lock_verification_detects_loss_and_changed_owner(tmp_path: Path) -> Non missing = generator.OutputReservation( path=lock, token="mine", + directory_fd=-1, device=0, inode=0, destination=tmp_path / "output", @@ -392,6 +393,7 @@ def test_reservation_metadata_supports_safe_recovery(tmp_path: Path) -> None: assert metadata["started_at"] assert metadata["final_output"] == str(destination) assert metadata["staging_output"] == str(staging) + generator._release_lock(reservation) def test_reservation_cleanup_leaves_unrecognized_contents(tmp_path: Path) -> None: @@ -412,6 +414,7 @@ def test_reservation_verification_rejects_changed_directory_identity(tmp_path: P changed_identity = generator.OutputReservation( path=reservation.path, token=reservation.token, + directory_fd=reservation.directory_fd, device=reservation.device, inode=reservation.inode + 1, destination=reservation.destination, @@ -421,6 +424,7 @@ def test_reservation_verification_rejects_changed_directory_identity(tmp_path: P with pytest.raises(InitializationError, match="reservation was lost"): generator._verify_lock(changed_identity) + generator._cleanup_reservation(reservation) def test_reservation_verification_rejects_non_file_owner(tmp_path: Path) -> None: @@ -430,6 +434,32 @@ def test_reservation_verification_rejects_non_file_owner(tmp_path: Path) -> None with pytest.raises(InitializationError, match="reservation was lost"): generator._verify_lock(reservation) + generator._release_lock(reservation) + + +def test_reservation_cleanup_does_not_touch_path_swapped_after_verification( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + lock = tmp_path / "lock" + moved_lock = tmp_path / "moved-lock" + reservation = generator._acquire_lock(lock, "mine", tmp_path / "output", "v0.0.86") + verify_lock = generator._verify_lock + + def verify_then_swap(current: generator.OutputReservation) -> None: + verify_lock(current) + lock.rename(moved_lock) + lock.mkdir() + (lock / "owner").write_text("replacement owner\n") + (lock / "metadata.json").write_text("replacement metadata\n") + + monkeypatch.setattr(generator, "_verify_lock", verify_then_swap) + + generator._release_lock(reservation) + + assert (lock / "owner").read_text() == "replacement owner\n" + assert (lock / "metadata.json").read_text() == "replacement metadata\n" + assert moved_lock.is_dir() + assert list(moved_lock.iterdir()) == [] def test_acquisition_failure_removes_only_known_reservation_files( From 59a36a1b9661be8b3aa21d42b70b34a196c9064b Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 22 Jul 2026 15:17:33 +0000 Subject: [PATCH 04/15] Declare supported initializer platforms --- projects/openshell-middleware-init/README.md | 3 +++ .../openshell_middleware_init/generator.py | 17 ++++++++-------- .../tests/test_generator.py | 20 +++++++++++++++++++ 3 files changed, 32 insertions(+), 8 deletions(-) diff --git a/projects/openshell-middleware-init/README.md b/projects/openshell-middleware-init/README.md index 0bd639aa..247c7468 100644 --- a/projects/openshell-middleware-init/README.md +++ b/projects/openshell-middleware-init/README.md @@ -98,6 +98,9 @@ that it is stale. ## Requirements +- Linux or macOS. The initializer uses POSIX directory descriptors and native + no-replace rename operations to preserve its non-destructive publication + guarantee. - All generation: network access to GitHub and the selected OpenShell release. - Python output: `uv`. - Rust output: Cargo and a toolchain compatible with Rust 1.90 / edition 2024. diff --git a/projects/openshell-middleware-init/src/openshell_middleware_init/generator.py b/projects/openshell-middleware-init/src/openshell_middleware_init/generator.py index 8ce084ce..62436a7f 100644 --- a/projects/openshell-middleware-init/src/openshell_middleware_init/generator.py +++ b/projects/openshell-middleware-init/src/openshell_middleware_init/generator.py @@ -99,6 +99,7 @@ def initialize_project( command_runner: CommandRunner | None = None, ) -> InitializationResult: """Generate and validate a project, then publish it atomically.""" + _validate_platform() context = _template_context(name, language, package_name) version = _normalize_version(requested_version) destination = destination.expanduser().resolve() @@ -159,6 +160,14 @@ def initialize_project( ) +def _validate_platform() -> None: + if sys.platform != "darwin" and not sys.platform.startswith("linux"): + raise InitializationError( + "openshell-middleware-init supports Linux and macOS; " + f"unsupported platform: {sys.platform}" + ) + + def _template_context(name: str, language: str, package_name: str | None) -> TemplateContext: normalized_name = name.strip().lower() if not _PROJECT_NAME_PATTERN.fullmatch(normalized_name): @@ -425,14 +434,6 @@ def _publish_no_replace(source: Path, destination: Path) -> None: rename.argtypes = (ctypes.c_char_p, ctypes.c_char_p, ctypes.c_uint) rename.restype = ctypes.c_int result = rename(source_bytes, destination_bytes, 0x00000004) - elif os.name == "nt": # pragma: no cover - platform-specific - try: - source.rename(destination) - except FileExistsError as error: - raise InitializationError( - f"output path appeared during setup; refusing to overwrite it: {destination}" - ) from error - return else: # pragma: no cover - unsupported platform raise InitializationError( "this platform cannot publish atomically without replacing an output" diff --git a/projects/openshell-middleware-init/tests/test_generator.py b/projects/openshell-middleware-init/tests/test_generator.py index fb6fae46..f0b0f441 100644 --- a/projects/openshell-middleware-init/tests/test_generator.py +++ b/projects/openshell-middleware-init/tests/test_generator.py @@ -114,6 +114,26 @@ def test_numeric_project_name_gets_importable_python_package(tmp_path: Path) -> assert (destination / "src/middleware_123/server.py").is_file() +def test_unsupported_platform_fails_before_filesystem_changes( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + destination = tmp_path / "output" + monkeypatch.setattr(generator.sys, "platform", "win32") + + with pytest.raises(InitializationError, match="supports Linux and macOS"): + initialize_project( + name="project", + language="python", + requested_version="v0.0.86", + destination=destination, + download_proto=local_proto, + command_runner=no_op_runner, + ) + + assert not destination.exists() + assert list(tmp_path.iterdir()) == [] + + @pytest.mark.parametrize( ("name", "language", "package_name", "message"), [ From 3b96e4749603270b234a4fde6b66e99772c99582 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 22 Jul 2026 15:23:26 +0000 Subject: [PATCH 05/15] Align initializer with middleware setup toolkit --- projects/openshell-middleware-init/README.md | 27 +++++++ .../openshell_middleware_init/generator.py | 29 ++++++- .../tests/test_generator.py | 79 +++++++++++++++++++ 3 files changed, 133 insertions(+), 2 deletions(-) diff --git a/projects/openshell-middleware-init/README.md b/projects/openshell-middleware-init/README.md index 247c7468..6d1771ec 100644 --- a/projects/openshell-middleware-init/README.md +++ b/projects/openshell-middleware-init/README.md @@ -75,6 +75,33 @@ Unlike the original `middleware_dev_setup` spike, this project initializer does not install or replace OpenShell. Install the desired OpenShell release through its official installer separately. +## Compatibility with `middleware_dev_setup` + +The initializer deliberately carries forward the spike's observable generation +rules: explicit language and OpenShell version selection; `latest` and bare-tag +normalization; toolchain preflight before network or output changes; one initial +download attempt plus three retries; a destination that must not exist; pinned +protocol download and SHA-256 manifest; `grpcio-tools==1.81.1` with package-safe +imports; the Rust 1.90 / edition 2024 Tonic stack with bundled `protoc`; locked +dependencies; compile/import validation; and hidden sibling staging with a +per-output reservation. + +The intentional differences are scoped to making one runnable middleware +project rather than a bindings workspace: + +| Difference | Reason | +| --- | --- | +| One of `python` or `rust`, rather than `all` or `none` | A generated directory is one runnable, conventional language project. Run the initializer twice when both implementations are wanted. | +| Project files at the output root, rather than nested `python/` or `rust/` directories | The output is directly usable with `uv` or Cargo and can become its own repository. | +| Required project name and optional `--output` | The name is stable project identity; output defaults to that name but remains overridable. Python import names derive from project identity, so moving the directory cannot silently rename the package. | +| No OpenShell install or replacement flags | Project initialization stays local and never mutates a developer's system installation. OpenShell installation remains a separate, explicit operation. | +| Python standard-library HTTP and hashing rather than requiring `curl` and a SHA utility | This removes unrelated host-tool prerequisites while preserving pinned-source, retry, and digest behavior. | +| Runnable service, tests, and development configuration | The CLI produces middleware boilerplate, not only generated protocol bindings. | +| Native atomic no-replace publication | This strengthens the spike's final absence check by preventing a concurrent writer from being overwritten. It is why Linux and macOS are the explicitly supported hosts. | + +The manifest retains the spike's fields and adds a `generator` object for CLI +provenance. + ### Recover a stale reservation A process killed without cleanup can leave diff --git a/projects/openshell-middleware-init/src/openshell_middleware_init/generator.py b/projects/openshell-middleware-init/src/openshell_middleware_init/generator.py index 62436a7f..865d9291 100644 --- a/projects/openshell-middleware-init/src/openshell_middleware_init/generator.py +++ b/projects/openshell-middleware-init/src/openshell_middleware_init/generator.py @@ -15,6 +15,7 @@ import subprocess import sys import tempfile +import time import urllib.error import urllib.request from collections.abc import Callable, Mapping, Sequence @@ -23,6 +24,7 @@ from datetime import datetime, timezone from importlib.resources import files from pathlib import Path +from typing import Any from openshell_middleware_init import __version__ @@ -33,6 +35,7 @@ _VERSION_PATTERN = re.compile(r"^v\d+\.\d+\.\d+(?:[+-][0-9A-Za-z._-]+)?$") _PYTHON_PACKAGE_PATTERN = re.compile(r"^[a-z][a-z0-9_]*$") _PROJECT_NAME_PATTERN = re.compile(r"^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$") +_NETWORK_ATTEMPTS = 4 class InitializationError(RuntimeError): @@ -101,6 +104,8 @@ def initialize_project( """Generate and validate a project, then publish it atomically.""" _validate_platform() context = _template_context(name, language, package_name) + if command_runner is None: + _preflight_language(language) version = _normalize_version(requested_version) destination = destination.expanduser().resolve() _validate_destination(destination) @@ -218,7 +223,7 @@ def _resolve_latest_version() -> str: headers={"User-Agent": f"openshell-middleware-init/{__version__}"}, ) try: - with urllib.request.urlopen(request, timeout=30) as response: + with _urlopen_with_retries(request) as response: resolved_url = response.geturl() except (OSError, urllib.error.URLError) as error: raise InitializationError("could not resolve OpenShell's latest release") from error @@ -238,7 +243,7 @@ def _download_proto(version: str) -> tuple[bytes, str]: headers={"User-Agent": f"openshell-middleware-init/{__version__}"}, ) try: - with urllib.request.urlopen(request, timeout=30) as response: + with _urlopen_with_retries(request) as response: return response.read(), url except (OSError, urllib.error.URLError) as error: raise InitializationError( @@ -246,6 +251,22 @@ def _download_proto(version: str) -> tuple[bytes, str]: ) from error +def _urlopen_with_retries(request: urllib.request.Request) -> Any: + """Open a URL with the same initial attempt plus three retries as the spike.""" + for attempt in range(_NETWORK_ATTEMPTS): + try: + return urllib.request.urlopen(request, timeout=30) + except urllib.error.HTTPError as error: + retryable = error.code in {408, 429} or 500 <= error.code < 600 + if not retryable or attempt == _NETWORK_ATTEMPTS - 1: + raise + except (OSError, urllib.error.URLError): + if attempt == _NETWORK_ATTEMPTS - 1: + raise + time.sleep(0.25 * (2**attempt)) + raise AssertionError("network retry loop exhausted without returning or raising") + + def _validate_proto(proto: bytes, version: str) -> None: required_fragments = ( b"package openshell.middleware.v1;", @@ -521,6 +542,10 @@ def _require_command(command: str) -> str: return resolved +def _preflight_language(language: str) -> None: + _require_command("uv" if language == "python" else "cargo") + + def _run( command: Sequence[str], *, diff --git a/projects/openshell-middleware-init/tests/test_generator.py b/projects/openshell-middleware-init/tests/test_generator.py index f0b0f441..1cdc8e1d 100644 --- a/projects/openshell-middleware-init/tests/test_generator.py +++ b/projects/openshell-middleware-init/tests/test_generator.py @@ -1,6 +1,7 @@ from __future__ import annotations import ctypes +import email.message import errno import json import os @@ -343,14 +344,20 @@ def test_latest_version_rejects_unexpected_redirects( def test_latest_version_translates_network_failure(monkeypatch: pytest.MonkeyPatch) -> None: + attempts = 0 + def fail(*args: object, **kwargs: object) -> None: + nonlocal attempts del args, kwargs + attempts += 1 raise urllib.error.URLError("offline") monkeypatch.setattr(generator.urllib.request, "urlopen", fail) + monkeypatch.setattr(generator.time, "sleep", lambda _: None) with pytest.raises(InitializationError, match="could not resolve"): generator._resolve_latest_version() + assert attempts == 4 def test_download_proto_returns_content_and_source(monkeypatch: pytest.MonkeyPatch) -> None: @@ -369,11 +376,83 @@ def fail(*args: object, **kwargs: object) -> None: raise urllib.error.URLError("missing") monkeypatch.setattr(generator.urllib.request, "urlopen", fail) + monkeypatch.setattr(generator.time, "sleep", lambda _: None) with pytest.raises(InitializationError, match="middleware-capable release"): generator._download_proto("v1.2.3") +def test_download_retries_transient_failures(monkeypatch: pytest.MonkeyPatch) -> None: + attempts = 0 + response = FakeResponse(body=PROTO) + + def transient(*args: object, **kwargs: object) -> FakeResponse: + nonlocal attempts + del args, kwargs + attempts += 1 + if attempts < 3: + raise urllib.error.URLError("temporary") + return response + + monkeypatch.setattr(generator.urllib.request, "urlopen", transient) + monkeypatch.setattr(generator.time, "sleep", lambda _: None) + + assert generator._download_proto("v1.2.3")[0] == PROTO + assert attempts == 3 + + +@pytest.mark.parametrize(("status", "expected_attempts"), [(503, 4), (404, 1)]) +def test_download_retries_only_retryable_http_statuses( + monkeypatch: pytest.MonkeyPatch, status: int, expected_attempts: int +) -> None: + attempts = 0 + + def fail(*args: object, **kwargs: object) -> None: + nonlocal attempts + del args, kwargs + attempts += 1 + raise urllib.error.HTTPError( + "https://example.test/proto", + status, + "failure", + hdrs=email.message.Message(), + fp=None, + ) + + monkeypatch.setattr(generator.urllib.request, "urlopen", fail) + monkeypatch.setattr(generator.time, "sleep", lambda _: None) + + with pytest.raises(InitializationError, match="middleware-capable release"): + generator._download_proto("v1.2.3") + + assert attempts == expected_attempts + + +@pytest.mark.parametrize(("language", "command"), [("python", "uv"), ("rust", "cargo")]) +def test_toolchain_preflight_precedes_latest_resolution_and_output_changes( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, language: str, command: str +) -> None: + destination = tmp_path / "generated" + monkeypatch.setattr(generator.shutil, "which", lambda _: None) + monkeypatch.setattr( + generator, + "_resolve_latest_version", + lambda: pytest.fail("latest must not be resolved before toolchain preflight"), + ) + + with pytest.raises(InitializationError, match=rf"'{command}' is required"): + generator.initialize_project( + name="audit-headers", + language=language, + requested_version="latest", + destination=destination, + download_proto=local_proto, + ) + + assert not destination.exists() + assert list(tmp_path.iterdir()) == [] + + def test_lock_verification_detects_loss_and_changed_owner(tmp_path: Path) -> None: lock = tmp_path / "lock" missing = generator.OutputReservation( From bf49f84ec423d360cd361732b7fa0d3426880e63 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 22 Jul 2026 15:29:53 +0000 Subject: [PATCH 06/15] Close middleware setup parity gaps --- .../openshell_middleware_init/generator.py | 109 +++++++++++++++--- .../templates/rust/Cargo.toml | 3 + .../templates/rust/src/main.rs | 2 +- .../tests/test_generator.py | 59 +++++++++- 4 files changed, 155 insertions(+), 18 deletions(-) diff --git a/projects/openshell-middleware-init/src/openshell_middleware_init/generator.py b/projects/openshell-middleware-init/src/openshell_middleware_init/generator.py index 865d9291..c914258d 100644 --- a/projects/openshell-middleware-init/src/openshell_middleware_init/generator.py +++ b/projects/openshell-middleware-init/src/openshell_middleware_init/generator.py @@ -5,6 +5,7 @@ import ctypes import errno import hashlib +import http.client import json import os import re @@ -24,7 +25,6 @@ from datetime import datetime, timezone from importlib.resources import files from pathlib import Path -from typing import Any from openshell_middleware_init import __version__ @@ -36,6 +36,61 @@ _PYTHON_PACKAGE_PATTERN = re.compile(r"^[a-z][a-z0-9_]*$") _PROJECT_NAME_PATTERN = re.compile(r"^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$") _NETWORK_ATTEMPTS = 4 +_RUST_KEYWORDS = { + "abstract", + "as", + "async", + "await", + "become", + "box", + "break", + "const", + "continue", + "crate", + "do", + "dyn", + "else", + "enum", + "extern", + "false", + "final", + "fn", + "for", + "gen", + "if", + "impl", + "in", + "let", + "loop", + "macro", + "match", + "mod", + "move", + "mut", + "override", + "priv", + "pub", + "ref", + "return", + "self", + "Self", + "static", + "struct", + "super", + "trait", + "true", + "try", + "type", + "typeof", + "union", + "unsized", + "unsafe", + "use", + "virtual", + "where", + "while", + "yield", +} class InitializationError(RuntimeError): @@ -74,6 +129,7 @@ class TemplateContext: distribution_name: str package_name: str rust_crate_name: str + rust_lib_name: str service_name: str @property @@ -83,6 +139,7 @@ def replacements(self) -> Mapping[str, str]: "__DISTRIBUTION_NAME__": self.distribution_name, "__PACKAGE_NAME__": self.package_name, "__RUST_CRATE_NAME__": self.rust_crate_name, + "__RUST_LIB_NAME__": self.rust_lib_name, "__SERVICE_NAME__": self.service_name, } @@ -125,6 +182,7 @@ def initialize_project( dir=destination.parent, ) ) + staging_path.chmod(0o755) _write_reservation_metadata(reservation, staging_path) proto, proto_url = downloader(version) _validate_proto(proto, version) @@ -185,7 +243,8 @@ def _template_context(name: str, language: str, package_name: str | None) -> Tem raise InitializationError("--package-name is only valid with --language python") distribution_name = re.sub(r"[._]+", "-", normalized_name) - derived_package = re.sub(r"[^a-z0-9]+", "_", normalized_name).strip("_") + identifier = re.sub(r"[^a-z0-9]+", "_", normalized_name).strip("_") + derived_package = identifier if not derived_package or not derived_package[0].isalpha(): derived_package = f"middleware_{derived_package}".rstrip("_") effective_package = package_name if package_name is not None else derived_package @@ -195,11 +254,17 @@ def _template_context(name: str, language: str, package_name: str | None) -> Tem "lowercase letters, digits, and underscores" ) service_name = normalized_name.replace("_", "-").replace(".", "-") + rust_lib_name = identifier + rust_crate_name = distribution_name + if not rust_lib_name[0].isalpha() or rust_lib_name in _RUST_KEYWORDS: + rust_lib_name = f"middleware_{rust_lib_name}" + rust_crate_name = f"middleware-{distribution_name}" return TemplateContext( project_name=normalized_name, distribution_name=distribution_name, package_name=effective_package, - rust_crate_name=distribution_name, + rust_crate_name=rust_crate_name, + rust_lib_name=rust_lib_name, service_name=service_name, ) @@ -223,9 +288,8 @@ def _resolve_latest_version() -> str: headers={"User-Agent": f"openshell-middleware-init/{__version__}"}, ) try: - with _urlopen_with_retries(request) as response: - resolved_url = response.geturl() - except (OSError, urllib.error.URLError) as error: + _, resolved_url = _fetch_url(request) + except (OSError, urllib.error.URLError, http.client.IncompleteRead) as error: raise InitializationError("could not resolve OpenShell's latest release") from error prefix = f"{_REPOSITORY_URL}/releases/tag/" if not resolved_url.startswith(prefix): @@ -243,30 +307,47 @@ def _download_proto(version: str) -> tuple[bytes, str]: headers={"User-Agent": f"openshell-middleware-init/{__version__}"}, ) try: - with _urlopen_with_retries(request) as response: - return response.read(), url - except (OSError, urllib.error.URLError) as error: + body, _ = _fetch_url(request) + return body, url + except urllib.error.HTTPError as error: + if error.code == 404: + raise InitializationError( + f"{version} does not expose {_PROTO_PATH}; choose a middleware-capable release" + ) from error + raise InitializationError( + f"could not download {_PROTO_PATH} for {version}: HTTP {error.code}" + ) from error + except (OSError, urllib.error.URLError, http.client.IncompleteRead) as error: raise InitializationError( - f"{version} does not expose {_PROTO_PATH}; choose a middleware-capable release" + f"could not download {_PROTO_PATH} for {version}: {_network_error_reason(error)}" ) from error -def _urlopen_with_retries(request: urllib.request.Request) -> Any: - """Open a URL with the same initial attempt plus three retries as the spike.""" +def _fetch_url(request: urllib.request.Request) -> tuple[bytes, str]: + """Fetch a complete response with the same transfer retries as the spike.""" for attempt in range(_NETWORK_ATTEMPTS): try: - return urllib.request.urlopen(request, timeout=30) + with urllib.request.urlopen(request, timeout=30) as response: + return response.read(), response.geturl() except urllib.error.HTTPError as error: retryable = error.code in {408, 429} or 500 <= error.code < 600 if not retryable or attempt == _NETWORK_ATTEMPTS - 1: raise - except (OSError, urllib.error.URLError): + except (OSError, urllib.error.URLError, http.client.IncompleteRead): if attempt == _NETWORK_ATTEMPTS - 1: raise time.sleep(0.25 * (2**attempt)) raise AssertionError("network retry loop exhausted without returning or raising") +def _network_error_reason( + error: OSError | urllib.error.URLError | http.client.IncompleteRead, +) -> str: + if isinstance(error, urllib.error.URLError): + return str(error.reason) + return str(error) + + def _validate_proto(proto: bytes, version: str) -> None: required_fragments = ( b"package openshell.middleware.v1;", diff --git a/projects/openshell-middleware-init/src/openshell_middleware_init/templates/rust/Cargo.toml b/projects/openshell-middleware-init/src/openshell_middleware_init/templates/rust/Cargo.toml index e1efe4cb..3e116d54 100644 --- a/projects/openshell-middleware-init/src/openshell_middleware_init/templates/rust/Cargo.toml +++ b/projects/openshell-middleware-init/src/openshell_middleware_init/templates/rust/Cargo.toml @@ -5,6 +5,9 @@ edition = "2024" rust-version = "1.90" publish = false +[lib] +name = "__RUST_LIB_NAME__" + [dependencies] prost = "0.14" prost-types = "0.14" diff --git a/projects/openshell-middleware-init/src/openshell_middleware_init/templates/rust/src/main.rs b/projects/openshell-middleware-init/src/openshell_middleware_init/templates/rust/src/main.rs index 3ea801c2..d0ea5378 100644 --- a/projects/openshell-middleware-init/src/openshell_middleware_init/templates/rust/src/main.rs +++ b/projects/openshell-middleware-init/src/openshell_middleware_init/templates/rust/src/main.rs @@ -1,6 +1,6 @@ use std::{env, error::Error, net::SocketAddr}; -use __PACKAGE_NAME__::middleware_service; +use __RUST_LIB_NAME__::middleware_service; use tonic::transport::Server; #[tokio::main] diff --git a/projects/openshell-middleware-init/tests/test_generator.py b/projects/openshell-middleware-init/tests/test_generator.py index 1cdc8e1d..00be58f2 100644 --- a/projects/openshell-middleware-init/tests/test_generator.py +++ b/projects/openshell-middleware-init/tests/test_generator.py @@ -3,8 +3,10 @@ import ctypes import email.message import errno +import http.client import json import os +import stat import subprocess import sys import urllib.error @@ -77,8 +79,11 @@ def test_generates_rust_project_with_normalized_crate_name(tmp_path: Path) -> No ) assert result.run_command == "cargo run -- 127.0.0.1:50051" - assert 'name = "request-audit"' in (destination / "Cargo.toml").read_text() + cargo = (destination / "Cargo.toml").read_text() + assert 'name = "request-audit"' in cargo + assert '[lib]\nname = "request_audit"' in cargo assert "use request_audit::" in (destination / "src/main.rs").read_text() + assert stat.S_IMODE(destination.stat().st_mode) == 0o755 manifest = json.loads((destination / "middleware-dev-manifest.json").read_text()) assert manifest["languages"] == ["rust"] assert manifest["python_package"] is None @@ -115,6 +120,33 @@ def test_numeric_project_name_gets_importable_python_package(tmp_path: Path) -> assert (destination / "src/middleware_123/server.py").is_file() +@pytest.mark.parametrize( + ("name", "crate", "library"), + [ + ("123", "middleware-123", "middleware_123"), + ("type", "middleware-type", "middleware_type"), + ], +) +def test_rust_project_names_get_valid_explicit_library_names( + tmp_path: Path, name: str, crate: str, library: str +) -> None: + destination = tmp_path / name + + initialize_project( + name=name, + language="rust", + requested_version="v0.0.86", + destination=destination, + download_proto=local_proto, + command_runner=no_op_runner, + ) + + cargo = (destination / "Cargo.toml").read_text() + assert f'name = "{crate}"' in cargo + assert f'[lib]\nname = "{library}"' in cargo + assert f"use {library}::middleware_service;" in (destination / "src/main.rs").read_text() + + def test_unsupported_platform_fails_before_filesystem_changes( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: @@ -378,7 +410,7 @@ def fail(*args: object, **kwargs: object) -> None: monkeypatch.setattr(generator.urllib.request, "urlopen", fail) monkeypatch.setattr(generator.time, "sleep", lambda _: None) - with pytest.raises(InitializationError, match="middleware-capable release"): + with pytest.raises(InitializationError, match=r"could not download.*missing"): generator._download_proto("v1.2.3") @@ -401,6 +433,26 @@ def transient(*args: object, **kwargs: object) -> FakeResponse: assert attempts == 3 +def test_download_retries_interrupted_response_body(monkeypatch: pytest.MonkeyPatch) -> None: + attempts = 0 + + class InterruptedResponse(FakeResponse): + def read(self) -> bytes: + raise http.client.IncompleteRead(b"partial") + + def interrupted_then_complete(*args: object, **kwargs: object) -> FakeResponse: + nonlocal attempts + del args, kwargs + attempts += 1 + return InterruptedResponse() if attempts == 1 else FakeResponse(body=PROTO) + + monkeypatch.setattr(generator.urllib.request, "urlopen", interrupted_then_complete) + monkeypatch.setattr(generator.time, "sleep", lambda _: None) + + assert generator._download_proto("v1.2.3")[0] == PROTO + assert attempts == 2 + + @pytest.mark.parametrize(("status", "expected_attempts"), [(503, 4), (404, 1)]) def test_download_retries_only_retryable_http_statuses( monkeypatch: pytest.MonkeyPatch, status: int, expected_attempts: int @@ -422,7 +474,8 @@ def fail(*args: object, **kwargs: object) -> None: monkeypatch.setattr(generator.urllib.request, "urlopen", fail) monkeypatch.setattr(generator.time, "sleep", lambda _: None) - with pytest.raises(InitializationError, match="middleware-capable release"): + expected_message = "middleware-capable release" if status == 404 else "HTTP 503" + with pytest.raises(InitializationError, match=expected_message): generator._download_proto("v1.2.3") assert attempts == expected_attempts From 8c4b5daa0a3b88c2be87582189e885dabf126e56 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 22 Jul 2026 15:33:07 +0000 Subject: [PATCH 07/15] Reserve Rust and Cargo internal names --- .../src/openshell_middleware_init/generator.py | 13 ++++++++++++- .../tests/test_generator.py | 2 ++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/projects/openshell-middleware-init/src/openshell_middleware_init/generator.py b/projects/openshell-middleware-init/src/openshell_middleware_init/generator.py index c914258d..6c8f824f 100644 --- a/projects/openshell-middleware-init/src/openshell_middleware_init/generator.py +++ b/projects/openshell-middleware-init/src/openshell_middleware_init/generator.py @@ -91,6 +91,17 @@ "while", "yield", } +_RUST_RESERVED_IDENTIFIERS = _RUST_KEYWORDS | { + "alloc", + "build", + "core", + "deps", + "examples", + "incremental", + "proc_macro", + "std", + "test", +} class InitializationError(RuntimeError): @@ -256,7 +267,7 @@ def _template_context(name: str, language: str, package_name: str | None) -> Tem service_name = normalized_name.replace("_", "-").replace(".", "-") rust_lib_name = identifier rust_crate_name = distribution_name - if not rust_lib_name[0].isalpha() or rust_lib_name in _RUST_KEYWORDS: + if not rust_lib_name[0].isalpha() or rust_lib_name in _RUST_RESERVED_IDENTIFIERS: rust_lib_name = f"middleware_{rust_lib_name}" rust_crate_name = f"middleware-{distribution_name}" return TemplateContext( diff --git a/projects/openshell-middleware-init/tests/test_generator.py b/projects/openshell-middleware-init/tests/test_generator.py index 00be58f2..994ed54c 100644 --- a/projects/openshell-middleware-init/tests/test_generator.py +++ b/projects/openshell-middleware-init/tests/test_generator.py @@ -125,6 +125,8 @@ def test_numeric_project_name_gets_importable_python_package(tmp_path: Path) -> [ ("123", "middleware-123", "middleware_123"), ("type", "middleware-type", "middleware_type"), + ("std", "middleware-std", "middleware_std"), + ("build", "middleware-build", "middleware_build"), ], ) def test_rust_project_names_get_valid_explicit_library_names( From 93f3a1e5d61a4ebe6f452b8efe31ba893fa7626f Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 22 Jul 2026 16:08:02 +0000 Subject: [PATCH 08/15] Fix initializer first-run review findings --- .../openshell_middleware_init/generator.py | 4 +++- .../templates/python/README.md | 10 ++++++--- .../templates/rust/README.md | 10 ++++++--- .../tests/test_generator.py | 21 +++++++++++++++++++ 4 files changed, 38 insertions(+), 7 deletions(-) diff --git a/projects/openshell-middleware-init/src/openshell_middleware_init/generator.py b/projects/openshell-middleware-init/src/openshell_middleware_init/generator.py index 6c8f824f..6196e369 100644 --- a/projects/openshell-middleware-init/src/openshell_middleware_init/generator.py +++ b/projects/openshell-middleware-init/src/openshell_middleware_init/generator.py @@ -175,7 +175,9 @@ def initialize_project( if command_runner is None: _preflight_language(language) version = _normalize_version(requested_version) - destination = destination.expanduser().resolve() + destination = destination.expanduser() + _validate_destination(destination) + destination = destination.parent.resolve() / destination.name _validate_destination(destination) downloader = download_proto if download_proto is not None else _download_proto runner = command_runner if command_runner is not None else _prepare_project diff --git a/projects/openshell-middleware-init/src/openshell_middleware_init/templates/python/README.md b/projects/openshell-middleware-init/src/openshell_middleware_init/templates/python/README.md index 86ee41ba..cc2604bf 100644 --- a/projects/openshell-middleware-init/src/openshell_middleware_init/templates/python/README.md +++ b/projects/openshell-middleware-init/src/openshell_middleware_init/templates/python/README.md @@ -43,6 +43,7 @@ Register the running service in the gateway configuration: [[openshell.supervisor.middleware]] name = "__SERVICE_NAME__" grpc_endpoint = "http://:50051" +allow_insecure = true max_body_bytes = 4194304 timeout = "500ms" ``` @@ -51,9 +52,12 @@ Replace `` with a host IP or DNS name reachable from both the gateway and sandbox supervisors; loopback works only when every process shares the middleware's network namespace. Binding outside loopback is an explicit opt-in because the development server is unauthenticated and insecure; -restrict port exposure to trusted networks. Then reference `__SERVICE_NAME__` from -a sandbox policy's middleware stage. Review the supervisor middleware documentation -for the policy syntax supported by your pinned OpenShell release. +restrict port exposure to trusted networks. `allow_insecure = true` is required +for this plaintext development endpoint and is only appropriate on trusted local +or isolated networks. Production and shared deployments should use an +authenticated TLS endpoint instead. Then reference `__SERVICE_NAME__` from a +sandbox policy's middleware stage. Review the supervisor middleware +documentation for the policy syntax supported by your pinned OpenShell release. ## Version-matched generated files diff --git a/projects/openshell-middleware-init/src/openshell_middleware_init/templates/rust/README.md b/projects/openshell-middleware-init/src/openshell_middleware_init/templates/rust/README.md index 8400f056..030ee878 100644 --- a/projects/openshell-middleware-init/src/openshell_middleware_init/templates/rust/README.md +++ b/projects/openshell-middleware-init/src/openshell_middleware_init/templates/rust/README.md @@ -40,6 +40,7 @@ Register the running service in the gateway configuration: [[openshell.supervisor.middleware]] name = "__SERVICE_NAME__" grpc_endpoint = "http://:50051" +allow_insecure = true max_body_bytes = 4194304 timeout = "500ms" ``` @@ -48,9 +49,12 @@ Replace `` with a host IP or DNS name reachable from both the gateway and sandbox supervisors; loopback works only when every process shares the middleware's network namespace. Binding outside loopback is an explicit opt-in because the development server is unauthenticated and insecure; -restrict port exposure to trusted networks. Then reference `__SERVICE_NAME__` from -a sandbox policy's middleware stage. Review the supervisor middleware documentation -for the policy syntax supported by your pinned OpenShell release. +restrict port exposure to trusted networks. `allow_insecure = true` is required +for this plaintext development endpoint and is only appropriate on trusted local +or isolated networks. Production and shared deployments should use an +authenticated TLS endpoint instead. Then reference `__SERVICE_NAME__` from a +sandbox policy's middleware stage. Review the supervisor middleware +documentation for the policy syntax supported by your pinned OpenShell release. ## Version-matched generated files diff --git a/projects/openshell-middleware-init/tests/test_generator.py b/projects/openshell-middleware-init/tests/test_generator.py index 994ed54c..3c902090 100644 --- a/projects/openshell-middleware-init/tests/test_generator.py +++ b/projects/openshell-middleware-init/tests/test_generator.py @@ -58,6 +58,7 @@ def test_generates_python_project_with_provenance(tmp_path: Path) -> None: assert (destination / "tests/test_server.py").is_file() assert (destination / "proto/supervisor_middleware.proto").read_bytes() == PROTO assert "__PACKAGE_NAME__" not in (destination / "pyproject.toml").read_text() + assert "allow_insecure = true" in (destination / "README.md").read_text() manifest = json.loads((destination / "middleware-dev-manifest.json").read_text()) assert manifest["openshell_version"] == "v0.0.86" assert manifest["languages"] == ["python"] @@ -83,6 +84,7 @@ def test_generates_rust_project_with_normalized_crate_name(tmp_path: Path) -> No assert 'name = "request-audit"' in cargo assert '[lib]\nname = "request_audit"' in cargo assert "use request_audit::" in (destination / "src/main.rs").read_text() + assert "allow_insecure = true" in (destination / "README.md").read_text() assert stat.S_IMODE(destination.stat().st_mode) == 0o755 manifest = json.loads((destination / "middleware-dev-manifest.json").read_text()) assert manifest["languages"] == ["rust"] @@ -225,6 +227,25 @@ def test_refuses_an_existing_destination(tmp_path: Path) -> None: ) +def test_refuses_a_dangling_destination_symlink(tmp_path: Path) -> None: + destination = tmp_path / "requested" + target = tmp_path / "symlink-target" + destination.symlink_to(target, target_is_directory=True) + + with pytest.raises(InitializationError, match="must not already exist"): + initialize_project( + name="project", + language="python", + requested_version="v0.0.86", + destination=destination, + download_proto=local_proto, + command_runner=no_op_runner, + ) + + assert destination.is_symlink() + assert not target.exists() + + def test_refuses_a_reserved_destination(tmp_path: Path) -> None: destination = tmp_path / "reserved" (tmp_path / ".reserved.openshell-middleware-init.lock").mkdir() From 4ef6387c529ee9fa2ca7e87bff24fca5a12a4ddb Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 22 Jul 2026 16:11:07 +0000 Subject: [PATCH 09/15] Match released middleware registration schema --- .../templates/python/README.md | 10 +++------- .../openshell_middleware_init/templates/rust/README.md | 10 +++------- .../openshell-middleware-init/tests/test_generator.py | 2 -- 3 files changed, 6 insertions(+), 16 deletions(-) diff --git a/projects/openshell-middleware-init/src/openshell_middleware_init/templates/python/README.md b/projects/openshell-middleware-init/src/openshell_middleware_init/templates/python/README.md index cc2604bf..86ee41ba 100644 --- a/projects/openshell-middleware-init/src/openshell_middleware_init/templates/python/README.md +++ b/projects/openshell-middleware-init/src/openshell_middleware_init/templates/python/README.md @@ -43,7 +43,6 @@ Register the running service in the gateway configuration: [[openshell.supervisor.middleware]] name = "__SERVICE_NAME__" grpc_endpoint = "http://:50051" -allow_insecure = true max_body_bytes = 4194304 timeout = "500ms" ``` @@ -52,12 +51,9 @@ Replace `` with a host IP or DNS name reachable from both the gateway and sandbox supervisors; loopback works only when every process shares the middleware's network namespace. Binding outside loopback is an explicit opt-in because the development server is unauthenticated and insecure; -restrict port exposure to trusted networks. `allow_insecure = true` is required -for this plaintext development endpoint and is only appropriate on trusted local -or isolated networks. Production and shared deployments should use an -authenticated TLS endpoint instead. Then reference `__SERVICE_NAME__` from a -sandbox policy's middleware stage. Review the supervisor middleware -documentation for the policy syntax supported by your pinned OpenShell release. +restrict port exposure to trusted networks. Then reference `__SERVICE_NAME__` from +a sandbox policy's middleware stage. Review the supervisor middleware documentation +for the policy syntax supported by your pinned OpenShell release. ## Version-matched generated files diff --git a/projects/openshell-middleware-init/src/openshell_middleware_init/templates/rust/README.md b/projects/openshell-middleware-init/src/openshell_middleware_init/templates/rust/README.md index 030ee878..8400f056 100644 --- a/projects/openshell-middleware-init/src/openshell_middleware_init/templates/rust/README.md +++ b/projects/openshell-middleware-init/src/openshell_middleware_init/templates/rust/README.md @@ -40,7 +40,6 @@ Register the running service in the gateway configuration: [[openshell.supervisor.middleware]] name = "__SERVICE_NAME__" grpc_endpoint = "http://:50051" -allow_insecure = true max_body_bytes = 4194304 timeout = "500ms" ``` @@ -49,12 +48,9 @@ Replace `` with a host IP or DNS name reachable from both the gateway and sandbox supervisors; loopback works only when every process shares the middleware's network namespace. Binding outside loopback is an explicit opt-in because the development server is unauthenticated and insecure; -restrict port exposure to trusted networks. `allow_insecure = true` is required -for this plaintext development endpoint and is only appropriate on trusted local -or isolated networks. Production and shared deployments should use an -authenticated TLS endpoint instead. Then reference `__SERVICE_NAME__` from a -sandbox policy's middleware stage. Review the supervisor middleware -documentation for the policy syntax supported by your pinned OpenShell release. +restrict port exposure to trusted networks. Then reference `__SERVICE_NAME__` from +a sandbox policy's middleware stage. Review the supervisor middleware documentation +for the policy syntax supported by your pinned OpenShell release. ## Version-matched generated files diff --git a/projects/openshell-middleware-init/tests/test_generator.py b/projects/openshell-middleware-init/tests/test_generator.py index 3c902090..74b7d6e4 100644 --- a/projects/openshell-middleware-init/tests/test_generator.py +++ b/projects/openshell-middleware-init/tests/test_generator.py @@ -58,7 +58,6 @@ def test_generates_python_project_with_provenance(tmp_path: Path) -> None: assert (destination / "tests/test_server.py").is_file() assert (destination / "proto/supervisor_middleware.proto").read_bytes() == PROTO assert "__PACKAGE_NAME__" not in (destination / "pyproject.toml").read_text() - assert "allow_insecure = true" in (destination / "README.md").read_text() manifest = json.loads((destination / "middleware-dev-manifest.json").read_text()) assert manifest["openshell_version"] == "v0.0.86" assert manifest["languages"] == ["python"] @@ -84,7 +83,6 @@ def test_generates_rust_project_with_normalized_crate_name(tmp_path: Path) -> No assert 'name = "request-audit"' in cargo assert '[lib]\nname = "request_audit"' in cargo assert "use request_audit::" in (destination / "src/main.rs").read_text() - assert "allow_insecure = true" in (destination / "README.md").read_text() assert stat.S_IMODE(destination.stat().st_mode) == 0o755 manifest = json.loads((destination / "middleware-dev-manifest.json").read_text()) assert manifest["languages"] == ["rust"] From 0586a79560fadbc5e81c2b1366c00e371db18aa9 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 22 Jul 2026 16:21:43 +0000 Subject: [PATCH 10/15] Use uv as Python dependency source --- AGENTS.md | 10 ++++-- .../requirements.txt | 34 ------------------- 2 files changed, 7 insertions(+), 37 deletions(-) delete mode 100644 projects/openshell-middleware-init/requirements.txt diff --git a/AGENTS.md b/AGENTS.md index 21b6e28b..503b4a45 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,8 +20,12 @@ read `docs/development/index.md`. ## Repository rules - Make the smallest change that satisfies the task and preserve unrelated work. -- Use `uv` and the committed `uv.lock` within Python projects. Do not hand-edit - generated dependency exports such as `requirements.txt`. +- Use `uv` for Python dependency management, environments, locking, builds, and + command execution unless a project explicitly documents an exception. Treat + `pyproject.toml` and the committed `uv.lock` as the dependency sources of truth. +- Do not add `requirements.txt` or another generated dependency export by + default. Commit one only when a named non-uv consumer requires it and that + workflow is documented; regenerate exports with `uv`, never by hand. - Never commit credentials or populated `.env` files. Document configuration in `.env.example`. - Do not hand-edit generated Dev Notes cards, bylines, or navigation; use @@ -36,4 +40,4 @@ read `docs/development/index.md`. - For documentation changes, run `python3 tests/test_render_dev_notes.py` and `scripts/build-docs.sh`, then serve the generated site as described in `docs/development/index.md`. -- Report the checks run and any checks that could not be completed. \ No newline at end of file +- Report the checks run and any checks that could not be completed. diff --git a/projects/openshell-middleware-init/requirements.txt b/projects/openshell-middleware-init/requirements.txt deleted file mode 100644 index 1aed6138..00000000 --- a/projects/openshell-middleware-init/requirements.txt +++ /dev/null @@ -1,34 +0,0 @@ -# This file was autogenerated by uv via the following command: -# uv export --format requirements.txt --no-dev --no-emit-project --locked --output-file requirements.txt -annotated-doc==0.0.4 \ - --hash=sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320 \ - --hash=sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4 - # via typer -colorama==0.4.6 ; sys_platform == 'win32' \ - --hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \ - --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 - # via typer -markdown-it-py==4.2.0 \ - --hash=sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49 \ - --hash=sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a - # via rich -mdurl==0.1.2 \ - --hash=sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8 \ - --hash=sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba - # via markdown-it-py -pygments==2.20.0 \ - --hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \ - --hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176 - # via rich -rich==15.0.0 \ - --hash=sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb \ - --hash=sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36 - # via typer -shellingham==1.5.4 \ - --hash=sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686 \ - --hash=sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de - # via typer -typer==0.27.0 \ - --hash=sha256:629bd12ea5d13a17148125d9a264f949eb171fb3f120f9b04d85873cab054fa5 \ - --hash=sha256:6f4b27631e47f077871b7dc30e933ec0131c1390fbe0e387ea5574b5bac9ccf1 - # via openshell-middleware-init From 54f52c4c2af99760ac1c48bcd6a6fe14d0db0edb Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 22 Jul 2026 18:18:57 +0000 Subject: [PATCH 11/15] Simplify generated Python callbacks --- .../templates/python/src/package/server.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/projects/openshell-middleware-init/src/openshell_middleware_init/templates/python/src/package/server.py b/projects/openshell-middleware-init/src/openshell_middleware_init/templates/python/src/package/server.py index c5b74249..e5b7e49e 100644 --- a/projects/openshell-middleware-init/src/openshell_middleware_init/templates/python/src/package/server.py +++ b/projects/openshell-middleware-init/src/openshell_middleware_init/templates/python/src/package/server.py @@ -34,7 +34,6 @@ def build_manifest() -> pb2.MiddlewareManifest: def validate_config(request: pb2.ValidateConfigRequest) -> pb2.ValidateConfigResponse: """Validate service-specific config before OpenShell admits a policy.""" - del request return pb2.ValidateConfigResponse(valid=True) @@ -57,7 +56,6 @@ async def Describe( request: object, context: grpc.aio.ServicerContext, ) -> pb2.MiddlewareManifest: - del request, context return build_manifest() async def ValidateConfig( @@ -65,7 +63,6 @@ async def ValidateConfig( request: pb2.ValidateConfigRequest, context: grpc.aio.ServicerContext, ) -> pb2.ValidateConfigResponse: - del context return validate_config(request) async def EvaluateHttpRequest( @@ -73,7 +70,6 @@ async def EvaluateHttpRequest( request: pb2.HttpRequestEvaluation, context: grpc.aio.ServicerContext, ) -> pb2.HttpRequestResult: - del context return evaluate_http_request(request) From cd4e6d31417b1d5c1ba80f0b841dd7cbf682c55f Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 22 Jul 2026 18:35:45 +0000 Subject: [PATCH 12/15] Refine middleware initializer guidance --- projects/openshell-middleware-init/AGENTS.md | 77 ++++++-- projects/openshell-middleware-init/README.md | 195 ++++++++----------- 2 files changed, 148 insertions(+), 124 deletions(-) diff --git a/projects/openshell-middleware-init/AGENTS.md b/projects/openshell-middleware-init/AGENTS.md index 108e2e32..2d7d00bb 100644 --- a/projects/openshell-middleware-init/AGENTS.md +++ b/projects/openshell-middleware-init/AGENTS.md @@ -1,17 +1,66 @@ -# OpenShell Middleware Init contributor guidance +# Agent instructions Read `README.md` and `pyproject.toml` before changing this project. -- Keep initialization non-destructive: never merge into or replace an existing - destination. -- Keep generated projects version-matched. The proto, generated bindings or - build configuration, dependency lock, and manifest must describe one - OpenShell release. -- Template markers use `__UPPER_SNAKE_CASE__`. Add every new marker to - `TemplateContext.replacements` and cover it with rendering tests. -- Generated Python bindings are generator-owned. Do not add formatting or type - checks that rewrite them. -- Tests for the initializer must be hermetic. Inject protocol download and - project preparation collaborators instead of accessing GitHub, uv, or Cargo. - -Run the validation commands documented in `README.md` after changes. +## Preserve these invariants + +- Keep initialization non-destructive. Never merge into, follow, or replace an + existing output path, including a symlink. +- Build and validate in a hidden sibling staging directory. Publish only after + all generation and validation steps succeed. +- Preserve reservation ownership checks and atomic no-replace publication. +- Support Linux and macOS explicitly. Do not weaken publication guarantees to + add another platform implicitly. +- Keep every generated project version-matched: the OpenShell tag, downloaded + proto, bindings or build configuration, lockfile, and manifest must agree. +- Do not install, replace, or configure the user's OpenShell installation. + +## Follow the reference behavior + +- Compare user-visible generation behavior with the repository sibling + `../openshell-internal-spikes/middleware_dev_setup` (resolved from the + repository root) before changing it. +- Preserve the reference behavior unless the project README documents a clear, + project-specific reason for a difference. + +## Use the project toolchain + +- Use `uv` for this Python project. Treat `pyproject.toml` and `uv.lock` as the + dependency sources of truth. +- Do not add `requirements.txt` or another dependency export unless a documented + non-uv consumer requires it. +- Use `uv add` or `uv remove` for dependency changes; do not hand-edit the lock. + +## Change templates carefully + +- Keep templates under `src/openshell_middleware_init/templates/` runnable as + standalone projects. +- Use `__UPPER_SNAKE_CASE__` for template markers. Add every marker to + `TemplateContext.replacements` and cover it with a rendering test. +- Treat generated Python protobuf and gRPC modules as generator-owned. Do not + format, type-check, or hand-edit them. +- When changing a template, generate the affected language project in isolated + scratch storage and run its documented checks when practical. + +## Test behavior, not implementation details + +- Keep initializer unit tests hermetic. Inject protocol downloads and project + preparation instead of contacting GitHub or invoking uv or Cargo. +- Add regression tests for changes to output safety, failure cleanup, naming, + manifests, network behavior, or rendered files. +- Use isolated temporary paths for end-to-end generation. Never generate over an + existing directory. + +## Validate every change + +Run these commands from this directory: + +```sh +uv run ruff format --check . +uv run ruff check . +uv run ty check +uv run pytest +uv build +``` + +Report any command that could not run and why. diff --git a/projects/openshell-middleware-init/README.md b/projects/openshell-middleware-init/README.md index 6d1771ec..8fe7d419 100644 --- a/projects/openshell-middleware-init/README.md +++ b/projects/openshell-middleware-init/README.md @@ -1,140 +1,116 @@ # OpenShell Middleware Init -`openshell-middleware-init` is a Typer-based Python CLI that creates runnable -OpenShell supervisor middleware projects in Python or Rust. Every generated -project uses the protocol contract from one explicit OpenShell release and -records its source and SHA-256 in a manifest. +`openshell-middleware-init` creates a runnable Python or Rust starter for an +OpenShell supervisor middleware service. The starter implements the complete +gRPC service as a pass-through, pins its protocol contract to one OpenShell +release, and includes tests, dependency locks, and registration guidance. -The generated service is a small pass-through implementation of all three -`SupervisorMiddleware` RPCs: `Describe`, `ValidateConfig`, and -`EvaluateHttpRequest`. It provides the transport and build boilerplate without -guessing the middleware's policy or domain behavior. +The initializer does not install or replace OpenShell. -## Install for development +## Requirements + +- Linux or macOS +- [uv](https://docs.astral.sh/uv/) +- Network access to GitHub and the selected OpenShell release +- For Rust projects: Cargo and a Rust 1.90-compatible toolchain + +## Quick start -Install [uv](https://docs.astral.sh/uv/), then create the locked environment: +From this directory, install the CLI's locked development environment: ```sh uv sync --locked ``` -## Generate a project - -Every material choice is explicit: +Generate and run a Python starter: ```sh uv run openshell-middleware-init audit-headers \ --language python \ - --openshell-version v0.0.86 + --openshell-version v0.0.86 \ + --output /tmp/audit-headers + +cd /tmp/audit-headers +uv run pytest +uv run audit-headers ``` -For Rust: +Or generate and run a Rust starter: ```sh uv run openshell-middleware-init audit-headers \ --language rust \ --openshell-version v0.0.86 \ - --output ../audit-headers + --output /tmp/audit-headers-rust + +cd /tmp/audit-headers-rust +cargo test --locked +cargo run --locked -- 127.0.0.1:50051 ``` -Use `--openshell-version latest` to resolve the current release. For shared -middleware, prefer a pinned tag so regeneration remains reproducible. +The output path must not already exist. Use a pinned OpenShell tag for +reproducible projects; `--openshell-version latest` is available for +experimentation. -Python package names default to a normalized form of the project name -(`audit-headers` becomes `audit_headers`) and can be overridden: +Run `uv run openshell-middleware-init --help` for all options. Python package +names default to a normalized project name and can be changed with +`--package-name`. -```sh -uv run openshell-middleware-init audit-headers \ - --language python \ - --openshell-version v0.0.86 \ - --package-name request_auditor -``` +## What you get -Run `uv run openshell-middleware-init --help` for the complete interface. - -## What generation does - -The initializer: - -1. resolves and downloads `proto/supervisor_middleware.proto` from the selected - OpenShell release; -2. renders a runnable pass-through service, tests, development configuration, - and registration guidance; -3. generates package-safe Python protobuf/gRPC modules or configures Rust Tonic - generation with a bundled `protoc`; -4. creates `uv.lock` or `Cargo.lock` and compile/import-checks the project; -5. writes `middleware-dev-manifest.json`; and -6. publishes the project only after validation succeeds. - -Generation happens in a hidden sibling staging directory. The final output must -not already exist, and a reservation directory prevents concurrent initializers -from publishing to the same path. A failed run removes its own staging and -reservation data and does not merge into an existing project. - -Unlike the original `middleware_dev_setup` spike, this project initializer does -not install or replace OpenShell. Install the desired OpenShell release through -its official installer separately. - -## Compatibility with `middleware_dev_setup` - -The initializer deliberately carries forward the spike's observable generation -rules: explicit language and OpenShell version selection; `latest` and bare-tag -normalization; toolchain preflight before network or output changes; one initial -download attempt plus three retries; a destination that must not exist; pinned -protocol download and SHA-256 manifest; `grpcio-tools==1.81.1` with package-safe -imports; the Rust 1.90 / edition 2024 Tonic stack with bundled `protoc`; locked -dependencies; compile/import validation; and hidden sibling staging with a -per-output reservation. - -The intentional differences are scoped to making one runnable middleware -project rather than a bindings workspace: - -| Difference | Reason | -| --- | --- | -| One of `python` or `rust`, rather than `all` or `none` | A generated directory is one runnable, conventional language project. Run the initializer twice when both implementations are wanted. | -| Project files at the output root, rather than nested `python/` or `rust/` directories | The output is directly usable with `uv` or Cargo and can become its own repository. | -| Required project name and optional `--output` | The name is stable project identity; output defaults to that name but remains overridable. Python import names derive from project identity, so moving the directory cannot silently rename the package. | -| No OpenShell install or replacement flags | Project initialization stays local and never mutates a developer's system installation. OpenShell installation remains a separate, explicit operation. | -| Python standard-library HTTP and hashing rather than requiring `curl` and a SHA utility | This removes unrelated host-tool prerequisites while preserving pinned-source, retry, and digest behavior. | -| Runnable service, tests, and development configuration | The CLI produces middleware boilerplate, not only generated protocol bindings. | -| Native atomic no-replace publication | This strengthens the spike's final absence check by preventing a concurrent writer from being overwritten. It is why Linux and macOS are the explicitly supported hosts. | - -The manifest retains the spike's fields and adds a `generator` object for CLI -provenance. - -### Recover a stale reservation - -A process killed without cleanup can leave -`..openshell-middleware-init.lock` and its hidden staging directory. The -initializer deliberately leaves ambiguous state in place instead of guessing -that it is stale. - -1. Read the reservation's `metadata.json`. It records the hostname, PID, start - time, target version, final output, and staging output. -2. On the recorded host, confirm that the PID is no longer an - `openshell-middleware-init` process. Account for PID reuse by comparing the - process start time and command. Confirm that the final output still does not - exist. -3. Inspect the recorded staging directory and preserve anything needed for - diagnosis. Remove it only after confirming the initializer is no longer - active. -4. Remove only `owner` and `metadata.json` from the reservation, then remove the - empty reservation directory with `rmdir`. If it contains any other entry, - stop and investigate rather than deleting recursively. -5. Run the initializer again. +Each generated project contains: -## Requirements +- a pass-through implementation of `Describe`, `ValidateConfig`, and + `EvaluateHttpRequest`; +- the exact `supervisor_middleware.proto` from the selected OpenShell release; +- generated Python gRPC bindings or Rust Tonic build configuration; +- tests and lint/type-check configuration; +- `uv.lock` or `Cargo.lock`; and +- `middleware-dev-manifest.json` with the release, source URL, and protocol + SHA-256. + +Start by implementing policy behavior in the generated `validate_config` and +`evaluate_http_request` functions. The generated README explains how to run the +service and register it with OpenShell. + +## Safety and failure behavior + +Generation is non-destructive. The initializer validates a hidden sibling +staging directory, then publishes it atomically. It refuses an existing output, +including a symlink, and uses a per-output reservation to prevent concurrent +writers. A normal failure removes the initializer's own staging and reservation +without publishing a partial project. + +If the process is killed, it may leave +`..openshell-middleware-init.lock` and a hidden staging directory. Before +removing either one: + +1. Read `metadata.json` in the reservation. +2. On the recorded host, confirm that the recorded PID is no longer the same + initializer process and that the final output does not exist. +3. Inspect and remove only the recorded staging directory. +4. Remove `owner` and `metadata.json`, then remove the empty reservation with + `rmdir`. Stop if it contains anything unexpected. + +## Relationship to `middleware_dev_setup` + +This CLI preserves the spike's important behavior: explicit version and +language selection, version normalization, toolchain preflight, retried pinned +protocol downloads, protocol hashing, generated bindings, dependency locks, +validation before publication, and non-destructive sibling staging. + +The scope is intentionally narrower and more project-oriented: -- Linux or macOS. The initializer uses POSIX directory descriptors and native - no-replace rename operations to preserve its non-destructive publication - guarantee. -- All generation: network access to GitHub and the selected OpenShell release. -- Python output: `uv`. -- Rust output: Cargo and a toolchain compatible with Rust 1.90 / edition 2024. +- one invocation creates one runnable Python or Rust project; +- project files live at the output root; +- project identity is independent of the output directory; +- OpenShell installation remains a separate operation; and +- publication uses an atomic no-replace operation rather than a final checked + move. -## Develop this CLI +## Develop the CLI -Run the full local gate from this directory: +Run the complete local gate from this directory: ```sh uv run ruff format --check . @@ -144,6 +120,5 @@ uv run pytest uv build ``` -`src/openshell_middleware_init/templates/` contains the generated project -assets. Tests use local protocol fixtures and do not contact GitHub or invoke -language package managers. +Unit tests are hermetic: they use local protocol fixtures and do not contact +GitHub or invoke uv or Cargo for generated projects. From 956ef9009b4de92e47781992a9cb6ccf6b2bd73c Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 22 Jul 2026 18:39:11 +0000 Subject: [PATCH 13/15] Remove initializer build provenance --- projects/openshell-middleware-init/AGENTS.md | 8 -------- projects/openshell-middleware-init/README.md | 16 ---------------- 2 files changed, 24 deletions(-) diff --git a/projects/openshell-middleware-init/AGENTS.md b/projects/openshell-middleware-init/AGENTS.md index 2d7d00bb..864d071c 100644 --- a/projects/openshell-middleware-init/AGENTS.md +++ b/projects/openshell-middleware-init/AGENTS.md @@ -15,14 +15,6 @@ Read `README.md` and `pyproject.toml` before changing this project. proto, bindings or build configuration, lockfile, and manifest must agree. - Do not install, replace, or configure the user's OpenShell installation. -## Follow the reference behavior - -- Compare user-visible generation behavior with the repository sibling - `../openshell-internal-spikes/middleware_dev_setup` (resolved from the - repository root) before changing it. -- Preserve the reference behavior unless the project README documents a clear, - project-specific reason for a difference. - ## Use the project toolchain - Use `uv` for this Python project. Treat `pyproject.toml` and `uv.lock` as the diff --git a/projects/openshell-middleware-init/README.md b/projects/openshell-middleware-init/README.md index 8fe7d419..f478758a 100644 --- a/projects/openshell-middleware-init/README.md +++ b/projects/openshell-middleware-init/README.md @@ -92,22 +92,6 @@ removing either one: 4. Remove `owner` and `metadata.json`, then remove the empty reservation with `rmdir`. Stop if it contains anything unexpected. -## Relationship to `middleware_dev_setup` - -This CLI preserves the spike's important behavior: explicit version and -language selection, version normalization, toolchain preflight, retried pinned -protocol downloads, protocol hashing, generated bindings, dependency locks, -validation before publication, and non-destructive sibling staging. - -The scope is intentionally narrower and more project-oriented: - -- one invocation creates one runnable Python or Rust project; -- project files live at the output root; -- project identity is independent of the output directory; -- OpenShell installation remains a separate operation; and -- publication uses an atomic no-replace operation rather than a final checked - move. - ## Develop the CLI Run the complete local gate from this directory: From d4455652fa0fa859f5bb936a38818292dd9a609a Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 22 Jul 2026 18:49:26 +0000 Subject: [PATCH 14/15] Document standalone CLI installation --- projects/openshell-middleware-init/README.md | 35 +++++++++++++++----- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/projects/openshell-middleware-init/README.md b/projects/openshell-middleware-init/README.md index f478758a..bbad8952 100644 --- a/projects/openshell-middleware-init/README.md +++ b/projects/openshell-middleware-init/README.md @@ -14,18 +14,38 @@ The initializer does not install or replace OpenShell. - Network access to GitHub and the selected OpenShell release - For Rust projects: Cargo and a Rust 1.90-compatible toolchain -## Quick start +## Install the CLI + +Install the command in an isolated tool environment from GitHub: + +```sh +uv tool install \ + "openshell-middleware-init @ git+https://github.com/NVIDIA/OpenShell-Research.git#subdirectory=projects/openshell-middleware-init" +``` + +If you already have this repository checked out, install from its local path +instead: -From this directory, install the CLI's locked development environment: +```sh +uv tool install /path/to/OpenShell-Research/projects/openshell-middleware-init +``` + +Both forms make `openshell-middleware-init` available outside the source tree +without running `uv sync` in this project. + +Contributors working on the CLI should use the locked project environment: ```sh uv sync --locked +uv run openshell-middleware-init --help ``` -Generate and run a Python starter: +## Quick start + +Generate and run a Python starter with the installed command: ```sh -uv run openshell-middleware-init audit-headers \ +openshell-middleware-init audit-headers \ --language python \ --openshell-version v0.0.86 \ --output /tmp/audit-headers @@ -38,7 +58,7 @@ uv run audit-headers Or generate and run a Rust starter: ```sh -uv run openshell-middleware-init audit-headers \ +openshell-middleware-init audit-headers \ --language rust \ --openshell-version v0.0.86 \ --output /tmp/audit-headers-rust @@ -52,9 +72,8 @@ The output path must not already exist. Use a pinned OpenShell tag for reproducible projects; `--openshell-version latest` is available for experimentation. -Run `uv run openshell-middleware-init --help` for all options. Python package -names default to a normalized project name and can be changed with -`--package-name`. +Run `openshell-middleware-init --help` for all options. Python package names +default to a normalized project name and can be changed with `--package-name`. ## What you get From 6be6bda0d20b0873b40f1c2f04438e1f69237a1d Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 22 Jul 2026 18:52:59 +0000 Subject: [PATCH 15/15] Record staging path before creation --- .../openshell_middleware_init/generator.py | 19 +++++++++---------- .../tests/test_generator.py | 13 +++++++------ 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/projects/openshell-middleware-init/src/openshell_middleware_init/generator.py b/projects/openshell-middleware-init/src/openshell_middleware_init/generator.py index 6196e369..60267c37 100644 --- a/projects/openshell-middleware-init/src/openshell_middleware_init/generator.py +++ b/projects/openshell-middleware-init/src/openshell_middleware_init/generator.py @@ -128,6 +128,7 @@ class OutputReservation: device: int inode: int destination: Path + staging_path: Path version: str started_at: str @@ -189,14 +190,9 @@ def initialize_project( staging_path: Path | None = None try: _validate_destination(destination) - staging_path = Path( - tempfile.mkdtemp( - prefix=f".{destination.name}.openshell-middleware-init.", - dir=destination.parent, - ) - ) + reservation.staging_path.mkdir(mode=0o700) + staging_path = reservation.staging_path staging_path.chmod(0o755) - _write_reservation_metadata(reservation, staging_path) proto, proto_url = downloader(version) _validate_proto(proto, version) _render_project(staging_path, language, context) @@ -409,12 +405,15 @@ def _acquire_lock( device=lock_stat.st_dev, inode=lock_stat.st_ino, destination=destination, + staging_path=( + destination.parent / f".{destination.name}.openshell-middleware-init.{token}" + ), version=version, started_at=datetime.now(timezone.utc).isoformat(), ) try: _write_reservation_file(reservation, "owner", token) - _write_reservation_metadata(reservation, None) + _write_reservation_metadata(reservation) except OSError: _cleanup_reservation(reservation) raise @@ -432,7 +431,7 @@ def _write_reservation_file(reservation: OutputReservation, name: str, content: reservation_file.write(content) -def _write_reservation_metadata(reservation: OutputReservation, staging_path: Path | None) -> None: +def _write_reservation_metadata(reservation: OutputReservation) -> None: _write_reservation_file( reservation, "metadata.json", @@ -443,7 +442,7 @@ def _write_reservation_metadata(reservation: OutputReservation, staging_path: Pa "started_at": reservation.started_at, "target_version": reservation.version, "final_output": str(reservation.destination), - "staging_output": str(staging_path) if staging_path is not None else None, + "staging_output": str(reservation.staging_path), }, indent=2, ) diff --git a/projects/openshell-middleware-init/tests/test_generator.py b/projects/openshell-middleware-init/tests/test_generator.py index 74b7d6e4..d337561e 100644 --- a/projects/openshell-middleware-init/tests/test_generator.py +++ b/projects/openshell-middleware-init/tests/test_generator.py @@ -536,6 +536,7 @@ def test_lock_verification_detects_loss_and_changed_owner(tmp_path: Path) -> Non device=0, inode=0, destination=tmp_path / "output", + staging_path=tmp_path / ".output.staging", version="v0.0.86", started_at="2026-07-22T00:00:00+00:00", ) @@ -555,17 +556,16 @@ def test_lock_verification_detects_loss_and_changed_owner(tmp_path: Path) -> Non def test_reservation_metadata_supports_safe_recovery(tmp_path: Path) -> None: lock = tmp_path / "lock" destination = tmp_path / "output" - staging = tmp_path / ".output.staging" reservation = generator._acquire_lock(lock, "mine", destination, "v0.0.86") - generator._write_reservation_metadata(reservation, staging) - metadata = json.loads((lock / "metadata.json").read_text()) assert metadata["pid"] == os.getpid() assert metadata["host"] assert metadata["started_at"] assert metadata["final_output"] == str(destination) - assert metadata["staging_output"] == str(staging) + assert metadata["staging_output"] == str(reservation.staging_path) + assert reservation.token in reservation.staging_path.name + assert not reservation.staging_path.exists() generator._release_lock(reservation) @@ -591,6 +591,7 @@ def test_reservation_verification_rejects_changed_directory_identity(tmp_path: P device=reservation.device, inode=reservation.inode + 1, destination=reservation.destination, + staging_path=reservation.staging_path, version=reservation.version, started_at=reservation.started_at, ) @@ -640,8 +641,8 @@ def test_acquisition_failure_removes_only_known_reservation_files( ) -> None: lock = tmp_path / "lock" - def fail_metadata(reservation: generator.OutputReservation, staging_path: Path | None) -> None: - del reservation, staging_path + def fail_metadata(reservation: generator.OutputReservation) -> None: + del reservation raise OSError("metadata failed") monkeypatch.setattr(generator, "_write_reservation_metadata", fail_metadata)