diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba8c2fc..165ffdc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,27 +54,28 @@ jobs: run: cargo test # `cargo build --all-targets` only *compiles* an example. `AGENTS.md` - # promises `cargo run -p template --example basic` works, and a compiled + # promises `cargo run -p tinyruntime-python --example basic` works, and a compiled # example can still fail on its first line. - name: Run the bundled example - run: cargo run -p template --example basic - - # `crates/template-bus` exists so a host can name the payload types - # without compiling the module. That promise is invisible in a diff, - # because a forbidden dependency arrives transitively through a feature - # someone enabled one crate away — so it is asserted rather than - # documented. + run: cargo run -p tinyruntime-python --example basic + + # The wire contract is vendored, not defined here, but this crate is one + # of the things that keeps it honest: a provider is exactly the kind of + # consumer that would be tempted to add a transport to it. The promise is + # invisible in a diff, because a forbidden dependency arrives transitively + # through a feature someone enabled one crate away — so it is asserted + # rather than documented. # - # The FORWARD form is required. `cargo tree -i -p template-bus` + # The FORWARD form is required. `cargo tree -i -p tinyruntime-bus` # discards the `-p` scope, prints the whole-workspace inverse tree, and - # exits 0 looking clean even when this crate is the one at fault. - - name: Assert the contract crate stays transport-free + # exits 0 looking clean even when the contract is the one at fault. + - name: Assert the vendored contract stays transport-free run: | set -euo pipefail - forbidden="$(cargo tree -p template-bus -e normal,build --prefix none \ + forbidden="$(cargo tree -p tinyruntime-bus -e normal,build --prefix none \ | grep -Ei 'tinybus|tokio|reqwest|ureq|hyper|rusqlite|git2' || true)" if [ -n "$forbidden" ]; then - echo "template-bus pulled in a dependency its manifest forbids:" >&2 + echo "tinyruntime-bus pulled in a dependency its manifest forbids:" >&2 echo "$forbidden" >&2 echo >&2 echo "The contract is what a host compiles against. It must stay free" >&2 @@ -128,7 +129,7 @@ jobs: run: | set -euo pipefail msrv="$(cargo metadata --format-version 1 --no-deps \ - | jq -r '.packages[] | select(.name == "template") | .rust_version')" + | jq -r '.packages[] | select(.name == "tinyruntime-python") | .rust_version')" if [[ -z "$msrv" || "$msrv" == "null" ]]; then echo "workspace.package.rust-version is not set in Cargo.toml" >&2 exit 1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4acf379..d75881a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,9 +22,8 @@ permissions: env: # The workspace member that ships as the loadable module. Its package name is - # the artifact name and the library name; `crates/template-bus` rides along on - # the same inherited version and is not packaged separately. - RELEASE_PACKAGE: template + # the artifact name and the library name. + RELEASE_PACKAGE: tinyruntime-python jobs: prepare: @@ -259,7 +258,7 @@ jobs: macOS) module="target/release/lib${library_name}.dylib" ;; *) echo "unsupported Unix runner: ${RUNNER_OS}" >&2; exit 1 ;; esac - cargo run --locked --package template --example verify_module -- "$module" + cargo run --locked --package tinyruntime-python --example verify_module -- "$module" - name: Verify Windows module through TinyBus loader if: ${{ runner.os == 'Windows' }} @@ -270,7 +269,7 @@ jobs: $ErrorActionPreference = 'Stop' $libraryName = $env:CRATE_NAME.Replace('-', '_') $module = "target/release/$libraryName.dll" - $verifyRoot = Join-Path $env:RUNNER_TEMP 'template-module-verify' + $verifyRoot = Join-Path $env:RUNNER_TEMP 'tinyruntime-python-module-verify' New-Item -ItemType Directory -Force $verifyRoot | Out-Null $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent() @@ -300,7 +299,7 @@ jobs: $verifiedModule = Join-Path $verifyRoot "$libraryName.dll" Copy-Item -LiteralPath $module -Destination $verifiedModule - cargo run --locked --package template --example verify_module -- $verifiedModule + cargo run --locked --package tinyruntime-python --example verify_module -- $verifiedModule - name: Assemble Unix module package if: ${{ runner.os != 'Windows' }} @@ -463,7 +462,7 @@ jobs: verify_root="/opt/${CRATE_NAME}-module-verify" install -d -m 700 "$verify_root" install -m 755 "target/release/lib${library_name}.so" "$verify_root/" - cargo run --locked --package template --example verify_module -- \ + cargo run --locked --package tinyruntime-python --example verify_module -- \ "$verify_root/lib${library_name}.so" - name: Assemble distribution module package @@ -587,5 +586,5 @@ jobs: cargo run --manifest-path vendor/tinybus/Cargo.toml --locked \ --package tinybus --all-features --example github_module_host -- \ "$release_url" "$archive" "$sha256" - cargo run --locked --package template --example verify_github_release -- \ + cargo run --locked --package tinyruntime-python --example verify_github_release -- \ "$release_url" "$archive" "$sha256" diff --git a/.gitmodules b/.gitmodules index da09a74..1dade06 100644 --- a/.gitmodules +++ b/.gitmodules @@ -2,3 +2,6 @@ path = vendor/tinybus url = https://github.com/tinyhumansai/tinybus branch = main +[submodule "vendor/tinyruntime"] + path = vendor/tinyruntime + url = https://github.com/tinyhumansai/tinyruntime diff --git a/AGENTS.md b/AGENTS.md index ee8fdfc..7c55d92 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,101 +4,98 @@ This file is the single source of truth for how humans and coding agents work in this repository. `CLAUDE.md` is a symlink to this file, so every agent reads the same instructions. -When you generate a new project from this template, keep this file and adapt -the project-specific parts (crate name, module map, feature flags, commands). -Delete guidance that no longer applies rather than leaving it to rot. - -## Template Checklist - -Do this once, in a single commit, before writing feature code: - -- [ ] Rename `crates/template` and `crates/template-bus` to the project's crate - names, and update `name` in each manifest plus the `template-bus` entry in - the root `[workspace.dependencies]`. -- [ ] Set `description`, `keywords`, and `categories` in each manifest, and - `repository` in the root `[workspace.package]`. -- [ ] Rename the crate references in `README.md`, both `src/lib.rs` files, - `crates/template/examples/`, and `crates/template/tests/` (search for - `template` and `template_bus`). -- [ ] Replace the placeholder `greeting` module in both crates with the first - real feature area — payload types in the contract crate, behavior in the - module crate — keeping the `mod.rs` / `types.rs` / `test.rs` layout. -- [ ] Confirm `license` and `LICENSE` match the project's intended license. -- [ ] Update the security contact in `SECURITY.md`. -- [ ] Rename the TinyBus interface, object path, and member constants in - `crates/template-bus/src/names/`, and the matching `provides` / `methods` - declarations in `crates/template/src/tinybus_module/`, while keeping - `vendor/tinybus` pinned. -- [ ] Reset `CONTRACT_VERSION` in `crates/template-bus/src/version/` for the new - contract. -- [ ] Replace `ROADMAP.md` with the real plan, or delete it. -- [ ] Rewrite the "Project Structure" section below to describe this workspace. +`tinyruntime-python` is the Python **provider** for `tinyruntime`. It supplies +the language knowledge the router does not have, and it deliberately does none +of the work the router does — see "This is a provider, not a runtime manager" +below before adding anything that touches the network or the filesystem. ## Project Structure -This is a Rust 2024 cargo workspace rooted at a virtual `Cargo.toml`. Every -crate lives under `crates/`, one directory per package, each directory named for -the package it holds. There is no root package: the crate that ships as the -loadable module is `crates/template`, the same as any other member. +This is a Rust 2024 cargo workspace rooted at a virtual `Cargo.toml`. There is +one member: `crates/tinyruntime-python`, built as both an `rlib` and the +`cdylib` TinyBus loads. ```text Cargo.toml # virtual workspace: members, [workspace.package], # [workspace.dependencies], [workspace.lints] crates/ -├── template-bus/ # the wire contract: what crosses the bus, nothing else -│ ├── README.md # why the contract is its own crate -│ └── src/ -│ ├── lib.rs # crate docs + the entire public re-export surface -│ ├── names/ # interface, object path, one constant per member -│ ├── version/ # contract version and the host bind rule -│ └── / # one directory per payload family -└── template/ # the module: behavior, adapter, and the cdylib +└── tinyruntime-python/ ├── src/ - │ ├── lib.rs # crate docs + public surface, re-exporting the contract - │ ├── error/mod.rs # crate-wide `Error` and `Result` - │ ├── tinybus_module/ # TinyBus interface, ABI exports, integration tests - │ └── / # one directory per feature area - │ ├── mod.rs # module docs, wiring, smallest useful public API - │ ├── types.rs # substantial type definitions - │ └── test.rs # module-local unit tests - ├── tests/ # integration tests against the public API only - └── examples/ # runnable, compiled-in-CI usage examples -vendor/tinybus/ # pinned TinyBus host types and module SDK + │ ├── lib.rs # crate docs + public surface + │ ├── error/ # crate-wide `Error` and `Result` + │ ├── version/ # floors, ceilings, and how Python spells versions + │ ├── system/ # finding an interpreter the host already has + │ ├── distribution/ # searching the standalone release index + │ │ ├── host.rs # the host-triple table + │ │ └── index.rs # index shape and selection, testable offline + │ ├── layout/ # where an install keeps its interpreter + │ ├── harness/ # the warm-worker harness + │ │ └── pool_worker.py + │ └── tinybus_module/ # TinyBus interface, ABI exports, integration tests + ├── tests/ # integration tests, including the harness suite + └── examples/ # runnable, compiled-in-CI usage examples +vendor/tinybus/ # pinned TinyBus host types and module SDK +vendor/tinyruntime/ # pinned wire contract (`tinyruntime-bus`) docs/ -├── specs/ # behavior and architecture specifications -├── plans/ # test-first implementation plans -└── adr/ # immutable architecture decision records +├── specs/ # behaviour and architecture specifications +├── plans/ # test-first implementation plans +└── adr/ # immutable architecture decision records ``` -### The two-crate split - -`crates/template-bus` holds every type that crosses the bus and the names of the -members that carry them. It has no transport, no runtime, and no behavior, and -CI asserts it stays that way. A host that only makes calls depends on it alone. - -`crates/template` depends on it and re-exports all of it, so -`template::GreetRequest` and `template_bus::GreetRequest` are the *same* type -rather than structural twins. That direction is load-bearing: a parallel set of -payload types for hosts would mean a conversion at every call site that nothing -checks. - -The rule for deciding where something goes: a payload type describes what a -frame carries and belongs in the contract; anything that answers a frame, holds -a connection, or touches an engine belongs in the module crate. - -Add a crate by creating `crates//` — `members = ["crates/*"]` picks it up -by existing. Inherit `version`, `edition`, `rust-version`, `license`, and -`repository` from `[workspace.package]`, take shared dependencies from -`[workspace.dependencies]`, and opt into the shared lint set with: - -```toml -[lints] -workspace = true -``` - -Each feature area belongs in a focused module directory under a crate's `src/`. -A module root explains the module, wires its pieces together, and exposes the -smallest useful API. Move substantial type definitions into `types.rs` and put +### This is a provider, not a runtime manager + +`tinyruntime` — the router, in its own repository — owns everything that is the +same for every language: downloading an archive, verifying its digest, unpacking +it, promoting it into a cache atomically, reusing it on the next start, and +keeping a bounded set of warm interpreter processes in front of it. + +This repository answers five questions about Python and nothing else: + +| Member | What it answers | +| --- | --- | +| `Describe` | what this provider is and what it targets by default | +| `DetectSystem` | whether the host already has a usable interpreter | +| `SelectDistribution` | which standalone build to install for this machine | +| `Layout` | where the interpreter is inside an unpacked install | +| `Harness` | what a warm Python worker is | + +**This module downloads nothing, installs nothing, and starts no worker.** The +one network call it is allowed to make is reading the release index, so the +distribution it names carries the digest the router verifies against. A change +that fetches an archive, writes to a cache, or spawns a worker here is a change +that belongs in the router instead. + +The contract lives in `vendor/tinyruntime/crates/tinyruntime-bus`, vendored so +the router and every provider share one definition of these types. Do not define +a local copy of a payload type: a parallel set would mean a conversion at every +call site that nothing checks. + +### Two things Python does that Node.js does not + +**A request names a version floor, not an exact version.** The standalone +channel publishes a moving set of builds rather than one archive per version, so +an exact pin would stop resolving the moment that build rotated out. Selection +is therefore a search — filter to this host, filter to the range, then rank — and +that search must stay testable without a network. + +**A pooled job cannot be isolated.** There is no worker thread to run it in and +no safe way to kill one, so jobs on a warm worker share module state, +`os.environ`, and logging configuration. The harness gives each job fresh globals +and captures output at the file-descriptor level; the router recycles workers +after a job budget. That bounds the leakage without eliminating it, which is why +a host opts into Python pooling rather than getting it by default. Do not +document or assume isolation this harness cannot provide. + +### The wire contract + +`vendor/tinyruntime` is registered as a git submodule and pinned by its gitlink. +Do not edit vendored code from this repository. Make contract changes in the +`tinyruntime` repository, push them there, then update this repository's gitlink +in a separate commit. + +Each feature area belongs in a focused module directory under `src/`. A module +root explains the module, wires its pieces together, and exposes the smallest +useful API. Move substantial type definitions into `types.rs` and put module-local unit tests in a dedicated `test.rs`, wired from the bottom of the module root with: @@ -109,13 +106,11 @@ mod test; Do not accumulate inline `mod tests` blocks in implementation files, and do not let a general-purpose `utils.rs` or `helpers.rs` grow — those are a symptom of a -missing module. Prefer many small modules that each do one thing well over few -broad ones. +missing module. -Keep public exports centralized in each crate's `src/lib.rs` so downstream users -have one predictable surface. Put shared error variants in -`crates/template/src/error/mod.rs` and return the crate-wide `Result` from -fallible public APIs. +Keep public exports centralized in `src/lib.rs` so downstream users have one +predictable surface. Put shared error variants in `src/error/mod.rs` and return +the crate-wide `Result` from fallible public APIs. ## Build And Test @@ -133,8 +128,9 @@ Supporting commands: - `cargo fmt --all` — format before committing. - `cargo test ` — run a focused subset while iterating. -- `cargo test -p template-bus` — run one crate's suite. -- `cargo run -p template --example basic` — run the bundled example. +- `cargo test --test harness_protocol` — run the harness suite against a real + `python`. It skips when the machine has none. +- `cargo run -p tinyruntime-python --example basic` — run the bundled example. - `cargo doc --no-deps --all-features` — build the rustdoc CI also builds with `RUSTDOCFLAGS="-D warnings"`. - `cargo test --doc` — run doctests alone when editing documentation examples. @@ -185,8 +181,6 @@ add one: - gate anything optional behind a Cargo feature, documented in `Cargo.toml`; - declare it once in the root `[workspace.dependencies]` when more than one crate needs it, and take it with `{ workspace = true }`; -- never add one to `crates/template-bus` that pulls in a transport, an async - runtime, an HTTP client, or a native library — CI fails the build if you do; - leave a comment above the entry explaining *why* the crate is needed and what uses it — see the existing entries for the expected tone; - prefer well-maintained crates with a compatible license. @@ -211,8 +205,16 @@ new module capability requires more. ## Testing -- Module-local unit tests live in `crates//src//test.rs` and may - touch private items. +- Module-local unit tests live in `src//test.rs` and may touch private + items. +- The harness is the one part of this crate that is not Rust, so + `tests/harness_protocol.rs` stands in for the router: it listens on loopback, + launches the harness the way the router would, completes the handshake, and + runs jobs through it. Any change to `pool_worker.py` needs a test there — + especially anything touching descriptor capture or the working directory. +- Distribution selection is testable without a network, and must stay that way. + `src/distribution/index.rs` holds the shape and the ranking; test it against a + realistic index body including the assets that must be ignored. - Integration tests live in `crates//tests/` and exercise only the public API — they are the regression suite for the crate's contract. - Payload types pin their serde representation in a unit test. That @@ -295,7 +297,7 @@ Releases run from `.github/workflows/release.yml` via a manual an interrupted release after its version commit and tag exist. The workflow re-runs the full validation suite, computes the next version, updates the root `[workspace.package]` version and `Cargo.lock`, commits and tags -`vX.Y.Z`, builds `crates/template` as a TinyBus module for every supported +`vX.Y.Z`, builds `crates/tinyruntime-python` as a TinyBus module for every supported platform, pushes, and creates an immutable GitHub release with installable native packages. diff --git a/Cargo.lock b/Cargo.lock index b4f454e..1039687 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -28,6 +28,18 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "base64" version = "0.23.1" @@ -68,6 +80,32 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -153,6 +191,48 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -160,8 +240,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -171,8 +253,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", + "rand_core", + "wasm-bindgen", ] [[package]] @@ -191,12 +276,198 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + [[package]] name = "httparse" version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -207,12 +478,29 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "ipnet" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" + [[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + [[package]] name = "libc" version = "0.2.189" @@ -225,12 +513,24 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + [[package]] name = "log" version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "memchr" version = "2.8.3" @@ -247,6 +547,17 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -265,6 +576,15 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -274,6 +594,62 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.47" @@ -289,6 +665,70 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + [[package]] name = "ring" version = "0.17.14" @@ -303,6 +743,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + [[package]] name = "rustix" version = "1.1.4" @@ -337,6 +783,7 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ + "web-time", "zeroize", ] @@ -351,6 +798,18 @@ dependencies = [ "untrusted", ] +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "serde" version = "1.0.229" @@ -403,18 +862,68 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "shlex" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "subtle" version = "2.6.1" @@ -443,6 +952,26 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tar" version = "0.4.46" @@ -467,26 +996,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "template" -version = "0.2.1" -dependencies = [ - "serde_json", - "template-bus", - "thiserror", - "tinybus", - "tinybus-module", - "tokio", -] - -[[package]] -name = "template-bus" -version = "0.2.1" -dependencies = [ - "serde", - "serde_json", -] - [[package]] name = "thiserror" version = "2.0.20" @@ -547,6 +1056,55 @@ dependencies = [ "tracing", ] +[[package]] +name = "tinyruntime-bus" +version = "0.2.1" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "tinyruntime-python" +version = "0.2.1" +dependencies = [ + "reqwest", + "serde", + "serde_json", + "tempfile", + "thiserror", + "tinybus", + "tinybus-module", + "tinyruntime-bus", + "tokio", + "tracing", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.53.1" @@ -554,8 +1112,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", + "libc", + "mio", "pin-project-lite", + "signal-hook-registry", + "socket2", "tokio-macros", + "windows-sys 0.61.2", ] [[package]] @@ -569,6 +1132,16 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "toml" version = "0.8.23" @@ -610,6 +1183,51 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.44" @@ -641,6 +1259,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -659,7 +1283,7 @@ version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "972d7902c8735f2695410b8aed7df6ed12a47394aa1c8d7af49f0497b731a94d" dependencies = [ - "base64", + "base64 0.23.1", "flate2", "log", "percent-encoding", @@ -676,24 +1300,126 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da5f78b09e6941e1a0f2e30e695e4b120377b54d5e0aec11b594bb57b3971613" dependencies = [ - "base64", + "base64 0.23.1", "http", "httparse", "log", ] +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + [[package]] name = "utf8-zero" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webpki-roots" version = "1.0.9" @@ -800,6 +1526,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + [[package]] name = "xattr" version = "1.6.1" @@ -810,12 +1542,89 @@ dependencies = [ "rustix", ] +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + [[package]] name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "zip" version = "2.4.2" diff --git a/Cargo.toml b/Cargo.toml index fae3c38..029d0d2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,15 +22,14 @@ version = "0.2.1" edition = "2024" rust-version = "1.88" license = "GPL-3.0-only" -repository = "https://github.com/tinyhumansai/rust-template" +repository = "https://github.com/tinyhumansai/tinyruntime-python" [workspace.dependencies] -# The wire contract. `crates/template` depends on it and re-exports it, so a -# host that only makes calls takes this crate alone. -# No `version` requirement on purpose: the workspace version moves on every -# release, and a pinned requirement here would stop resolving the moment it did. -# Nothing in this workspace is published, so the path is the whole address. -template-bus = { path = "crates/template-bus" } +# The wire contract this module implements the provider half of. It is vendored +# rather than defined here on purpose: the router and every provider must agree +# on one definition of these types, and a second copy would be a conversion at +# every call site that nothing checks. +tinyruntime-bus = { path = "vendor/tinyruntime/crates/tinyruntime-bus" } # TinyBus defines the message types, interface macro, and frozen module ABI # used by the generated integration. Socket and CLI features are unnecessary # here. @@ -49,8 +48,26 @@ thiserror = "2" serde = { version = "1", features = ["derive"] } # Positional argument arrays and the module configuration blob. serde_json = "1" -# Module integration tests exercise the real asynchronous in-memory TinyBus. -tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] } +# Reading the release index this provider selects a distribution from, and +# probing a host interpreter's version without blocking the bus. +tokio = { version = "1", features = [ + "macros", + "rt-multi-thread", + "time", + "process", + "fs", +] } +# Reading the python-build-standalone release index, which is JSON over HTTPS. +# `rustls` rather than the platform TLS stack so the module has no native TLS +# build dependency. +reqwest = { version = "0.12", default-features = false, features = [ + "rustls-tls", + "json", +] } +# Diagnostics, through the same facade tinybus itself uses. +tracing = "0.1" +# Scratch directories for the tests that probe a fabricated install tree. +tempfile = "3" # Lints apply to every member that opts in with `[lints] workspace = true`, and # to every target of that member. CI runs clippy with `-D warnings`, so anything diff --git a/MODULE.md b/MODULE.md index 651906e..2590abc 100644 --- a/MODULE.md +++ b/MODULE.md @@ -1,15 +1,48 @@ -# Template TinyBus Module +# tinyruntime-python TinyBus Module -This package contains the native `template` module for TinyBus module ABI -v1. Install only the archive matching the host operating system and +This package contains the native `tinyruntime-python` module for TinyBus module +ABI v1. Install only the archive matching the host operating system and architecture. -The module claims `ai.tinyhumans.template.Greeting`, serves the object at -`/ai/tinyhumans/template/Greeting`, and provides the `Greet` method. The -method accepts a `GreetRequest` and returns a `GreetResponse` carrying -`Hello, !`; empty names are rejected. Both payload types, the interface -name, the object path, and the member names are published as the `template-bus` -crate, so a host names them from a library rather than by string literal. +The module claims `ai.tinyhumans.runtime.python.Provider`, implements the shared +provider interface `ai.tinyhumans.runtime.Provider` at +`/ai/tinyhumans/runtime/python/Provider`, and provides `Describe`, `DetectSystem`, +`SelectDistribution`, `Layout`, and `Harness`. Every payload type and every name +is published as the `tinyruntime-bus` crate, so a host names them from a library +rather than by string literal. + +## It is not useful on its own + +This is a provider. It answers questions about Python and performs no +installation or execution of its own. Load it alongside the `tinyruntime` +module, which routes the `python` language to the bus name above. + +Load order does not matter: the router contacts providers per call, not at +setup, so this module may be loaded before or after it. + +## Configuration + +None. The module takes no configuration — everything it needs arrives with each +request, so a host that changes a version floor or a cache directory does not +reload anything. + +## What it does to the machine + +Nothing persistent. It runs ` --version` to probe candidates, and +it reads the `astral-sh/python-build-standalone` release index when the router +asks which build to install. It writes no files and starts no long-lived process. + +## A note on pooled execution + +The warm-worker harness this module supplies cannot isolate a job the way the +JavaScript one can: CPython offers no safe way to terminate a running thread. On +a warm worker, jobs share module state, `os.environ`, and logging configuration. +Each job does get fresh globals, its output is captured at the file-descriptor +level, and the router recycles workers after a job budget — but a host that needs +strict isolation between Python jobs should leave pooling off and accept a +short-lived interpreter per execution. + +## Installing The archive contains one `.so`, `.dylib`, or `.dll` plus `modules.toml`. Keep those files together when copying them into a TinyBus module directory. The @@ -22,8 +55,8 @@ archive. Install directly from a tagged release with: ```sh tinybus modules load-github \ - https://github.com/tinyhumansai/rust-template/releases/tag/v0.1.5 \ - template-0.1.5-ubuntu-24.04-x86_64.tar.gz \ + https://github.com/tinyhumansai/tinyruntime-python/releases/tag/v0.1.0 \ + tinyruntime-python-0.1.0-ubuntu-24.04-x86_64.tar.gz \ ``` diff --git a/README.md b/README.md index 67a4e39..064611e 100644 --- a/README.md +++ b/README.md @@ -1,156 +1,93 @@ -# Rust Template - -A production-ready Rust 2024 TinyBus module template used by TinyHumans AI. It -ships the workspace layout, TinyBus ABI adapter, error handling, testing, -documentation, CI, and multi-platform release workflow that every new -integration in this organization starts from. - -It is a two-crate cargo workspace. `crates/template-bus` is the wire contract — -member names, payload types, and the contract version, with no transport and no -behavior — and `crates/template` is the implementation, built as both an `rlib` -and the `cdylib` TinyBus loads. A host that only makes calls depends on the -contract crate alone and compiles neither the module nor `tinybus` itself. - -## Use This Template - -Choose **Use this template** on GitHub, create a repository, then work through -the checklist at the top of [`AGENTS.md`](AGENTS.md): - -- rename the `crates/template` and `crates/template-bus` directories and the - `name` fields in their manifests, and set the shared `description`, - `repository`, `keywords`, and `categories`; -- update this README and the crate documentation in `crates/template/src/lib.rs`; -- replace the placeholder `greeting` module with the first real feature area, in - both crates: the payload types in the contract, the behavior in the module; -- rename the TinyBus interface, object path, and member constants in - `crates/template-bus/src/names/`, and the matching `provides` / `methods` - declarations in `crates/template/src/tinybus_module/`; -- update the security contact and repository links in the community files; -- replace `ROADMAP.md` with the real plan, or delete it; -- change the license if GPL-3.0-only is not appropriate. - -Search for `template` and `template_bus` to find every remaining -template-specific value. - -## What You Get - -| Area | What is configured | -| --- | --- | -| Layout | A cargo workspace under `crates/`, split into a dependency-light wire contract and the module that implements it; directory modules with `mod.rs` / `types.rs` / `test.rs`, a crate-wide error type, integration tests, and a runnable example | -| Lints | `unsafe_code` forbidden, `missing_docs`, clippy `all` + `pedantic`, no `unwrap`/`expect`/`panic`/`todo` in library code — all declared once in `[workspace.lints]` so every crate, local run, and CI run agree | -| CI | Format, clippy, build, test (default and all features), a run of the bundled example, an assertion that the contract crate stays transport-free, at least 90% line coverage in every source file, rustdoc with `-D warnings`, an MSRV build, and a `cargo-deny` supply-chain check | -| Release | Manual `workflow_dispatch` bump that validates, versions, tags, and creates installable native module packages for every supported platform | -| Community | Issue and pull request templates, Dependabot, contributing, security, support, and code of conduct docs | -| Agents | [`AGENTS.md`](AGENTS.md) as the single source of truth, symlinked as `CLAUDE.md`, plus a `.claude/settings.json` allowlist for the standard commands | -| Vendor | TinyBus host types and module SDK pinned as the `vendor/tinybus` build-time submodule | - -## Layout - -```text -Cargo.toml # virtual workspace: members, shared metadata, lints -crates/ -├── template-bus/ # the wire contract — what crosses the bus -│ ├── README.md # why the contract is its own crate -│ └── src/ -│ ├── lib.rs # crate docs + the entire public re-export surface -│ ├── names/ # interface, object path, one constant per member -│ ├── greeting/ # payload types, one directory per family -│ │ ├── mod.rs -│ │ ├── types.rs -│ │ └── test.rs -│ └── version/ # contract version and the host bind rule -└── template/ # the module — behavior, adapter, and the cdylib - ├── src/ - │ ├── lib.rs # crate docs + public surface, re-exporting the contract - │ ├── error/ # crate-wide `Error` and `Result` - │ ├── greeting/ # one directory per feature area - │ └── tinybus_module/ # bus interface, setup, and ABI v1 exports - ├── tests/ - │ └── public_api.rs # integration tests against the public API only - └── examples/ - ├── basic.rs # ordinary library API usage - ├── verify_module.rs # local dynamic-module verification - └── verify_github_release.rs # tagged-release download and bus call -vendor/ -└── tinybus/ # pinned TinyBus git submodule -docs/ -├── README.md # documentation index and conventions -├── specs/ # behavior and architecture specifications -├── plans/ # implementation-ordered delivery plans -└── adr/ # immutable architecture decision records -``` +# tinyruntime-python -The split is the point. A payload type describes what a frame carries; the -behavior that answers it is a different obligation. `template` depends on -`template-bus` and re-exports all of it, so `template::GreetRequest` and -`template_bus::GreetRequest` are the *same* type rather than structural twins, -and a host is never forced to choose between linking the whole module and -redefining the vocabulary. See -[`crates/template-bus/README.md`](crates/template-bus/README.md). +The Python provider for [`tinyruntime`](https://github.com/tinyhumansai/tinyruntime). -Within each crate, feature areas use directory modules: implementation and -exports live in `mod.rs`, substantial types move to `types.rs`, and unit tests -live in `test.rs`. [`AGENTS.md`](AGENTS.md) holds the complete repository -guidance, and `CLAUDE.md` is a symlink to it so every coding agent reads one -source of truth. +## What this is -## Development +One half of a deliberate split. -Clone with submodules, or initialize them before building: +`tinyruntime` — the router — owns everything that is the same for every +language: downloading an archive, verifying its digest, unpacking it, promoting +it into a cache atomically, reusing it on the next start, and keeping a bounded +set of warm interpreter processes in front of it. -```sh -git submodule update --init --recursive +This module owns everything that is true only of Python. It answers five +questions and does nothing else: + +| Member | What it answers | +| --- | --- | +| `Describe` | what this provider is and what it targets by default | +| `DetectSystem` | whether the host already has a usable interpreter | +| `SelectDistribution` | which standalone build to install for this machine | +| `Layout` | where the interpreter is inside an unpacked install | +| `Harness` | what a warm Python worker is | + +It downloads nothing, installs nothing, and starts no worker. Every answer it +gives is a description the router acts on. + +## The Python knowledge, in four parts + +**A request names a floor, not a version.** `3.12` means "3.12 or newer". That +follows from the channel: `astral-sh/python-build-standalone` publishes a moving +set of builds rather than one archive per version, so an exact pin would stop +resolving the moment that build rotated out. A caller that needs to stay off a +newer series sets an exclusive ceiling — which is what keeps selection away from +a 3.15 release candidate sitting in the same index as the 3.12 builds it wants. + +**`python3.12` is tried before `python3`.** On a machine with several +interpreters installed, `python3` is whatever the distribution decided, and it is +often older than the versioned binary sitting right next to it. + +**Every build unpacks into a directory called `python`.** Whatever the version. +So the install directory in the cache is named from the asset rather than from +the archive's contents — otherwise every version would claim the same directory +and each install would silently replace the last. + +**A pooled job cannot be isolated, and the module says so.** There is no worker +thread to run it in and no safe way to kill one, so jobs on a warm worker share +module state, `os.environ`, and logging configuration. The harness gives each job +fresh globals, captures output at the file-descriptor level — so `os.write(1, +...)`, subprocesses, and native extensions are captured too — and enforces a soft +deadline with `SIGALRM` on Unix. The router recycles workers after a job budget, +which bounds the leakage without eliminating it. That is why a host opts into +Python pooling rather than getting it by default. + +## Using it + +Load it alongside `tinyruntime`, which routes `python` to the well-known name +this module claims (`ai.tinyhumans.runtime.python.Provider`). A host then asks +the router to run Python and never addresses this module directly. + +```rust +use tinyruntime_python::{DEFAULT_VERSION, parse_version, satisfies}; + +let installed = parse_version("Python 3.13.1").expect("a version"); +assert!(satisfies(installed, DEFAULT_VERSION, None)); +assert!(!satisfies(installed, DEFAULT_VERSION, Some("3.13"))); ``` +## Supported hosts + +Whatever the standalone channel publishes: macOS, Linux, and Windows on x86-64 +and ARM64. Anything else is refused by name rather than guessed at. + +## Building + ```sh +git submodule update --init --recursive cargo fmt --all -- --check cargo clippy --all-targets --all-features -- -D warnings cargo build --all-targets --all-features cargo test --all-features -cargo run -p template --example basic -cargo build -p template --release --lib # produces the installable cdylib ``` -Those four checks are exactly what CI runs. Optional extras: - -```sh -cargo doc --no-deps --all-features # CI builds this with RUSTDOCFLAGS="-D warnings" -cargo deny check all # supply-chain check; see deny.toml -cargo install cargo-llvm-cov # once, before running the coverage gate -.github/scripts/check-file-coverage.sh 90 coverage.json -``` +The harness suite in `tests/harness_protocol.rs` launches a real `python` and +drives the protocol end to end. It skips when the machine has none, so the suite +stays hermetic on a runner without Python. -## Releasing - -Run the **Release** workflow from the Actions tab with a `patch`, `minor`, or -`major` bump. Use `current` only to resume an interrupted release whose version -commit and tag already exist. The workflow revalidates the workspace, versions -and tags it — one `[workspace.package]` version that every member inherits — -builds `crates/template` as a TinyBus `cdylib`, and creates a GitHub release. -Assets follow `template--.` and contain the -native module, its SHA-256 `modules.toml`, license, and -[`MODULE.md`](MODULE.md). Every release also publishes `checksum.toml`, which -TinyBus uses to verify an archive before extraction. The workflow loads the -published Ubuntu archive through TinyBus's GitHub release API and calls its -`Greet` method before declaring the release successful. TinyBus itself is not -shipped by this repository; the pinned submodule is the build-time SDK. The stable native -matrix covers Ubuntu 22.04 and 24.04 on x86_64 and ARM64; Fedora 43 and 44 on -x86_64 and ARM64; rolling Arch Linux on its officially supported x86_64 -architecture; macOS 15 and 26 on Intel and Apple Silicon; Windows Server 2022 -and 2025 on x86_64; and Windows 11 on ARM64. Preview, deprecated, and unofficial -architecture images are not release gates. Do not hand-edit the version in the -root `Cargo.toml`. - -## Documentation - -- [`AGENTS.md`](AGENTS.md) — repository guidelines for humans and agents -- [`CONTRIBUTING.md`](CONTRIBUTING.md) — how to propose a change -- [`docs/specs/`](docs/specs/README.md) — behavior and architecture specs -- [`docs/plans/`](docs/plans/README.md) — test-first implementation plans -- [`docs/adr/`](docs/adr/0001-record-architecture-decisions.md) — architecture - decision records -- [`SECURITY.md`](SECURITY.md) — how to report a vulnerability +See [`AGENTS.md`](AGENTS.md) for the working agreement, and +[`MODULE.md`](MODULE.md) for installing a release artifact. ## License -GPL-3.0-only. See [LICENSE](LICENSE). +GPL-3.0-only. See [`LICENSE`](LICENSE). diff --git a/ROADMAP.md b/ROADMAP.md index 1134024..ed4e1e1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,26 +1,36 @@ # Roadmap -Replace this file with the real plan for the crate generated from this -template, or delete it if the project does not need a public roadmap. - -Keep it short and honest: what exists, what is next, and what is deliberately -out of scope. A roadmap that lists everything is a roadmap nobody trusts. +What is deliberately not built yet, and what would have to be true before it is. ## Shipped -- module layout, crate-wide error type, and the public re-export surface -- lint configuration in `[lints]`, enforced identically locally and in CI -- CI: format, clippy, build, test, per-file coverage, rustdoc, MSRV, and - supply-chain checks -- a manual release workflow that versions, tags, publishes to crates.io, and - creates a GitHub release with crate and TinyBus runtime/module assets +- Host interpreter detection, series-specific candidates first, bounded so a + wedged binary costs a probe rather than a hang. +- Standalone build selection: filtered to this host and to the requested range, + newest first, stripped builds preferred, tested without a network. +- Install layout, including the `python/` wrapper directory every build ships. +- The warm-worker harness: fresh globals per job, descriptor-level capture, + `SIGALRM` soft deadlines, and a protocol a job cannot forge. ## Next -- the first real feature area, replacing the placeholder `greeting` module -- module-level `README.md` and `docs/spec/` entries as modules grow +**Package installation.** `pip` is reported in the layout but nothing calls it. +Whether installing dependencies belongs behind a provider member or stays a host +concern is an open question in the contract, not here. + +**Virtual environments.** A host that wants isolated dependencies per workload +currently builds that itself. The layout has everything needed to create one; the +question is whose job it is. + +**Free-threaded builds.** The channel publishes `freethreaded` variants. They +would change the isolation story for pooled jobs considerably, which makes them +interesting — and makes them something to adopt deliberately rather than by +having selection pick one up. + +## Not planned -## Out Of Scope +**Downloading or installing anything here.** That is the router's half, and +duplicating it would give every language its own subtly different pipeline. -- anything that cannot be tested deterministically -- convenience wrappers that hide the crate's error taxonomy from callers +**Claiming isolation the harness cannot provide.** CPython cannot safely kill a +running thread. The honest position — bounded leakage, opt-in pooling — stays. diff --git a/crates/template-bus/Cargo.toml b/crates/template-bus/Cargo.toml deleted file mode 100644 index a30dd85..0000000 --- a/crates/template-bus/Cargo.toml +++ /dev/null @@ -1,24 +0,0 @@ -[package] -name = "template-bus" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -description = "The TinyBus wire contract for the template module: member names, payload types, and the contract version." -documentation = "https://docs.rs/template-bus" -readme = "README.md" -keywords = ["tinybus", "module", "contract", "template"] -categories = ["development-tools"] -publish = false - -# Deliberately dependency-light: this is the crate a host links to talk to the -# loadable module, so it must cost that host almost nothing. Nothing here may -# pull in `tinybus`, an async runtime, an HTTP client, or a native library — -# see `src/lib.rs` for why the transport in particular is absent. CI asserts it. -[dependencies] -serde = { workspace = true } -serde_json = { workspace = true } - -[lints] -workspace = true diff --git a/crates/template-bus/README.md b/crates/template-bus/README.md deleted file mode 100644 index 7f8e99c..0000000 --- a/crates/template-bus/README.md +++ /dev/null @@ -1,100 +0,0 @@ -# template-bus - -Every type that crosses the template module's `TinyBus` boundary, and the names -of the members that carry them. - -The template ships as a loadable module so a host does not compile the -implementation: `crates/template` is built as a `cdylib` and exports one object. -A host can load that binary but cannot `use` anything out of it, so the payload -vocabulary has to be published as an ordinary library. This is it. - -| module | what it holds | -| ---------- | ------------------------------------------------------------ | -| `names` | interface name, object path, one constant per member | -| `greeting` | the value vocabulary: the `Greet` request and response | -| `version` | `CONTRACT_VERSION` and the bind rule a host applies to it | - -Two dependencies, both pure Rust: `serde` and `serde_json`. - -## This crate sits underneath `template` - -`template` **depends on this crate and re-exports all of it**. That direction -matters, and it is the opposite of the obvious one. - -A *host* needs the payload types and needs nothing else: it loads the module and -makes calls, so it names `GreetRequest` and `GreetResponse` but implements no -behavior and links no transport. Making it depend on the whole module crate — and -through it on `tinybus`, `tokio`, and the module SDK — to spell a payload type -would be the wrong shape. - -The alternative, a parallel set of payload types for hosts, is worse: a -`GreetRequest` defined twice is two distinct types, with a conversion at every -call site that nothing checks. One definition, here, at the bottom. - -Because the re-export is by module as well as by item, `template::GreetRequest`, -`template::names::OBJECT_PATH`, and `template_bus::greeting::GreetRequest` all -resolve to the same items, not twins. - -So: a module author depends on `template` and gets behavior and vocabulary. A -host depends on `template-bus` and gets vocabulary alone. - -## What is deliberately absent - -**No behavior.** `greet` lives in `crates/template`. A payload type describes -what a frame carries, not what the module does with it. The split is readable -off the path: a name here is data, a name there is an obligation. - -**No transport.** This crate does not depend on `tinybus` and holds no -connection, client, or codec. A host already owns its connection — its reconnect -policy, its timeouts, its tracing — and the useful part is the vocabulary. - -That is also structural, not just preference: `tinybus` is vendored as a -submodule whose manifest inherits fields from its own nested -`[workspace.package]`. Keeping the contract crate transport-free is what keeps -it down to two dependencies and what lets anything in the workspace — or outside -it — depend on it freely. CI asserts the dependency tree stays that way. - -## Making a call - -Arguments travel as a positional JSON array — `#[tinybus::interface]` decodes -them into a tuple — and the member name comes from `names`: - -```rust,ignore -use template_bus::{names, GreetRequest, GreetResponse}; - -let proxy = connection.proxy(names::INTERFACE, names::OBJECT_PATH, names::INTERFACE)?; -let reply: GreetResponse = proxy - .call(names::methods::GREET, (GreetRequest::new("Ferris"),)) - .await?; -assert_eq!(reply.greeting, "Hello, Ferris!"); -``` - -Nothing above is a string literal at a call site. Renaming the interface, the -path, or a member is therefore a compile error in every consumer rather than an -`UnknownMethod` discovered at runtime. - -## Staying in step with the module - -`names::METHODS` lists every member in dispatch order. `crates/template` asserts -its served members against that list, so a method added to the interface without -an entry here fails that crate's tests rather than surfacing in a host. - -## Versioning - -`CONTRACT_VERSION` describes *this vocabulary*, not the package. Bump its major -component when a payload's wire form changes incompatibly or a member is removed -or renamed, and its minor component when a member or an optional field is added. -It is deliberately independent of the package version the release workflow owns, -which tracks the shipped artifact. - -The payload tests pin the serde representation, because that representation is -the wire form: a host and a module that disagree about a field name fail at -runtime with a decode error, so the shape is asserted rather than assumed. - -## Generating a project from the template - -Rename the interface, the object path, and the member constants in `names` -together, replace `greeting` with the first real payload family, and reset -`CONTRACT_VERSION` to `(1, 0)` for the new contract. Keep the crate -dependency-light: the moment it links a transport or a runtime, the reason it -exists is gone. diff --git a/crates/template-bus/src/greeting/mod.rs b/crates/template-bus/src/greeting/mod.rs deleted file mode 100644 index f810aab..0000000 --- a/crates/template-bus/src/greeting/mod.rs +++ /dev/null @@ -1,17 +0,0 @@ -//! The payloads the `Greet` member exchanges. -//! -//! A module root like this one documents the module, wires its pieces together, -//! and exposes the smallest useful API. The type definitions live in the -//! sibling `types.rs`, and the unit tests in `test.rs`, wired in at the bottom -//! of this file. -//! -//! Replace this module with the first real payload family the module carries. -//! Payload types are `serde`-derived, `#[non_exhaustive]`, and hold owned data: -//! they are decoded from a frame, so they can borrow nothing from the caller. - -mod types; - -pub use types::{GreetRequest, GreetResponse}; - -#[cfg(test)] -mod test; diff --git a/crates/template-bus/src/greeting/test.rs b/crates/template-bus/src/greeting/test.rs deleted file mode 100644 index 1a30000..0000000 --- a/crates/template-bus/src/greeting/test.rs +++ /dev/null @@ -1,65 +0,0 @@ -//! Unit tests for the `Greet` payloads. -//! -//! These pin the serde representation. It is the wire form: a host and a module -//! that disagree about a field name fail at runtime with a decode error, so the -//! shape is asserted here rather than assumed. - -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - -use super::{GreetRequest, GreetResponse}; - -#[test] -fn a_request_serializes_to_its_wire_form() { - let encoded = serde_json::to_value(GreetRequest::new("Ferris")).unwrap(); - assert_eq!(encoded, serde_json::json!({ "name": "Ferris" })); -} - -#[test] -fn a_response_serializes_to_its_wire_form() { - let encoded = serde_json::to_value(GreetResponse::new("Hello, Ferris!")).unwrap(); - assert_eq!(encoded, serde_json::json!({ "greeting": "Hello, Ferris!" })); -} - -#[test] -fn a_request_round_trips_through_json() { - let request = GreetRequest::new(" Ferris "); - let encoded = serde_json::to_string(&request).unwrap(); - assert_eq!( - serde_json::from_str::(&encoded).unwrap(), - request - ); -} - -#[test] -fn a_response_round_trips_through_json() { - let response = GreetResponse::new("Hello, Ferris!"); - let encoded = serde_json::to_string(&response).unwrap(); - assert_eq!( - serde_json::from_str::(&encoded).unwrap(), - response - ); -} - -#[test] -fn a_request_missing_its_name_is_rejected() { - let decoded = serde_json::from_value::(serde_json::json!({})); - assert!(decoded.is_err()); -} - -#[test] -fn a_response_missing_its_greeting_is_rejected() { - let decoded = serde_json::from_value::(serde_json::json!({})); - assert!(decoded.is_err()); -} - -#[test] -fn constructors_accept_both_borrowed_and_owned_names() { - assert_eq!( - GreetRequest::new(String::from("Ferris")), - GreetRequest::new("Ferris") - ); - assert_eq!( - GreetResponse::new(String::from("Hi")), - GreetResponse::new("Hi") - ); -} diff --git a/crates/template-bus/src/greeting/types.rs b/crates/template-bus/src/greeting/types.rs deleted file mode 100644 index d70b376..0000000 --- a/crates/template-bus/src/greeting/types.rs +++ /dev/null @@ -1,54 +0,0 @@ -//! Request and response types for the `Greet` member. - -use serde::{Deserialize, Serialize}; - -/// The argument to [`crate::names::methods::GREET`]. -/// -/// The module trims surrounding whitespace from [`GreetRequest::name`] and -/// rejects a name that is empty once trimmed. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[non_exhaustive] -pub struct GreetRequest { - /// The name to greet. - pub name: String, -} - -impl GreetRequest { - /// Builds a request greeting `name`. - /// - /// # Examples - /// - /// ``` - /// # use template_bus::GreetRequest; - /// assert_eq!(GreetRequest::new("Ferris").name, "Ferris"); - /// ``` - #[must_use] - pub fn new(name: impl Into) -> Self { - Self { name: name.into() } - } -} - -/// The reply from [`crate::names::methods::GREET`]. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[non_exhaustive] -pub struct GreetResponse { - /// The rendered greeting. - pub greeting: String, -} - -impl GreetResponse { - /// Builds a reply carrying `greeting`. - /// - /// # Examples - /// - /// ``` - /// # use template_bus::GreetResponse; - /// assert_eq!(GreetResponse::new("Hello, Ferris!").greeting, "Hello, Ferris!"); - /// ``` - #[must_use] - pub fn new(greeting: impl Into) -> Self { - Self { - greeting: greeting.into(), - } - } -} diff --git a/crates/template-bus/src/lib.rs b/crates/template-bus/src/lib.rs deleted file mode 100644 index a1857d1..0000000 --- a/crates/template-bus/src/lib.rs +++ /dev/null @@ -1,74 +0,0 @@ -//! Every type that crosses the template module's `TinyBus` boundary, and the -//! names of the members that carry them. -//! -//! This crate ships as a loadable `TinyBus` module: `crates/template` is built -//! as a `cdylib` and exports one object. A host that loads that binary can call -//! into it but cannot `use` anything out of it, so the payload vocabulary has -//! to be published as an ordinary library. This is that library. -//! -//! # What is here -//! -//! - [`names`] — the interface name, the object path, and one constant per -//! member, plus [`names::METHODS`] listing them in dispatch order. -//! - [`greeting`] — the value vocabulary: the request and response payloads the -//! `Greet` member exchanges. -//! - [`version`] — [`CONTRACT_VERSION`] and the [`is_compatible`] bind rule. -//! -//! # What is deliberately not here -//! -//! **No behavior.** The `greet` implementation lives in `crates/template`, -//! which depends on this crate and re-exports it. A payload type describes what -//! a frame carries, not what the module does with it. -//! -//! **No transport.** This crate does not depend on `tinybus` and holds no -//! connection, client, or codec. A host already owns its connection — its -//! reconnect policy, its timeouts, its tracing — and the useful part is the -//! vocabulary, not another wrapper around it. -//! -//! That is also a structural necessity, not only a preference: `tinybus` is -//! vendored as a submodule whose manifest inherits fields from its own nested -//! `[workspace.package]`. A crate that every workspace member can depend on has -//! to stay transport-free, and staying transport-free is what keeps this crate -//! down to two pure-Rust dependencies. -//! -//! # This crate sits underneath the implementation, not beside it -//! -//! `template` **depends on this crate and re-exports all of it**, so -//! `template::GreetRequest` and `template_bus::greeting::GreetRequest` are the -//! *same type*, not structural twins. Defining a parallel set of payload types -//! for hosts would mean a conversion at every call site that nothing checks. -//! One definition, here, at the bottom. -//! -//! So: a module author depends on `template` and gets behavior and vocabulary. -//! A host depends on `template-bus` and gets vocabulary alone. -//! -//! # Staying in step with the module -//! -//! [`names::METHODS`] lists every member. `crates/template` asserts its served -//! members against that list, in order, so a method added to the interface -//! without an entry here fails that crate's tests rather than surfacing as an -//! unknown method in a host at runtime. -//! -//! # Example -//! -//! ``` -//! use template_bus::{names, GreetRequest, GreetResponse}; -//! -//! let body = serde_json::to_value([GreetRequest::new("Ferris")])?; -//! assert_eq!(names::methods::GREET, "Greet"); -//! assert_eq!(names::OBJECT_PATH, "/ai/tinyhumans/template/Greeting"); -//! -//! let reply: GreetResponse = serde_json::from_value( -//! serde_json::json!({ "greeting": "Hello, Ferris!" }), -//! )?; -//! assert_eq!(reply.greeting, "Hello, Ferris!"); -//! # Ok::<(), serde_json::Error>(()) -//! ``` - -pub mod greeting; -pub mod names; -pub mod version; - -pub use greeting::{GreetRequest, GreetResponse}; -pub use names::{INTERFACE, METHODS, OBJECT_PATH}; -pub use version::{CONTRACT_VERSION, is_compatible}; diff --git a/crates/template-bus/src/names/mod.rs b/crates/template-bus/src/names/mod.rs deleted file mode 100644 index 4da1547..0000000 --- a/crates/template-bus/src/names/mod.rs +++ /dev/null @@ -1,33 +0,0 @@ -//! The bus identity of the template module: interface name, object path, and -//! one constant per member. -//! -//! Nothing here is a string literal at a call site. A host names a member -//! through [`methods`] and the object through [`OBJECT_PATH`], so a rename is a -//! compile error in every consumer rather than a runtime "unknown method". -//! -//! When generating a project from this template, rename all three together — -//! the interface, the path, and the member constants — and keep -//! [`METHODS`] in the same order as the interface's dispatch table. - -/// The well-known interface name the module claims on the bus. -pub const INTERFACE: &str = "ai.tinyhumans.template.Greeting"; - -/// The object path the module serves its interface at. -pub const OBJECT_PATH: &str = "/ai/tinyhumans/template/Greeting"; - -/// One constant per member of [`INTERFACE`]. -pub mod methods { - /// Builds a greeting for a name. - /// - /// Takes a [`crate::GreetRequest`] and returns a [`crate::GreetResponse`]. - pub const GREET: &str = "Greet"; -} - -/// Every member of [`INTERFACE`], in the order the interface dispatches them. -/// -/// `crates/template` asserts its declared manifest methods against this list, -/// so the two cannot drift. -pub const METHODS: &[&str] = &[methods::GREET]; - -#[cfg(test)] -mod test; diff --git a/crates/template-bus/src/names/test.rs b/crates/template-bus/src/names/test.rs deleted file mode 100644 index bf7bea2..0000000 --- a/crates/template-bus/src/names/test.rs +++ /dev/null @@ -1,28 +0,0 @@ -//! Unit tests for the bus name table. - -use super::{INTERFACE, METHODS, OBJECT_PATH, methods}; - -#[test] -fn the_object_path_is_the_interface_in_path_form() { - let expected = format!("/{}", INTERFACE.replace('.', "/")); - assert_eq!(OBJECT_PATH, expected); -} - -#[test] -fn every_member_is_listed_exactly_once() { - let mut sorted = METHODS.to_vec(); - sorted.sort_unstable(); - let mut deduplicated = sorted.clone(); - deduplicated.dedup(); - assert_eq!(sorted, deduplicated); -} - -#[test] -fn the_method_table_holds_the_declared_members() { - assert_eq!(METHODS, [methods::GREET]); -} - -#[test] -fn no_member_name_is_empty() { - assert!(METHODS.iter().all(|method| !method.is_empty())); -} diff --git a/crates/template-bus/src/version/mod.rs b/crates/template-bus/src/version/mod.rs deleted file mode 100644 index ada372d..0000000 --- a/crates/template-bus/src/version/mod.rs +++ /dev/null @@ -1,46 +0,0 @@ -//! The contract version, and the rule a host uses to decide whether it can bind -//! to a module that reports one. -//! -//! The version describes *this vocabulary*, not the crate: bump the major -//! component when a payload's wire form changes incompatibly or a member is -//! removed or renamed, and the minor component when a member or an optional -//! field is added. It is deliberately independent of the package version the -//! release workflow bumps, which tracks the shipped artifact. - -/// The wire contract version this crate defines. -pub const CONTRACT_VERSION: (u32, u32) = (1, 0); - -/// Returns whether a host holding [`CONTRACT_VERSION`] can bind to a module -/// reporting `module`. -/// -/// Compatibility is the ordinary semantic-version rule for a pre-release-free -/// contract: the majors must match, and the module must be at least as new as -/// the host, because a host cannot call a member a module does not serve. -/// -/// # Examples -/// -/// ``` -/// # use template_bus::{is_compatible, CONTRACT_VERSION}; -/// assert!(is_compatible(CONTRACT_VERSION)); -/// assert!(is_compatible((1, 4))); -/// assert!(!is_compatible((2, 0))); -/// ``` -#[must_use] -pub fn is_compatible(module: (u32, u32)) -> bool { - binds(CONTRACT_VERSION, module) -} - -/// The bind rule with the host version supplied explicitly. -/// -/// [`is_compatible`] is this function applied to [`CONTRACT_VERSION`]. It is -/// split out so the unit tests can exercise both directions of the comparison -/// without pinning them to whatever the shipped version happens to be. -fn binds(host: (u32, u32), module: (u32, u32)) -> bool { - let (host_major, host_minor) = host; - let (module_major, module_minor) = module; - - module_major == host_major && module_minor >= host_minor -} - -#[cfg(test)] -mod test; diff --git a/crates/template-bus/src/version/test.rs b/crates/template-bus/src/version/test.rs deleted file mode 100644 index 3fd3edf..0000000 --- a/crates/template-bus/src/version/test.rs +++ /dev/null @@ -1,34 +0,0 @@ -//! Unit tests for the contract version and its bind rule. - -use super::{CONTRACT_VERSION, binds, is_compatible}; - -#[test] -fn the_shipped_contract_version_is_pinned() { - assert_eq!(CONTRACT_VERSION, (1, 0)); -} - -#[test] -fn the_contract_binds_to_itself() { - assert!(is_compatible(CONTRACT_VERSION)); -} - -#[test] -fn a_newer_minor_on_the_module_side_binds() { - assert!(is_compatible((1, 1))); - assert!(is_compatible((1, 97))); -} - -#[test] -fn an_older_minor_on_the_module_side_is_rejected() { - // A host built against 1.4 cannot call a 1.2 module: the members it names - // may not be served. - assert!(!binds((1, 4), (1, 2))); - assert!(binds((1, 4), (1, 4))); -} - -#[test] -fn a_different_major_is_rejected() { - assert!(!is_compatible((0, 0))); - assert!(!is_compatible((2, 0))); - assert!(!is_compatible((2, 97))); -} diff --git a/crates/template/examples/basic.rs b/crates/template/examples/basic.rs deleted file mode 100644 index 99233ec..0000000 --- a/crates/template/examples/basic.rs +++ /dev/null @@ -1,22 +0,0 @@ -//! Minimal end-to-end usage of the crate. -//! -//! Examples are compiled and linted in CI, so they cannot drift from the API. -//! Run it with: -//! -//! ```sh -//! cargo run --example basic -//! ``` - -use template::{Result, greet}; - -fn main() -> Result<()> { - println!("{}", greet("Rust")?); - - // Failure modes are part of the public contract; show them too. - match greet(" ") { - Ok(greeting) => println!("{greeting}"), - Err(error) => println!("expected failure: {error}"), - } - - Ok(()) -} diff --git a/crates/template/src/error/mod.rs b/crates/template/src/error/mod.rs deleted file mode 100644 index b8ddbe0..0000000 --- a/crates/template/src/error/mod.rs +++ /dev/null @@ -1,28 +0,0 @@ -//! Crate-wide error and result types. -//! -//! Every fallible public function in this crate returns [`Result`], and every -//! failure mode is a distinct [`Error`] variant. Add a variant rather than -//! encoding new context into an existing message: callers match on variants, -//! and message text is not a stable API. -//! -//! Variants carry the data a caller needs to react, keep their `#[error]` -//! message lowercase and free of trailing punctuation, and are documented so -//! the rendered rustdoc explains when each one occurs. - -/// Errors returned by this crate. -#[derive(Debug, thiserror::Error, PartialEq, Eq)] -#[non_exhaustive] -pub enum Error { - /// A required name was empty or contained only whitespace. - #[error("name must not be empty")] - EmptyName, -} - -/// The crate's standard result type. -/// -/// Use this alias in public signatures instead of spelling out -/// `std::result::Result`. -pub type Result = std::result::Result; - -#[cfg(test)] -mod test; diff --git a/crates/template/src/error/test.rs b/crates/template/src/error/test.rs deleted file mode 100644 index 4c5d609..0000000 --- a/crates/template/src/error/test.rs +++ /dev/null @@ -1,17 +0,0 @@ -//! Unit tests for the crate-wide error type. - -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - -use super::*; - -#[test] -fn renders_a_human_readable_message() { - assert_eq!(Error::EmptyName.to_string(), "name must not be empty"); -} - -#[test] -fn is_a_standard_error() { - fn assert_error(_: &E) {} - - assert_error(&Error::EmptyName); -} diff --git a/crates/template/src/greeting/mod.rs b/crates/template/src/greeting/mod.rs deleted file mode 100644 index 862fa21..0000000 --- a/crates/template/src/greeting/mod.rs +++ /dev/null @@ -1,38 +0,0 @@ -//! Greeting behavior used to demonstrate the template's module layout. -//! -//! A module root like this one documents the module, wires its pieces -//! together, and exposes the smallest useful API. Substantial type definitions -//! belong in a sibling `types.rs`, and unit tests belong in `test.rs`, wired in -//! at the bottom of this file. -//! -//! Replace this module with the crate's first real feature area. - -use crate::{Error, Result}; - -/// Returns a friendly greeting for `name`. -/// -/// Surrounding whitespace is trimmed before the greeting is built. -/// -/// # Examples -/// -/// ``` -/// # use template::greet; -/// assert_eq!(greet(" Ferris ")?, "Hello, Ferris!"); -/// # Ok::<(), template::Error>(()) -/// ``` -/// -/// # Errors -/// -/// Returns [`Error::EmptyName`] when `name` is empty or contains only -/// whitespace. -pub fn greet(name: &str) -> Result { - let name = name.trim(); - if name.is_empty() { - return Err(Error::EmptyName); - } - - Ok(format!("Hello, {name}!")) -} - -#[cfg(test)] -mod test; diff --git a/crates/template/src/greeting/test.rs b/crates/template/src/greeting/test.rs deleted file mode 100644 index de04ef4..0000000 --- a/crates/template/src/greeting/test.rs +++ /dev/null @@ -1,28 +0,0 @@ -//! Unit tests for the greeting module. -//! -//! Unit tests live next to the code they cover and may reach into private -//! items. Tests of the public contract belong in `tests/` instead. - -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - -use super::*; - -#[test] -fn greets_a_named_person() { - assert_eq!(greet("Ferris").unwrap(), "Hello, Ferris!"); -} - -#[test] -fn trims_the_name() { - assert_eq!(greet(" Ferris ").unwrap(), "Hello, Ferris!"); -} - -#[test] -fn rejects_an_empty_name() { - assert_eq!(greet("").unwrap_err(), Error::EmptyName); -} - -#[test] -fn rejects_a_whitespace_only_name() { - assert_eq!(greet(" \t\n ").unwrap_err(), Error::EmptyName); -} diff --git a/crates/template/src/lib.rs b/crates/template/src/lib.rs deleted file mode 100644 index 566fa7e..0000000 --- a/crates/template/src/lib.rs +++ /dev/null @@ -1,62 +0,0 @@ -//! A production-ready starting point for an installable `TinyBus` module. -//! -//! This crate is a template. It ships the layout, lint configuration, error -//! handling, testing, and documentation conventions described in `AGENTS.md`. -//! The compiled `cdylib` exports `TinyBus` module ABI v1 and serves the example -//! [`greet`] behavior over the bus. -//! -//! # Layout -//! -//! This is the implementation half of a two-crate workspace: -//! -//! - [`template_bus`] — the wire contract. Member names, payload types, and the -//! contract version, with no transport and no behavior. A host that only -//! makes calls depends on that crate alone. -//! - `template` — this crate. The behavior, the crate-wide error type, and the -//! `TinyBus` adapter that serves them, built as both an `rlib` and the -//! `cdylib` the loader consumes. -//! -//! Within this crate: -//! -//! - `src/error/` holds the crate-wide [`Error`] enum and the [`Result`] alias -//! returned by every fallible public function. -//! - Each feature area lives in its own module directory with a `mod.rs` -//! module root, an optional `types.rs`, and a `test.rs` holding its unit -//! tests. -//! - Every public item is re-exported from here — including all of -//! [`template_bus`] — so downstream users have a single predictable surface -//! and `template::GreetRequest` is the *same type* as -//! `template_bus::GreetRequest`, not a structural twin. -//! - `tinybus_module` adapts the public behavior to `TinyBus` and exports the -//! module descriptor, embedded manifest, and initialization entrypoint. -//! -//! # Example -//! -//! ``` -//! use template::{greet, Error, GreetRequest}; -//! -//! assert_eq!(greet("Ferris")?, "Hello, Ferris!"); -//! assert_eq!(greet(" ").unwrap_err(), Error::EmptyName); -//! assert_eq!(GreetRequest::new("Ferris").name, "Ferris"); -//! # Ok::<(), template::Error>(()) -//! ``` -//! -//! Replace the `greeting` module with the first real feature area, keep the -//! conventions, and update this documentation to describe the new crate. - -mod error; -mod greeting; -mod tinybus_module; - -pub use error::{Error, Result}; -pub use greeting::greet; - -// The wire contract, re-exported by module rather than by item so every path -// through this crate resolves to the same definitions the contract crate -// publishes. A host may depend on `template-bus` directly and get exactly these -// types; nothing here redefines them. -pub use template_bus; -pub use template_bus::{ - CONTRACT_VERSION, GreetRequest, GreetResponse, INTERFACE, METHODS, OBJECT_PATH, is_compatible, - names, version, -}; diff --git a/crates/template/src/tinybus_module/mod.rs b/crates/template/src/tinybus_module/mod.rs deleted file mode 100644 index 1c9c2f0..0000000 --- a/crates/template/src/tinybus_module/mod.rs +++ /dev/null @@ -1,43 +0,0 @@ -//! `TinyBus` module entrypoint and bus-facing interface. -//! -//! This adapter keeps the feature implementation independent from `TinyBus` -//! while exposing it as an installable, dynamically loaded integration. The -//! names and payload types it serves come from [`template_bus`], so a host -//! spells them from the contract crate instead of repeating string literals. - -use template_bus::{GreetRequest, GreetResponse, names}; -use tinybus::{Connection, Result as TinyBusResult}; - -struct GreetingService; - -#[tinybus::interface(name = "ai.tinyhumans.template.Greeting")] -impl GreetingService { - async fn greet(&self, request: GreetRequest) -> TinyBusResult { - std::future::ready(crate::greet(&request.name)) - .await - .map(GreetResponse::new) - .map_err(|error| tinybus::Error::failed(error.to_string())) - } -} - -async fn setup(connection: Connection) -> TinyBusResult<()> { - connection - .serve_at(names::OBJECT_PATH.try_into()?, GreetingService) - .await?; - connection.request_name(names::INTERFACE).await?; - Ok(()) -} - -tinybus_module::module_export! { - setup = setup, - worker_threads = 1, - provides = ["ai.tinyhumans.template.Greeting"], - methods = ["Greet"], - signals = [], - requires = [], - optional = [], - lazy = false, -} - -#[cfg(test)] -mod test; diff --git a/crates/template/src/tinybus_module/test.rs b/crates/template/src/tinybus_module/test.rs deleted file mode 100644 index d5fe71a..0000000 --- a/crates/template/src/tinybus_module/test.rs +++ /dev/null @@ -1,64 +0,0 @@ -//! Tests for the `TinyBus` module adapter and its declared surface. - -use super::{GreetingService, setup}; -use template_bus::{GreetRequest, GreetResponse, names}; -use tinybus::broker::Broker; -use tinybus::transport::memory::MemoryBus; -use tinybus::{Connection, Interface}; - -#[test] -fn declared_methods_match_the_dispatch_table() { - let methods = GreetingService - .members() - .into_iter() - .map(|member| member.to_string()) - .collect::>(); - - assert_eq!(methods, names::METHODS.to_vec()); -} - -#[test] -fn the_served_interface_name_matches_the_contract() { - assert_eq!(GreetingService.name().to_string(), names::INTERFACE); -} - -#[tokio::test] -async fn module_serves_greetings_over_a_real_bus() -> tinybus::Result<()> { - let bus = MemoryBus::new(); - Broker::new().spawn(bus.clone()); - - let service = Connection::connect(bus.connect().await?).await?; - setup(service.clone()).await?; - - let client = Connection::connect(bus.connect().await?).await?; - let proxy = client.proxy(names::INTERFACE, names::OBJECT_PATH, names::INTERFACE)?; - let reply: GreetResponse = proxy - .call(names::methods::GREET, (GreetRequest::new("Ferris"),)) - .await?; - - assert_eq!(reply, GreetResponse::new("Hello, Ferris!")); - Ok(()) -} - -#[tokio::test] -async fn module_rejects_an_empty_name_over_the_bus() -> tinybus::Result<()> { - let bus = MemoryBus::new(); - Broker::new().spawn(bus.clone()); - - let service = Connection::connect(bus.connect().await?).await?; - setup(service.clone()).await?; - - let client = Connection::connect(bus.connect().await?).await?; - let proxy = client.proxy(names::INTERFACE, names::OBJECT_PATH, names::INTERFACE)?; - let result = proxy - .call::(names::methods::GREET, (GreetRequest::new(" "),)) - .await; - - let Err(error) = result else { - return Err(tinybus::Error::failed( - "whitespace-only names unexpectedly succeeded", - )); - }; - assert!(error.to_string().contains("name must not be empty")); - Ok(()) -} diff --git a/crates/template/tests/public_api.rs b/crates/template/tests/public_api.rs deleted file mode 100644 index 256b71c..0000000 --- a/crates/template/tests/public_api.rs +++ /dev/null @@ -1,20 +0,0 @@ -//! Integration tests for the public crate surface. -//! -//! These tests link against the crate as a downstream consumer would: they can -//! only use what `src/lib.rs` re-exports. Treat them as the regression suite -//! for the crate's public contract — if a change breaks a test here, it is a -//! breaking change for users. - -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - -use template::{Error, greet}; - -#[test] -fn greeting_is_available_to_consumers() { - assert_eq!(greet("Rust").unwrap(), "Hello, Rust!"); -} - -#[test] -fn errors_are_available_to_consumers() { - assert_eq!(greet("").unwrap_err(), Error::EmptyName); -} diff --git a/crates/template/Cargo.toml b/crates/tinyruntime-python/Cargo.toml similarity index 60% rename from crates/template/Cargo.toml rename to crates/tinyruntime-python/Cargo.toml index e1bcdf4..c5f811a 100644 --- a/crates/template/Cargo.toml +++ b/crates/tinyruntime-python/Cargo.toml @@ -1,14 +1,14 @@ [package] -name = "template" +name = "tinyruntime-python" version.workspace = true edition.workspace = true rust-version.workspace = true license.workspace = true repository.workspace = true -description = "A production-ready template for installable TinyBus modules." -documentation = "https://docs.rs/template" +description = "The Python runtime provider for tinyruntime: which interpreter counts, which standalone build to install, and how to run a warm worker." +documentation = "https://docs.rs/tinyruntime-python" readme = "../../README.md" -keywords = ["tinybus", "module", "plugin", "template"] +keywords = ["tinybus", "runtime", "python", "provider"] categories = ["development-tools"] publish = false @@ -22,16 +22,21 @@ crate-type = ["rlib", "cdylib"] # Re-exported wholesale from `src/lib.rs` so a consumer takes one dependency # rather than two, and so `template::GreetRequest` and # `template_bus::GreetRequest` are the same type. -template-bus = { workspace = true } +tinyruntime-bus = { workspace = true } tinybus = { workspace = true } tinybus-module = { workspace = true } thiserror = { workspace = true } - -[dev-dependencies] tokio = { workspace = true } -# The GitHub release verifier passes an explicit empty module configuration. +reqwest = { workspace = true } +tracing = { workspace = true } +serde = { workspace = true } serde_json = { workspace = true } +[dev-dependencies] +# The layout tests probe a fabricated install tree in a scratch directory, and +# the harness tests launch a real `python` against a loopback listener. +tempfile = { workspace = true } + [features] default = [] diff --git a/crates/tinyruntime-python/examples/basic.rs b/crates/tinyruntime-python/examples/basic.rs new file mode 100644 index 0000000..0f91168 --- /dev/null +++ b/crates/tinyruntime-python/examples/basic.rs @@ -0,0 +1,46 @@ +//! What this provider knows about Python, printed. +//! +//! Every answer here is one the router would otherwise have to hard-code. None +//! of it downloads or installs anything — that is the router's half. + +use tinyruntime_python::{ + DEFAULT_VERSION, RuntimeSettings, distribution, harness, parse_version, satisfies, +}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + println!("default version floor: {DEFAULT_VERSION}"); + + match distribution::host_suffix() { + Ok(suffix) => println!("this host installs: cpython-*-{suffix}"), + Err(error) => println!("this host cannot install a managed build: {error}"), + } + + // A request names a floor, so anything newer satisfies it. + for candidate in ["3.11.9", "3.12.4", "3.13.1"] { + let verdict = match parse_version(candidate) { + Some(parsed) if satisfies(parsed, DEFAULT_VERSION, None) => "reused", + Some(_) => "rejected", + None => "not a version", + }; + println!(" a host {candidate} would be {verdict}"); + } + + match tinyruntime_python::system::detect(&RuntimeSettings::new(DEFAULT_VERSION)).await { + Some(layout) => println!( + "found a host interpreter: {} at {}", + layout.version, layout.bin_dir + ), + None => println!("no compatible host interpreter; the router would install one"), + } + + let harness = harness(); + println!( + "warm worker: {} ({} bytes) under `{}` with {:?}", + harness.filename, + harness.source.len(), + harness.executable, + harness.args_before_script + ); + Ok(()) +} diff --git a/crates/template/examples/verify_github_release.rs b/crates/tinyruntime-python/examples/verify_github_release.rs similarity index 68% rename from crates/template/examples/verify_github_release.rs rename to crates/tinyruntime-python/examples/verify_github_release.rs index 9b173fe..fcb3f4e 100644 --- a/crates/template/examples/verify_github_release.rs +++ b/crates/tinyruntime-python/examples/verify_github_release.rs @@ -4,19 +4,19 @@ //! //! ```text //! cargo run --example verify_github_release -- \ -//! https://github.com/tinyhumansai/template/releases/tag/v0.1.4 \ -//! template-0.1.4-ubuntu-24.04-x86_64.tar.gz \ +//! https://github.com/tinyhumansai/tinyruntime-python/releases/tag/v0.1.4 \ +//! tinyruntime-python-0.1.4-ubuntu-24.04-x86_64.tar.gz \ //! //! ``` use std::io; use std::time::Duration; -use template::{GreetRequest, GreetResponse, names}; use tinybus::Connection; use tinybus::broker::Broker; use tinybus::module::ModuleHost; use tinybus::transport::memory::MemoryBus; +use tinyruntime_python::{ProviderDescriptor, names}; #[tokio::main] async fn main() -> Result<(), Box> { @@ -45,7 +45,10 @@ async fn main() -> Result<(), Box> { tokio::time::timeout(Duration::from_secs(5), async { loop { let claimed = client.list_names().await?; - if claimed.iter().any(|name| name.as_str() == names::INTERFACE) { + if claimed + .iter() + .any(|name| name.as_str() == names::providers::PYTHON) + { return tinybus::Result::Ok(()); } tokio::task::yield_now().await; @@ -53,21 +56,26 @@ async fn main() -> Result<(), Box> { }) .await??; - let proxy = client.proxy(names::INTERFACE, names::OBJECT_PATH, names::INTERFACE)?; - let reply: GreetResponse = proxy - .call(names::methods::GREET, (GreetRequest::new("TinyBus"),)) - .await?; - if reply.greeting != "Hello, TinyBus!" { + // `Describe` is the right probe for a provider: it exercises the whole + // dispatch path and needs neither a network nor an installed interpreter, so + // it verifies the artifact rather than the machine it happens to run on. + let proxy = client.proxy( + names::providers::PYTHON, + names::providers::PYTHON_OBJECT_PATH, + names::PROVIDER_INTERFACE, + )?; + let descriptor: ProviderDescriptor = proxy.call(names::provider_methods::DESCRIBE, ()).await?; + if descriptor.language.as_str() != tinyruntime_python::PYTHON { return Err(io::Error::other(format!( - "module returned an unexpected greeting: {}", - reply.greeting + "module claims to serve `{}` rather than python", + descriptor.language )) .into()); } println!( - "verified {archive} from {release_url} as TinyBus module `{}`", - info.name + "verified {archive} from {release_url} as TinyBus module `{}`, providing {} {}", + info.name, descriptor.display_name, descriptor.default_version ); broker_task.abort(); Ok(()) diff --git a/crates/template/examples/verify_module.rs b/crates/tinyruntime-python/examples/verify_module.rs similarity index 62% rename from crates/template/examples/verify_module.rs rename to crates/tinyruntime-python/examples/verify_module.rs index 6e3856e..6521771 100644 --- a/crates/template/examples/verify_module.rs +++ b/crates/tinyruntime-python/examples/verify_module.rs @@ -4,11 +4,11 @@ use std::io; use std::path::PathBuf; use std::time::Duration; -use template::{GreetRequest, GreetResponse, names}; use tinybus::Connection; use tinybus::broker::Broker; use tinybus::module::ModuleHost; use tinybus::transport::memory::MemoryBus; +use tinyruntime_python::{ProviderDescriptor, names}; #[tokio::main] async fn main() -> Result<(), Box> { @@ -32,7 +32,10 @@ async fn main() -> Result<(), Box> { tokio::time::timeout(Duration::from_secs(5), async { loop { let claimed = client.list_names().await?; - if claimed.iter().any(|name| name.as_str() == names::INTERFACE) { + if claimed + .iter() + .any(|name| name.as_str() == names::providers::PYTHON) + { return tinybus::Result::Ok(()); } tokio::task::yield_now().await; @@ -40,22 +43,29 @@ async fn main() -> Result<(), Box> { }) .await??; - let proxy = client.proxy(names::INTERFACE, names::OBJECT_PATH, names::INTERFACE)?; - let reply: GreetResponse = proxy - .call(names::methods::GREET, (GreetRequest::new("TinyBus"),)) - .await?; - if reply.greeting != "Hello, TinyBus!" { + // `Describe` is the right probe for a provider: it exercises the whole + // dispatch path and needs neither a network nor an installed interpreter, so + // it verifies the artifact rather than the machine it happens to run on. + let proxy = client.proxy( + names::providers::PYTHON, + names::providers::PYTHON_OBJECT_PATH, + names::PROVIDER_INTERFACE, + )?; + let descriptor: ProviderDescriptor = proxy.call(names::provider_methods::DESCRIBE, ()).await?; + if descriptor.language.as_str() != tinyruntime_python::PYTHON { return Err(io::Error::other(format!( - "module returned an unexpected greeting: {}", - reply.greeting + "module claims to serve `{}` rather than python", + descriptor.language )) .into()); } println!( - "verified {} as TinyBus module `{}`", + "verified {} as TinyBus module `{}`, providing {} {}", module.display(), - info.name + info.name, + descriptor.display_name, + descriptor.default_version ); broker_task.abort(); Ok(()) diff --git a/crates/tinyruntime-python/src/distribution/host.rs b/crates/tinyruntime-python/src/distribution/host.rs new file mode 100644 index 0000000..790c52d --- /dev/null +++ b/crates/tinyruntime-python/src/distribution/host.rs @@ -0,0 +1,41 @@ +//! Which standalone Python build this machine takes. +//! +//! A plain table of host triples, because it is only correct by matching what +//! the channel actually publishes. A machine missing from it is one with no +//! standalone build, and saying so is better than guessing a filename. + +use crate::error::{Error, Result}; + +/// The asset suffix for the machine this is running on. +/// +/// # Errors +/// +/// Returns [`Error::UnsupportedHost`] when the channel publishes nothing for +/// this operating system and architecture. +pub fn host_suffix() -> Result<&'static str> { + suffix_for(std::env::consts::OS, std::env::consts::ARCH) +} + +/// The asset suffix for a named operating system and architecture. +/// +/// Split from [`host_suffix`] so the table can be tested for every platform +/// rather than only for whichever one the tests happen to run on. +/// +/// # Errors +/// +/// Returns [`Error::UnsupportedHost`] for a combination the channel does not +/// publish. +pub fn suffix_for(os: &str, arch: &str) -> Result<&'static str> { + match (os, arch) { + ("macos", "aarch64") => Ok("aarch64-apple-darwin-install_only.tar.gz"), + ("macos", "x86_64") => Ok("x86_64-apple-darwin-install_only.tar.gz"), + ("linux", "aarch64") => Ok("aarch64-unknown-linux-gnu-install_only.tar.gz"), + ("linux", "x86_64") => Ok("x86_64-unknown-linux-gnu-install_only.tar.gz"), + ("windows", "aarch64") => Ok("aarch64-pc-windows-msvc-install_only.tar.gz"), + ("windows", "x86_64") => Ok("x86_64-pc-windows-msvc-install_only.tar.gz"), + _ => Err(Error::UnsupportedHost { + os: os.to_owned(), + arch: arch.to_owned(), + }), + } +} diff --git a/crates/tinyruntime-python/src/distribution/index.rs b/crates/tinyruntime-python/src/distribution/index.rs new file mode 100644 index 0000000..baecafd --- /dev/null +++ b/crates/tinyruntime-python/src/distribution/index.rs @@ -0,0 +1,181 @@ +//! The shape of the release index, and which asset in it to install. +//! +//! Kept apart from the network call so selection can be tested against a real +//! index body rather than against a live channel. That matters more here than it +//! would elsewhere: selection is the part with actual judgement in it — version +//! bounds, host matching, and a preference between two builds of the same +//! version — and none of that should need a network to exercise. + +use serde::Deserialize; + +use tinyruntime_bus::{ArchiveFormat, Distribution}; + +use crate::error::{Error, Result}; +use crate::version::{Version, parse_version, satisfies}; + +/// One release of the standalone Python channel. +#[derive(Debug, Clone, Deserialize)] +pub struct Release { + /// The release's tag, which is a datestamp for this channel. + pub tag_name: String, + /// Everything published under it. + pub assets: Vec, +} + +/// One published file in a release. +#[derive(Debug, Clone, Deserialize)] +pub struct Asset { + /// The filename, which is where the version and host triple live. + pub name: String, + /// Where to fetch it. + pub browser_download_url: String, + /// The digest, when the channel published one, as `sha256:`. + #[serde(default)] + pub digest: Option, +} + +/// A candidate build, parsed out of an asset name. +#[derive(Debug, Clone, PartialEq, Eq)] +struct Candidate { + version: Version, + asset_name: String, + url: String, + sha256: Option, + stripped: bool, +} + +/// Pick the build to install from `release`. +/// +/// Preference order, applied in turn: newest version first, then a stripped +/// build over a full one. Stripped builds omit debug symbols and static +/// libraries — a few hundred megabytes of things nothing here uses — so +/// preferring them is a large saving for no loss. +/// +/// # Errors +/// +/// Returns [`Error::InvalidVersion`] when a bound is not a version, +/// [`Error::UnsupportedHost`] when the channel publishes nothing for this +/// machine, and [`Error::NoMatchingBuild`] when the bounds excluded everything. +pub fn select( + release: &Release, + minimum: &str, + maximum: Option<&str>, + suffix: &str, +) -> Result { + if parse_version(minimum).is_none() { + return Err(Error::InvalidVersion { + value: minimum.to_owned(), + bound: "minimum", + }); + } + if let Some(maximum) = maximum.map(str::trim).filter(|value| !value.is_empty()) + && parse_version(maximum).is_none() + { + return Err(Error::InvalidVersion { + value: maximum.to_owned(), + bound: "maximum", + }); + } + + let mut candidates: Vec = release + .assets + .iter() + .filter_map(|asset| candidate(asset, suffix)) + .filter(|candidate| satisfies(candidate.version, minimum, maximum)) + .collect(); + + if candidates.is_empty() { + return Err(Error::NoMatchingBuild { + release: release.tag_name.clone(), + bounds: render_bounds(minimum, maximum), + }); + } + + // Newest first; a stripped build wins a tie; the name breaks any remaining + // tie so the choice is deterministic rather than index-order. + candidates.sort_by(|left, right| { + right + .version + .cmp(&left.version) + .then_with(|| right.stripped.cmp(&left.stripped)) + .then_with(|| left.asset_name.cmp(&right.asset_name)) + }); + + let chosen = candidates.swap_remove(0); + let mut distribution = Distribution::new( + chosen.version.to_string(), + &chosen.asset_name, + chosen.url, + ArchiveFormat::TarGz, + ) + // The archive expands into a plain `python/` directory, identical across + // every build, so the install directory has to be named from the asset + // rather than from what is inside it — otherwise every version would want + // the same directory. + .with_install_dir_name(install_dir_name(&chosen.asset_name)); + + if let Some(digest) = chosen.sha256 { + distribution = distribution.with_sha256(digest); + } + Ok(distribution) +} + +/// Parse one asset into a candidate, or skip it. +/// +/// Only `install_only` archives are considered. The channel also publishes full +/// build artifacts with debug information and a build manifest, which are large +/// and are not a runnable interpreter tree. +fn candidate(asset: &Asset, suffix: &str) -> Option { + let name = asset.name.as_str(); + if !name.starts_with("cpython-") || !name.ends_with(".tar.gz") || !name.contains("install_only") + { + return None; + } + if !matches_host(name, suffix) { + return None; + } + + // `cpython-3.12.4+20240909-x86_64-unknown-linux-gnu-install_only.tar.gz`: + // the version runs from after the prefix to the `+` that starts the + // channel's own build stamp. + let version = parse_version(name.strip_prefix("cpython-")?.split('+').next()?)?; + + Some(Candidate { + version, + asset_name: asset.name.clone(), + url: asset.browser_download_url.clone(), + sha256: asset + .digest + .as_deref() + .and_then(|digest| digest.strip_prefix("sha256:")) + .map(str::to_owned), + stripped: name.contains("install_only_stripped"), + }) +} + +/// Whether an asset targets this host. +/// +/// Both spellings of the suffix count: the channel publishes a full +/// `-install_only.tar.gz` and a smaller `-install_only_stripped.tar.gz` for the +/// same triple, and both are usable. +fn matches_host(asset_name: &str, suffix: &str) -> bool { + asset_name.ends_with(suffix) + || asset_name + .ends_with(&suffix.replace("-install_only.tar.gz", "-install_only_stripped.tar.gz")) +} + +/// The directory name a build installs into, derived from its asset name. +fn install_dir_name(asset_name: &str) -> String { + asset_name + .strip_suffix(".tar.gz") + .unwrap_or(asset_name) + .to_owned() +} + +/// Render the version bounds for an error a person reads. +fn render_bounds(minimum: &str, maximum: Option<&str>) -> String { + match maximum.map(str::trim).filter(|value| !value.is_empty()) { + Some(maximum) => format!(">= {minimum} and < {maximum}"), + None => format!(">= {minimum}"), + } +} diff --git a/crates/tinyruntime-python/src/distribution/mod.rs b/crates/tinyruntime-python/src/distribution/mod.rs new file mode 100644 index 0000000..10c943d --- /dev/null +++ b/crates/tinyruntime-python/src/distribution/mod.rs @@ -0,0 +1,119 @@ +//! Choosing which standalone Python build to install. +//! +//! The channel is `astral-sh/python-build-standalone`, which publishes a set of +//! relocatable `CPython` builds per release rather than one archive per version. +//! Two consequences shape this module. +//! +//! First, selection is a search rather than a lookup: the index has to be read, +//! filtered to this host, filtered to the requested version range, and then +//! ranked. That is what the private `index` submodule does, and it is deliberately separable from the +//! network so it can be tested against a real index body. +//! +//! Second, every build unpacks into a directory called `python`, regardless of +//! version. The install directory is therefore named from the asset rather than +//! from the archive's contents — otherwise every version would want the same +//! directory in the cache and each install would silently replace the last. + +use reqwest::Client; + +use tinyruntime_bus::{Distribution, RuntimeSettings}; + +use crate::error::{Error, Result}; + +mod host; +mod index; + +pub use host::{host_suffix, suffix_for}; +pub use index::{Asset, Release, select as select_from}; + +/// Where the standalone Python builds are published. +const RELEASES_API: &str = + "https://api.github.com/repos/astral-sh/python-build-standalone/releases"; + +/// Pick the build to install for this host under `settings`. +/// +/// # Errors +/// +/// Returns [`Error::IndexUnavailable`] when the release index cannot be read, +/// and the selection errors from [`select_from`] otherwise. +pub async fn select(client: &Client, settings: &RuntimeSettings) -> Result { + select_from_api(client, RELEASES_API, settings).await +} + +/// [`select`] against a named release index. +/// +/// Split out so the request, the tag handling, and the failure mapping can be +/// tested against a server the test controls. Reaching GitHub from a unit test +/// would tie the suite to the network and to a release staying published, which +/// the repository's testing rules rule out. +/// +/// # Errors +/// +/// As [`select`]. +pub async fn select_from_api( + client: &Client, + releases_api: &str, + settings: &RuntimeSettings, +) -> Result { + let suffix = host_suffix()?; + let release = fetch_release(client, releases_api, settings.release_tag()).await?; + + let distribution = index::select( + &release, + &settings.version, + settings.maximum_version(), + suffix, + )?; + + tracing::info!( + release = %release.tag_name, + version = %distribution.version, + "[tinyruntime-python] selected a standalone build for this host" + ); + Ok(distribution) +} + +/// Read one release from the channel, or its current one. +async fn fetch_release( + client: &Client, + releases_api: &str, + tag: Option<&str>, +) -> Result { + let url = match tag { + Some(tag) => format!("{releases_api}/tags/{tag}"), + None => format!("{releases_api}/latest"), + }; + + client + .get(&url) + .header( + reqwest::header::USER_AGENT, + concat!("tinyruntime-python/", env!("CARGO_PKG_VERSION")), + ) + .header(reqwest::header::ACCEPT, "application/vnd.github+json") + .send() + .await + .and_then(reqwest::Response::error_for_status) + .map_err(|error| Error::IndexUnavailable(describe(&error)))? + .json::() + .await + .map_err(|error| Error::IndexUnavailable(describe(&error))) +} + +/// Describe a request failure without putting the URL in a host-visible message. +fn describe(error: &reqwest::Error) -> String { + if error.is_timeout() { + "the request timed out".to_owned() + } else if error.is_connect() { + "the connection could not be established".to_owned() + } else if let Some(status) = error.status() { + format!("the channel answered with status {status}") + } else if error.is_decode() { + "the index was not in the expected shape".to_owned() + } else { + "the request failed".to_owned() + } +} + +#[cfg(test)] +mod test; diff --git a/crates/tinyruntime-python/src/distribution/test.rs b/crates/tinyruntime-python/src/distribution/test.rs new file mode 100644 index 0000000..e2ea487 --- /dev/null +++ b/crates/tinyruntime-python/src/distribution/test.rs @@ -0,0 +1,339 @@ +//! Unit tests for standalone build selection. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use super::{Release, host_suffix, select_from, suffix_for}; +use crate::error::Error; + +/// The Linux x86-64 suffix, which the fixtures below are written against. +const LINUX: &str = "x86_64-unknown-linux-gnu-install_only.tar.gz"; + +/// A release index shaped like the real one, including the assets that must be +/// ignored and the near-miss hosts that must not match. +fn release() -> Release { + serde_json::from_value(serde_json::json!({ + "tag_name": "20240909", + "assets": [ + { + "name": "cpython-3.12.4+20240909-x86_64-unknown-linux-gnu-install_only.tar.gz", + "browser_download_url": "https://example.invalid/3.12.4-full", + "digest": "sha256:aa" + }, + { + "name": "cpython-3.12.4+20240909-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz", + "browser_download_url": "https://example.invalid/3.12.4-stripped", + "digest": "sha256:bb" + }, + { + "name": "cpython-3.13.1+20240909-x86_64-unknown-linux-gnu-install_only.tar.gz", + "browser_download_url": "https://example.invalid/3.13.1", + "digest": "sha256:cc" + }, + { + "name": "cpython-3.15.0rc1+20240909-x86_64-unknown-linux-gnu-install_only.tar.gz", + "browser_download_url": "https://example.invalid/3.15.0rc1", + "digest": "sha256:dd" + }, + { + "name": "cpython-3.13.1+20240909-aarch64-apple-darwin-install_only.tar.gz", + "browser_download_url": "https://example.invalid/darwin", + "digest": "sha256:ee" + }, + { + "name": "cpython-3.13.1+20240909-x86_64-unknown-linux-gnu-debug-full.tar.zst", + "browser_download_url": "https://example.invalid/debug", + "digest": "sha256:ff" + }, + { + "name": "SHA256SUMS", + "browser_download_url": "https://example.invalid/sums", + "digest": null + } + ] + })) + .expect("the fixture is a valid release") +} + +#[test] +fn the_newest_build_within_the_bounds_is_chosen() { + let chosen = select_from(&release(), "3.12", Some("3.15"), LINUX).expect("a build matches"); + assert_eq!(chosen.version, "3.13.1"); + assert_eq!(chosen.expected_sha256.as_deref(), Some("cc")); +} + +#[test] +fn a_stripped_build_wins_a_tie_with_a_full_one() { + // Stripped builds omit debug symbols and static libraries — hundreds of + // megabytes nothing here uses. + let chosen = select_from(&release(), "3.12", Some("3.13"), LINUX).expect("a build matches"); + assert!( + chosen.archive_name.contains("install_only_stripped"), + "chose {}", + chosen.archive_name + ); + assert_eq!(chosen.expected_sha256.as_deref(), Some("bb")); +} + +#[test] +fn an_exclusive_ceiling_keeps_selection_off_a_pre_release_series() { + // The 3.15 release candidate is in the same index. Without the ceiling it + // would be the newest thing there and would win. + let bounded = select_from(&release(), "3.12", Some("3.15"), LINUX).unwrap(); + assert_eq!(bounded.version, "3.13.1"); + + let unbounded = select_from(&release(), "3.12", None, LINUX).unwrap(); + assert_eq!( + unbounded.version, "3.15.0", + "the ceiling was doing the work" + ); +} + +#[test] +fn builds_for_another_host_are_not_considered() { + // The darwin asset in the fixture is a newer-or-equal version; matching it + // would install an interpreter that cannot run on this machine. + let chosen = select_from(&release(), "3.12", Some("3.15"), LINUX).unwrap(); + assert!( + !chosen.archive_name.contains("darwin"), + "chose {}", + chosen.archive_name + ); +} + +#[test] +fn artifacts_that_are_not_a_runnable_interpreter_are_ignored() { + // Debug archives and checksum files sit in the same release. + let chosen = select_from(&release(), "3.12", None, LINUX).unwrap(); + assert!(chosen.archive_name.contains("install_only")); + assert!(chosen.archive_name.ends_with(".tar.gz")); +} + +#[test] +fn the_install_directory_is_named_from_the_asset_not_the_archive() { + // Every standalone build unpacks into a directory called `python`. Naming + // the install from that would make every version claim one cache directory + // and silently replace the last. + let chosen = select_from(&release(), "3.12", Some("3.15"), LINUX).unwrap(); + assert_eq!( + chosen.install_dir_name, + "cpython-3.13.1+20240909-x86_64-unknown-linux-gnu-install_only" + ); + assert_ne!(chosen.install_dir_name, "python"); +} + +#[test] +fn a_floor_nothing_reaches_is_a_distinct_failure_from_an_unreadable_index() { + let error = select_from(&release(), "3.99", None, LINUX).expect_err("nothing is that new"); + let Error::NoMatchingBuild { release, bounds } = &error else { + panic!("got {error:?}"); + }; + assert_eq!(release, "20240909"); + assert!( + bounds.contains("3.99"), + "the bounds that excluded everything are named" + ); +} + +#[test] +fn a_bound_that_is_not_a_version_is_refused_by_name() { + let error = select_from(&release(), "latest", None, LINUX).expect_err("refused"); + assert!(matches!( + error, + Error::InvalidVersion { + bound: "minimum", + .. + } + )); + + let error = select_from(&release(), "3.12", Some("nonsense"), LINUX).expect_err("refused"); + assert!(matches!( + error, + Error::InvalidVersion { + bound: "maximum", + .. + } + )); +} + +#[test] +fn a_release_with_no_digest_still_selects() { + // The router installs it and says loudly that it could not verify it; + // refusing here would make the language unusable rather than safer. + let release: Release = serde_json::from_value(serde_json::json!({ + "tag_name": "20240909", + "assets": [{ + "name": "cpython-3.12.4+20240909-x86_64-unknown-linux-gnu-install_only.tar.gz", + "browser_download_url": "https://example.invalid/3.12.4", + "digest": null + }] + })) + .unwrap(); + let chosen = select_from(&release, "3.12", None, LINUX).expect("a build matches"); + assert!(chosen.expected_sha256.is_none()); +} + +#[test] +fn every_platform_the_channel_publishes_for_is_in_the_table() { + for (os, arch, expected) in [ + ("linux", "x86_64", LINUX), + ( + "linux", + "aarch64", + "aarch64-unknown-linux-gnu-install_only.tar.gz", + ), + ( + "macos", + "aarch64", + "aarch64-apple-darwin-install_only.tar.gz", + ), + ("macos", "x86_64", "x86_64-apple-darwin-install_only.tar.gz"), + ( + "windows", + "x86_64", + "x86_64-pc-windows-msvc-install_only.tar.gz", + ), + ] { + assert_eq!( + suffix_for(os, arch).unwrap_or_else(|_| panic!("{os}/{arch} is missing")), + expected + ); + } +} + +#[test] +fn a_host_the_channel_does_not_publish_for_is_refused_by_name() { + let error = suffix_for("plan9", "x86_64").expect_err("no build exists"); + assert!(error.to_string().contains("plan9"), "got `{error}`"); +} + +#[test] +fn this_machine_is_one_the_channel_publishes_for() { + assert!( + host_suffix().is_ok(), + "no build for {}", + std::env::consts::ARCH + ); +} + +// --------------------------------------------------------------------------- +// Against a release index the test serves +// +// Reaching GitHub here would tie the suite to the network and to a release +// staying published. A loopback server gives the same code path with neither. +// --------------------------------------------------------------------------- + +use std::io::{BufRead, BufReader, Write}; +use std::net::TcpListener; + +use reqwest::Client; +use tinyruntime_bus::RuntimeSettings; + +/// Serve one JSON body, recording the path that was requested. +fn serve_index(body: String) -> (String, std::thread::JoinHandle) { + let listener = TcpListener::bind("127.0.0.1:0").expect("loopback is available"); + let base = format!("http://{}", listener.local_addr().expect("an address")); + + let handle = std::thread::spawn(move || { + let Ok((mut stream, _)) = listener.accept() else { + return String::new(); + }; + let Ok(clone) = stream.try_clone() else { + return String::new(); + }; + let mut reader = BufReader::new(clone); + let mut request_line = String::new(); + let _ = reader.read_line(&mut request_line); + let mut line = String::new(); + while reader.read_line(&mut line).unwrap_or(0) > 0 { + if line == "\r\n" { + break; + } + line.clear(); + } + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + let _ = stream.write_all(response.as_bytes()); + let _ = stream.flush(); + request_line + }); + + (base, handle) +} + +/// A release body holding one build for this host. +fn release_body_for_this_host() -> String { + let suffix = host_suffix().expect("this host is supported"); + serde_json::json!({ + "tag_name": "20240909", + "assets": [{ + "name": format!("cpython-3.12.4+20240909-{suffix}"), + "browser_download_url": "https://example.invalid/cpython.tar.gz", + "digest": "sha256:abc" + }] + }) + .to_string() +} + +#[tokio::test] +async fn a_build_is_selected_from_the_channels_current_release() { + let (base, server) = serve_index(release_body_for_this_host()); + + let chosen = super::select_from_api(&Client::new(), &base, &RuntimeSettings::new("3.12")) + .await + .expect("a build is selected"); + + assert_eq!(chosen.version, "3.12.4"); + assert_eq!(chosen.expected_sha256.as_deref(), Some("abc")); + let requested = server.join().expect("the server finished"); + assert!( + requested.contains("/latest"), + "an unpinned request should ask for the current release: {requested}" + ); +} + +#[tokio::test] +async fn a_pinned_release_tag_is_requested_by_name() { + let (base, server) = serve_index(release_body_for_this_host()); + + let mut settings = RuntimeSettings::new("3.12"); + settings.release_tag = "20240909".to_string(); + super::select_from_api(&Client::new(), &base, &settings) + .await + .expect("a build is selected"); + + let requested = server.join().expect("the server finished"); + assert!( + requested.contains("/tags/20240909"), + "a pinned tag was not requested: {requested}" + ); +} + +#[tokio::test] +async fn an_index_that_is_not_the_expected_shape_is_reported_as_unreadable() { + let (base, server) = serve_index("{\"unexpected\": true}".to_string()); + + let error = super::select_from_api(&Client::new(), &base, &RuntimeSettings::new("3.12")) + .await + .expect_err("a body that is not a release cannot be read"); + assert!(matches!(error, Error::IndexUnavailable(_)), "got {error:?}"); + let _ = server.join(); +} + +#[tokio::test] +async fn an_unreachable_channel_is_reported_without_the_url() { + // These messages reach a host's UI; a URL can carry a token. + let error = super::select_from_api( + &Client::new(), + "http://127.0.0.1:1", + &RuntimeSettings::new("3.12"), + ) + .await + .expect_err("an unreachable channel fails"); + + let Error::IndexUnavailable(reason) = &error else { + panic!("got {error:?}"); + }; + assert!(!reason.contains("127.0.0.1"), "got `{reason}`"); + assert!(reason.contains("connection"), "got `{reason}`"); +} diff --git a/crates/tinyruntime-python/src/error/mod.rs b/crates/tinyruntime-python/src/error/mod.rs new file mode 100644 index 0000000..10c6df0 --- /dev/null +++ b/crates/tinyruntime-python/src/error/mod.rs @@ -0,0 +1,55 @@ +//! The crate-wide error type and result alias. +//! +//! A provider's failures are narrow by design: it answers questions and does not +//! install anything, so the things that can go wrong are a host it cannot serve, +//! a version bound that is not a version, and a release index it could not read +//! or that offered nothing suitable. +//! +//! Messages are lowercase and carry no credential, payload, or absolute path. +//! They travel to the router, which puts them in front of a person. + +/// Everything this provider can fail with. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum Error { + /// The standalone Python channel publishes no build for this machine. + #[error("no standalone python build is published for {os}/{arch}")] + UnsupportedHost { + /// The operating system, as Rust names it. + os: String, + /// The architecture, as Rust names it. + arch: String, + }, + + /// A configured version bound is not a version. + #[error("`{value}` is not a python version ({bound})")] + InvalidVersion { + /// What was configured. + value: String, + /// Which bound it was configured as. + bound: &'static str, + }, + + /// The release index could not be read. + #[error("the standalone python release index could not be read: {0}")] + IndexUnavailable(String), + + /// The release index was readable but held nothing this host can use. + /// + /// Distinct from [`Error::IndexUnavailable`] because it calls for a different + /// response: the index is fine, and the version bounds are what excluded + /// everything in it. + #[error("the standalone python release `{release}` publishes no build matching {bounds}")] + NoMatchingBuild { + /// The release that was searched. + release: String, + /// The bounds that excluded everything, rendered for display. + bounds: String, + }, +} + +/// The crate's result alias. +pub type Result = std::result::Result; + +#[cfg(test)] +mod test; diff --git a/crates/tinyruntime-python/src/error/test.rs b/crates/tinyruntime-python/src/error/test.rs new file mode 100644 index 0000000..7db07e0 --- /dev/null +++ b/crates/tinyruntime-python/src/error/test.rs @@ -0,0 +1,64 @@ +//! Unit tests for the crate-wide error type. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use super::Error; + +#[test] +fn messages_are_lowercase_and_unpunctuated() { + let errors = [ + Error::UnsupportedHost { + os: "plan9".to_string(), + arch: "x86_64".to_string(), + }, + Error::InvalidVersion { + value: "latest".to_string(), + bound: "minimum", + }, + Error::IndexUnavailable("the request timed out".to_string()), + Error::NoMatchingBuild { + release: "20240909".to_string(), + bounds: ">= 3.12".to_string(), + }, + ]; + for error in errors { + let rendered = error.to_string(); + assert!( + !rendered.ends_with('.'), + "`{rendered}` ends with punctuation" + ); + let first = rendered.chars().next().expect("a non-empty message"); + assert!(!first.is_uppercase(), "`{rendered}` starts with a capital"); + } +} + +#[test] +fn an_unreadable_index_and_an_empty_one_are_different_errors() { + // They call for different responses: one is worth retrying, the other means + // the version bounds excluded everything the channel actually publishes. + let unreadable = Error::IndexUnavailable("the connection failed".to_string()).to_string(); + let empty = Error::NoMatchingBuild { + release: "20240909".to_string(), + bounds: ">= 3.99".to_string(), + } + .to_string(); + assert!( + unreadable.contains("could not be read"), + "got `{unreadable}`" + ); + assert!(empty.contains("no build matching"), "got `{empty}`"); + assert!( + empty.contains(">= 3.99"), + "the bounds that excluded everything are named" + ); +} + +#[test] +fn an_invalid_bound_says_which_bound_it_was() { + let rendered = Error::InvalidVersion { + value: "nonsense".to_string(), + bound: "maximum", + } + .to_string(); + assert!(rendered.contains("maximum"), "got `{rendered}`"); + assert!(rendered.contains("`nonsense`"), "got `{rendered}`"); +} diff --git a/crates/tinyruntime-python/src/harness/mod.rs b/crates/tinyruntime-python/src/harness/mod.rs new file mode 100644 index 0000000..06bbe11 --- /dev/null +++ b/crates/tinyruntime-python/src/harness/mod.rs @@ -0,0 +1,29 @@ +//! The warm-worker harness this provider ships to the router. +//! +//! The script is compiled into the module and handed over on request rather than +//! installed anywhere, so an upgraded provider ships an upgraded harness and +//! there is no version of it on disk that can drift. +//! +//! One flag and one variable travel with it. `-u` makes the interpreter's own +//! streams unbuffered, and `PYTHONUNBUFFERED` covers what the flag does not, so a +//! job's output reaches the capture files promptly instead of sitting in a buffer +//! until the process exits. + +use tinyruntime_bus::WorkerHarness; + +/// The harness source, compiled in. +const SOURCE: &str = include_str!("pool_worker.py"); + +/// The filename the router writes the harness under. +const FILENAME: &str = "pool_worker.py"; + +/// The harness for this provider's warm workers. +#[must_use] +pub fn harness() -> WorkerHarness { + WorkerHarness::new(FILENAME, SOURCE, "python") + .with_flag("-u") + .with_env("PYTHONUNBUFFERED", "1") +} + +#[cfg(test)] +mod test; diff --git a/crates/tinyruntime-python/src/harness/pool_worker.py b/crates/tinyruntime-python/src/harness/pool_worker.py new file mode 100644 index 0000000..d269547 --- /dev/null +++ b/crates/tinyruntime-python/src/harness/pool_worker.py @@ -0,0 +1,216 @@ +# The tinyruntime Python worker harness. +# +# One long-lived `python` process that runs many inline jobs, so a host pays for +# one warm interpreter instead of one child per execution. +# +# Protocol (newline-delimited JSON over an authenticated loopback socket): +# 1. Send exactly one handshake: {ready, protocol, language, token} +# 2. For each {id, code, cwd, timeout_ms} reply with +# {id, ok, stdout, stderr, exit_code, timed_out, elapsed_ms, error} +# +# Python cannot isolate a job the way the Node harness does. There is no +# equivalent of a worker thread here — CPython cannot safely kill a running +# thread — so a job runs in this interpreter, and two things follow. +# +# Isolation is per-job globals plus the pool's recycle-after-N-jobs. Module +# state, `os.environ`, and logging handlers do leak between jobs on one worker, +# which is why the router leaves Python pooling off unless a host opts in. +# +# The soft deadline is best effort: a SIGALRM on Unix, and nothing on Windows. +# The router's own hard deadline is the backstop, and it kills and replaces the +# worker rather than waiting. + +import json +import os +import socket +import sys +import tempfile +import time +import traceback + +PROTOCOL_VERSION = 1 + +_TOKEN = os.environ.get("TINYRUNTIME_PROTOCOL_TOKEN") +_ADDRESS = os.environ.get("TINYRUNTIME_PROTOCOL_ADDR") + +if not _ADDRESS: + sys.stderr.write("tinyruntime: no protocol address was supplied\n") + sys.exit(1) + +_host, _port = _ADDRESS.rsplit(":", 1) +try: + _SOCKET = socket.create_connection((_host, int(_port))) +except OSError as exc: + sys.stderr.write(f"tinyruntime: protocol connection failed: {exc!r}\n") + sys.exit(1) + +# Separate file objects for the two directions. The protocol never touches file +# descriptors 0, 1, or 2 — those belong to the job, and the redirection below +# reassigns them freely. +_INCOMING = _SOCKET.makefile("r") +_OUTGOING = _SOCKET.makefile("w", buffering=1) + +try: + import signal + + _HAVE_ALARM = hasattr(signal, "SIGALRM") and hasattr(signal, "setitimer") +except ImportError: # pragma: no cover - a platform without signal support + signal = None + _HAVE_ALARM = False + + +class _JobTimeout(Exception): + """Raised from the alarm handler to unwind a job at its soft deadline.""" + + +def _failure(job, started, message): + return { + "id": job.get("id") if isinstance(job, dict) else None, + "ok": False, + "stdout": "", + "stderr": "", + "exit_code": None, + "timed_out": False, + "elapsed_ms": int((time.time() - started) * 1000), + "error": message, + } + + +def _run_job(job): + code = job.get("code") or "" + cwd = job.get("cwd") + timeout_ms = job.get("timeout_ms") + started = time.time() + exit_code = 0 + timed_out = False + extra_stderr = "" + + # Entering the job's directory before running anything. Failing here rather + # than running anyway matters: a job that silently ran in the previous job's + # directory would escape whatever sandbox the caller set up. + previous_cwd = None + if cwd: + try: + previous_cwd = os.getcwd() + os.chdir(cwd) + except OSError as exc: + return _failure(job, started, f"failed to set worker cwd: {exc!r}") + + # Capture at the file-descriptor level rather than by swapping `sys.stdout`. + # A job can write with `os.write(1, ...)`, spawn a subprocess, or call into a + # native extension, and none of those go through `sys.stdout`. Temporary + # files rather than pipes, because a pipe would deadlock on a job that + # produces more output than its buffer holds. + stdin_capture = tempfile.TemporaryFile(mode="w+b") + stdout_capture = tempfile.TemporaryFile(mode="w+b") + stderr_capture = tempfile.TemporaryFile(mode="w+b") + saved_stdin = os.dup(0) + saved_stdout = os.dup(1) + saved_stderr = os.dup(2) + os.dup2(stdin_capture.fileno(), 0) + os.dup2(stdout_capture.fileno(), 1) + os.dup2(stderr_capture.fileno(), 2) + + armed = False + if _HAVE_ALARM and timeout_ms and timeout_ms > 0: + + def _on_alarm(_signum, _frame): + raise _JobTimeout() + + signal.signal(signal.SIGALRM, _on_alarm) + signal.setitimer(signal.ITIMER_REAL, timeout_ms / 1000.0) + armed = True + + try: + # Fresh globals per job, so a name defined by one job is not visible to + # the next one on this worker. + namespace = {"__name__": "__main__", "__builtins__": __builtins__} + exec(compile(code, "", "exec"), namespace, namespace) + except _JobTimeout: + timed_out = True + except SystemExit as exc: # honour sys.exit(n) + if exc.code is None: + exit_code = 0 + elif isinstance(exc.code, int): + exit_code = exc.code + else: + exit_code = 1 + extra_stderr = str(exc.code) + "\n" + except BaseException: # noqa: BLE001 - every job failure belongs to the caller + exit_code = 1 + extra_stderr = traceback.format_exc() + finally: + if armed: + signal.setitimer(signal.ITIMER_REAL, 0) + # Flush Python's own buffers into the redirected descriptors before + # restoring them, or the last of a job's output is lost. A flush that + # fails is reported in the job's stderr rather than discarded. + for stream, label in ((sys.stdout, "stdout"), (sys.stderr, "stderr")): + try: + stream.flush() + except Exception as exc: # noqa: BLE001 + extra_stderr += f"[harness] {label} flush failed: {exc!r}\n" + os.dup2(saved_stdin, 0) + os.dup2(saved_stdout, 1) + os.dup2(saved_stderr, 2) + os.close(saved_stdin) + os.close(saved_stdout) + os.close(saved_stderr) + if previous_cwd is not None: + try: + os.chdir(previous_cwd) + except OSError: + pass # the next job sets its own directory anyway + + stdin_capture.close() + stdout_capture.seek(0) + stderr_capture.seek(0) + stdout = stdout_capture.read().decode("utf-8", "replace") + stderr = stderr_capture.read().decode("utf-8", "replace") + extra_stderr + stdout_capture.close() + stderr_capture.close() + + return { + "id": job.get("id"), + "ok": True, + "stdout": stdout, + "stderr": stderr, + "exit_code": None if timed_out else exit_code, + "timed_out": timed_out, + "elapsed_ms": int((time.time() - started) * 1000), + "error": None, + } + + +def _send(frame): + _OUTGOING.write(json.dumps(frame) + "\n") + _OUTGOING.flush() + + +def main(): + _send( + { + "ready": True, + "protocol": PROTOCOL_VERSION, + "language": "python", + "token": _TOKEN, + } + ) + + for line in _INCOMING: + line = line.strip() + if not line: + continue + try: + job = json.loads(line) + except ValueError: + continue # an unparseable line is not a job + try: + reply = _run_job(job) + except BaseException as exc: # noqa: BLE001 - a harness-level failure + reply = _failure(job, time.time(), repr(exc)) + _send(reply) + + +if __name__ == "__main__": + main() diff --git a/crates/tinyruntime-python/src/harness/test.rs b/crates/tinyruntime-python/src/harness/test.rs new file mode 100644 index 0000000..3ed577a --- /dev/null +++ b/crates/tinyruntime-python/src/harness/test.rs @@ -0,0 +1,81 @@ +//! Unit tests for the shipped harness. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use tinyruntime_bus::WORKER_PROTOCOL_VERSION; + +use super::{FILENAME, SOURCE, harness}; + +#[test] +fn the_harness_runs_under_python_and_speaks_this_protocol() { + let harness = harness(); + assert_eq!(harness.executable, "python"); + assert_eq!(harness.filename, FILENAME); + assert_eq!( + harness.protocol_version, WORKER_PROTOCOL_VERSION, + "a harness on another protocol is refused at the handshake" + ); +} + +#[test] +fn output_is_unbuffered_by_both_the_flag_and_the_environment() { + // Without these a job's output can sit in a buffer until the process exits, + // which for a worker that never exits means it is never seen at all. + let harness = harness(); + assert!(harness.args_before_script.contains(&"-u".to_string())); + assert!( + harness + .env + .contains(&("PYTHONUNBUFFERED".to_string(), "1".to_string())) + ); + assert_eq!( + harness + .command_args("/cache/pool_worker.py") + .last() + .map(String::as_str), + Some("/cache/pool_worker.py"), + "the script must come after the flags" + ); +} + +#[test] +fn the_harness_announces_the_protocol_version_this_build_speaks() { + // The script's constant and the contract's are two separate declarations of + // one number; a mismatch fails every handshake at runtime. + assert!( + SOURCE.contains(&format!("PROTOCOL_VERSION = {WORKER_PROTOCOL_VERSION}")), + "the harness declares a different protocol version than the contract" + ); +} + +#[test] +fn the_harness_reads_the_protocol_environment_the_router_sets() { + assert!(SOURCE.contains("TINYRUNTIME_PROTOCOL_ADDR")); + assert!(SOURCE.contains("TINYRUNTIME_PROTOCOL_TOKEN")); +} + +#[test] +fn output_is_captured_at_the_file_descriptor_level() { + // Swapping `sys.stdout` would miss `os.write(1, ...)`, subprocess output, + // and anything a native extension writes — all of which would then land on + // whatever the real descriptor points at. + assert!( + SOURCE.contains("os.dup2("), + "capture is not descriptor-level" + ); + assert!( + SOURCE.contains("tempfile.TemporaryFile"), + "a pipe would deadlock on a job that outproduces its buffer" + ); +} + +#[test] +fn a_job_that_cannot_enter_its_directory_fails_rather_than_running_elsewhere() { + assert!(SOURCE.contains("failed to set worker cwd")); +} + +#[test] +fn a_job_calling_sys_exit_is_reported_rather_than_ending_the_worker() { + // A long-lived worker must survive `sys.exit(3)`; the exit code belongs in + // the reply instead. + assert!(SOURCE.contains("except SystemExit")); +} diff --git a/crates/tinyruntime-python/src/layout/mod.rs b/crates/tinyruntime-python/src/layout/mod.rs new file mode 100644 index 0000000..88a330e --- /dev/null +++ b/crates/tinyruntime-python/src/layout/mod.rs @@ -0,0 +1,135 @@ +//! Where a Python install keeps its executables. +//! +//! Two shapes again, but with a wrinkle Node.js does not have: the interpreter's +//! filename varies with the version. A standalone build ships `bin/python3.12` +//! and usually symlinks `bin/python3` and `bin/python` to it, but which of those +//! exist is not guaranteed, so the search tries the specific name first and falls +//! back. +//! +//! * Unix: `/python/bin/{python3.N,python3,python}`. +//! * Windows: `/python/{python.exe}`, with no `bin` directory. +//! +//! The `python/` component is the standalone channel's own doing: every build, +//! of every version, unpacks into a directory with that name. It is also why the +//! install directory in the cache is named from the asset rather than from what +//! is inside the archive. + +use std::path::{Path, PathBuf}; + +use tinyruntime_bus::{RuntimeLayout, RuntimeSettings}; + +use crate::version; + +/// The logical executables this provider reports. +pub const TOOLS: &[&str] = &["python", "pip"]; + +/// Describe the interpreter in `install_dir`, if there is a usable one. +/// +/// Returns `None` when no interpreter is found or when the one found is outside +/// the requested version range. Both are ordinary answers: the router is scanning +/// a cache that may hold several versions and several kinds of leftover. +pub async fn describe(install_dir: &Path, settings: &RuntimeSettings) -> Option { + let binary = find_interpreter(install_dir)?; + let reported = crate::system::probe_version(&binary).await?; + let parsed = version::parse_version(&reported)?; + + if !version::satisfies(parsed, &settings.version, settings.maximum_version()) { + tracing::debug!( + reported = %parsed, + "[tinyruntime-python] a cached install is outside the requested range" + ); + return None; + } + + let bin_dir = binary.parent()?; + Some(from_parts(bin_dir, &binary, &parsed.to_string())) +} + +/// Build a layout from an interpreter and the directory holding it. +/// +/// Only tools that are actually present are recorded. An install without `pip` +/// is usable, and claiming a path that is not there would turn a clear "this +/// install has no pip" into a spawn failure much later. +#[must_use] +pub fn from_parts(bin_dir: &Path, interpreter: &Path, version: &str) -> RuntimeLayout { + let mut layout = RuntimeLayout::new(version, bin_dir.to_string_lossy().into_owned()) + .with_executable("python", interpreter.to_string_lossy().into_owned()); + + for name in pip_names() { + let candidate = bin_dir.join(name); + if candidate.is_file() { + layout = layout.with_executable("pip", candidate.to_string_lossy().into_owned()); + break; + } + } + layout +} + +/// Find the interpreter inside an unpacked standalone build. +/// +/// Tries the conventional locations rather than walking the tree: a standalone +/// build is a known shape, and a walk would happily find an interpreter bundled +/// inside some package's test fixtures. +#[must_use] +pub fn find_interpreter(install_dir: &Path) -> Option { + find_interpreter_for(install_dir, cfg!(windows)) +} + +/// [`find_interpreter`] with the platform stated explicitly. +/// +/// The Windows shape is only correct by matching what the channel ships, and a +/// `cfg!(windows)` branch is never executed on the machines that run this suite. +/// Passing the platform in is what lets both shapes be checked everywhere. +#[must_use] +pub fn find_interpreter_for(install_dir: &Path, windows: bool) -> Option { + for root in [install_dir.join("python"), install_dir.to_path_buf()] { + let bin_dir = if windows { + root.clone() + } else { + root.join("bin") + }; + for name in interpreter_names(windows) { + let candidate = bin_dir.join(&name); + if candidate.is_file() { + return Some(candidate); + } + } + } + None +} + +/// Interpreter filenames to try, most specific first. +/// +/// The versioned names come first because a build may ship `python3.12` without +/// the generic symlinks, and because on a host install `python` may well be a +/// Python 2 left over from a previous decade. +fn interpreter_names(windows: bool) -> Vec { + let mut names = Vec::new(); + if windows { + names.push("python.exe".to_owned()); + return names; + } + // A standalone build's minor version is not known here, so the generic + // names do the work and the version is confirmed by probing. + for name in ["python3", "python"] { + names.push(name.to_owned()); + } + names +} + +/// Package-installer filenames to try. +fn pip_names() -> Vec { + pip_names_for(cfg!(windows)) +} + +/// [`pip_names`] with the platform stated explicitly. +fn pip_names_for(windows: bool) -> Vec { + if windows { + vec!["pip.exe".to_owned(), "pip3.exe".to_owned()] + } else { + vec!["pip3".to_owned(), "pip".to_owned()] + } +} + +#[cfg(test)] +mod test; diff --git a/crates/tinyruntime-python/src/layout/test.rs b/crates/tinyruntime-python/src/layout/test.rs new file mode 100644 index 0000000..a0991a3 --- /dev/null +++ b/crates/tinyruntime-python/src/layout/test.rs @@ -0,0 +1,198 @@ +//! Unit tests for the Python install layout. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::fs; +use std::path::Path; + +use tinyruntime_bus::RuntimeSettings; + +use super::{find_interpreter, from_parts}; + +/// Build an unpacked standalone build with the named files in its bin directory. +fn fabricate(root: &Path, files: &[&str]) -> std::path::PathBuf { + let bin = if cfg!(windows) { + root.join("python") + } else { + root.join("python").join("bin") + }; + fs::create_dir_all(&bin).unwrap(); + for file in files { + fs::write(bin.join(file), b"").unwrap(); + } + bin +} + +#[cfg(unix)] +#[test] +fn the_interpreter_is_found_inside_the_channels_python_directory() { + // Every standalone build, of every version, unpacks into `python/`. + let scratch = tempfile::tempdir().unwrap(); + fabricate(scratch.path(), &["python3"]); + + let found = find_interpreter(scratch.path()).expect("the interpreter is there"); + assert!( + found.ends_with("python/bin/python3"), + "found {}", + found.display() + ); +} + +#[cfg(unix)] +#[test] +fn an_install_without_the_wrapper_directory_still_resolves() { + // A host interpreter, or a build laid out differently, is still usable. + let scratch = tempfile::tempdir().unwrap(); + let bin = scratch.path().join("bin"); + fs::create_dir_all(&bin).unwrap(); + fs::write(bin.join("python3"), b"").unwrap(); + + let found = find_interpreter(scratch.path()).expect("the interpreter is there"); + assert!(found.ends_with("bin/python3"), "found {}", found.display()); +} + +#[test] +fn a_directory_with_no_interpreter_is_not_an_install() { + let scratch = tempfile::tempdir().unwrap(); + fabricate(scratch.path(), &[]); + assert!(find_interpreter(scratch.path()).is_none()); +} + +#[test] +fn an_empty_directory_is_not_an_install() { + let scratch = tempfile::tempdir().unwrap(); + assert!(find_interpreter(scratch.path()).is_none()); +} + +#[cfg(unix)] +#[test] +fn a_layout_records_the_interpreter_and_its_package_installer() { + let scratch = tempfile::tempdir().unwrap(); + let bin = fabricate(scratch.path(), &["python3", "pip3"]); + + let layout = from_parts(&bin, &bin.join("python3"), "3.12.4"); + assert_eq!(layout.version, "3.12.4"); + assert!(layout.executable("python").unwrap().ends_with("python3")); + assert!(layout.executable("pip").unwrap().ends_with("pip3")); +} + +#[cfg(unix)] +#[test] +fn an_install_without_pip_is_still_a_usable_layout() { + // Claiming a pip that is not there turns a clear absence into a confusing + // spawn failure much later. + let scratch = tempfile::tempdir().unwrap(); + let bin = fabricate(scratch.path(), &["python3"]); + + let layout = from_parts(&bin, &bin.join("python3"), "3.12.4"); + assert!(layout.executable("python").is_some()); + assert!(layout.executable("pip").is_none()); +} + +#[test] +fn the_windows_layout_is_checked_everywhere_rather_than_only_on_windows() { + // A standalone build on Windows has no `bin/` directory and ships + // `python.exe`. Neither is exercised by a `cfg!` branch on Linux. + let scratch = tempfile::tempdir().unwrap(); + let root = scratch.path().join("python"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(root.join("python.exe"), b"").unwrap(); + + let found = super::find_interpreter_for(scratch.path(), true) + .expect("the Windows interpreter is found"); + assert!( + found.ends_with("python/python.exe"), + "found {}", + found.display() + ); + + assert!( + super::find_interpreter_for(scratch.path(), false).is_none(), + "the Unix search must not match a Windows layout" + ); +} + +#[test] +fn the_package_installer_is_named_per_platform() { + assert_eq!(super::pip_names_for(true), vec!["pip.exe", "pip3.exe"]); + assert_eq!(super::pip_names_for(false), vec!["pip3", "pip"]); +} + +/// Write an executable standing in for an interpreter, printing `version`. +/// +/// Waits until the script actually runs before returning. A file written and +/// immediately executed can transiently fail — the kernel may still see a writer +/// on it — and a failed probe is reported as "no interpreter", which would +/// surface as a confusing assertion failure rather than as the flake it is. +#[cfg(unix)] +fn fake_python(bin: &Path, version: &str) { + use std::os::unix::fs::PermissionsExt; + + let path = bin.join("python3"); + fs::write(&path, format!("#!/bin/sh\necho '{version}'\n")).expect("the script writes"); + fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).expect("it is executable"); + + for _ in 0..50 { + if std::process::Command::new(&path) + .arg("--version") + .output() + .is_ok_and(|out| out.status.success()) + { + return; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + panic!( + "the fake interpreter at {} never became runnable", + path.display() + ); +} + +#[cfg(unix)] +#[tokio::test] +async fn an_install_inside_the_requested_range_is_described() { + let scratch = tempfile::tempdir().unwrap(); + let bin = fabricate(scratch.path(), &["pip3"]); + fake_python(&bin, "Python 3.12.4"); + + let layout = super::describe(scratch.path(), &RuntimeSettings::new("3.12")) + .await + .expect("a compatible install is described"); + + assert_eq!(layout.version, "3.12.4"); + assert!(layout.executable("python").is_some()); + assert!(layout.executable("pip").is_some()); +} + +#[cfg(unix)] +#[tokio::test] +async fn an_install_outside_the_requested_range_is_not_described() { + // The router scans a cache that may hold several series; reporting one the + // caller excluded would run the wrong interpreter. + let scratch = tempfile::tempdir().unwrap(); + let bin = fabricate(scratch.path(), &[]); + fake_python(&bin, "Python 3.11.9"); + + assert!( + super::describe(scratch.path(), &RuntimeSettings::new("3.12")) + .await + .is_none() + ); +} + +#[cfg(unix)] +#[tokio::test] +async fn an_install_whose_interpreter_does_not_answer_is_not_described() { + use std::os::unix::fs::PermissionsExt; + + let scratch = tempfile::tempdir().unwrap(); + let bin = fabricate(scratch.path(), &[]); + let path = bin.join("python3"); + fs::write(&path, "#!/bin/sh\nexit 1\n").unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).unwrap(); + + assert!( + super::describe(scratch.path(), &RuntimeSettings::new("3.12")) + .await + .is_none() + ); +} diff --git a/crates/tinyruntime-python/src/lib.rs b/crates/tinyruntime-python/src/lib.rs new file mode 100644 index 0000000..4192458 --- /dev/null +++ b/crates/tinyruntime-python/src/lib.rs @@ -0,0 +1,80 @@ +//! The Python runtime provider for tinyruntime. +//! +//! # What this crate is +//! +//! One half of a deliberate split. `tinyruntime` — the router — owns everything +//! that is the same for every language: downloading an archive, verifying its +//! digest, unpacking it, promoting it into a cache atomically, reusing it on the +//! next start, and keeping a bounded set of warm interpreter processes in front +//! of it. This crate owns everything that is true only of Python: +//! +//! - [`version`] — that a Python request names a *floor* rather than an exact +//! version, because that is what its distribution channel actually supports. +//! - [`system`] — how to find a host interpreter, and why `python3.12` is tried +//! before `python3`. +//! - [`distribution`] — how to search a moving release index for the newest build +//! inside the requested range, and why a stripped build wins a tie. +//! - [`layout`] — that a standalone build hides its interpreter under `python/`, +//! whatever version it is. +//! - [`harness`](mod@harness) — what a warm Python worker is, and the two things it cannot +//! promise that the Node one can. +//! +//! It downloads nothing, installs nothing, and starts no worker. Every answer it +//! gives is a description the router acts on. +//! +//! # A note on isolation +//! +//! Python cannot isolate a pooled job the way JavaScript can: there is no worker +//! thread to run it in and no safe way to kill one. Jobs on a warm Python worker +//! share module state, `os.environ`, and logging configuration. The harness +//! gives each job fresh globals and the router recycles workers after a job +//! budget, which bounds the leakage without eliminating it — which is why a host +//! opts into Python pooling rather than getting it by default. +//! +//! # Using it +//! +//! Load it alongside `tinyruntime`, which routes `python` to the well-known name +//! this module claims. A host then asks the router to run Python and never +//! addresses this module directly. +//! +//! ``` +//! use tinyruntime_python::{DEFAULT_VERSION, parse_version, satisfies}; +//! +//! // A request names a floor, so a newer interpreter satisfies it. +//! let installed = parse_version("Python 3.13.1").expect("a version"); +//! assert!(satisfies(installed, DEFAULT_VERSION, None)); +//! assert!(!satisfies(installed, DEFAULT_VERSION, Some("3.13"))); +//! ``` + +pub mod distribution; +pub mod error; +pub mod harness; +pub mod layout; +pub mod system; +pub mod version; + +mod tinybus_module; + +pub use error::{Error, Result}; +pub use harness::harness; +pub use tinybus_module::DEFAULT_VERSION; +pub use version::{Version, parse_version, satisfies}; + +/// Whether this provider's contract version can bind to the one it was built +/// against. +/// +/// Always true for a build whose vendored contract matches, and the check the +/// router makes before routing anything here. Exposed so a host can assert it +/// without reconstructing the comparison. +#[must_use] +pub fn is_compatible_with_contract() -> bool { + tinyruntime_bus::is_compatible(CONTRACT_VERSION) +} + +// The wire contract, re-exported whole, so a consumer of this crate names the +// very types the module serves rather than copies of them. +pub use tinyruntime_bus::{ + ArchiveFormat, CONTRACT_VERSION, Distribution, Language, LayoutRequest, LayoutResponse, + PROVIDER_INTERFACE, PROVIDER_METHODS, PYTHON, ProviderDescriptor, RuntimeLayout, + RuntimeSettings, WORKER_PROTOCOL_VERSION, WorkerHarness, names, object_path_for, +}; diff --git a/crates/tinyruntime-python/src/system/mod.rs b/crates/tinyruntime-python/src/system/mod.rs new file mode 100644 index 0000000..3577175 --- /dev/null +++ b/crates/tinyruntime-python/src/system/mod.rs @@ -0,0 +1,203 @@ +//! Finding a Python interpreter the host already has. +//! +//! Cheap, and worth trying first: most machines have a usable `python3`, and one +//! `--version` probe is the difference between using it and downloading a +//! standalone build to sit beside it. +//! +//! The candidate order is the interesting part. A caller's preferred command +//! first, then the exact series the floor names (`python3.12`), then the generic +//! `python3`, then bare `python`. Trying the series-specific name before the +//! generic one matters on a machine with several interpreters installed: `python3` +//! is whatever the distribution decided, and it is often older than the versioned +//! binary sitting right next to it. + +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::time::Duration; + +use tinyruntime_bus::{RuntimeLayout, RuntimeSettings}; + +use crate::layout; +use crate::version::{self, Version}; + +/// How long a `--version` probe may take before it is abandoned. +/// +/// The probe runs an interpreter, which can hang on a network filesystem or +/// behind an antivirus scanner. A probe that does not answer is treated as no +/// interpreter rather than waited on. +const PROBE_TIMEOUT: Duration = Duration::from_secs(5); + +/// Look for a host interpreter satisfying `settings`. +/// +/// Returns `None` when nothing suitable is installed, which is the signal for +/// the router to install a managed build instead. +pub async fn detect(settings: &RuntimeSettings) -> Option { + detect_in(settings, std::env::var_os("PATH").as_ref()).await +} + +/// [`detect`] with the search path supplied explicitly. +/// +/// Split out so a test can point the probe at a directory it controls. Rewriting +/// the process environment is not an option — `unsafe` is forbidden +/// workspace-wide, and a shared `PATH` would make concurrent tests interfere — +/// and the candidate ordering here is the whole point of the module. +pub async fn detect_in( + settings: &RuntimeSettings, + path_var: Option<&std::ffi::OsString>, +) -> Option { + let Some(minimum) = version::parse_version(&settings.version) else { + tracing::warn!( + "[tinyruntime-python] the minimum version is not a version; skipping host detection" + ); + return None; + }; + + for candidate in candidates(settings.preferred_command(), minimum) { + let Some(path) = locate(&candidate, path_var) else { + continue; + }; + let Some(reported) = probe_version(&path).await else { + tracing::debug!("[tinyruntime-python] a candidate did not answer `--version`"); + continue; + }; + let Some(parsed) = version::parse_version(&reported) else { + continue; + }; + if !version::satisfies(parsed, &settings.version, settings.maximum_version()) { + tracing::debug!( + reported = %parsed, + "[tinyruntime-python] a host interpreter is outside the requested range" + ); + continue; + } + + let Some(bin_dir) = path.parent() else { + continue; + }; + tracing::info!( + reported = %parsed, + "[tinyruntime-python] reusing a compatible host interpreter" + ); + return Some(layout::from_parts(bin_dir, &path, &parsed.to_string())); + } + None +} + +/// The commands to try, in order. +/// +/// The series-specific name comes before the generic one: on a machine with +/// several interpreters, `python3` is whatever the distribution chose and is +/// often older than the `python3.12` sitting beside it. +fn candidates(preferred: Option<&str>, minimum: Version) -> Vec { + let mut candidates = Vec::new(); + if let Some(preferred) = preferred { + candidates.push(preferred.to_owned()); + } + for fallback in [ + format!("python{}.{}", minimum.major, minimum.minor), + "python3".to_owned(), + "python".to_owned(), + ] { + if !candidates.contains(&fallback) { + candidates.push(fallback); + } + } + candidates +} + +/// Resolve a command to an executable file, searching `PATH` for a bare name. +fn locate(command: &str, path_var: Option<&std::ffi::OsString>) -> Option { + let as_path = Path::new(command); + if as_path.is_absolute() || as_path.components().count() > 1 { + return is_executable(as_path).then(|| as_path.to_path_buf()); + } + + let path_var = path_var?; + for directory in std::env::split_paths(path_var) { + let candidate = directory.join(command); + if is_executable(&candidate) { + return Some(candidate); + } + if let Some(found) = windows_executable(&directory, command, cfg!(windows)) { + return Some(found); + } + } + None +} + +/// The `.exe` a bare command names on Windows, if it is there. +/// +/// The platform is a parameter rather than a `cfg!`, so the Windows lookup is +/// exercised on the machines that actually run this suite. +fn windows_executable(directory: &Path, command: &str, windows: bool) -> Option { + if !windows { + return None; + } + let candidate = directory.join(format!("{command}.exe")); + is_executable(&candidate).then_some(candidate) +} + +/// Whether `path` is a file this process could execute. +#[cfg(unix)] +fn is_executable(path: &Path) -> bool { + use std::os::unix::fs::PermissionsExt; + std::fs::metadata(path) + .is_ok_and(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0) +} + +/// Whether `path` is a file. Windows has no execute bit to consult. +#[cfg(not(unix))] +fn is_executable(path: &Path) -> bool { + path.is_file() +} + +/// Ask an interpreter what version it is, within a bounded time. +/// +/// Older Python releases printed their version to standard error rather than +/// standard output, so both are read. Returns `None` for anything other than a +/// clean, prompt answer. +pub async fn probe_version(binary: &Path) -> Option { + let mut command = tokio::process::Command::new(binary); + command + .arg("--version") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + no_console_window(&mut command); + + let output = tokio::time::timeout(PROBE_TIMEOUT, command.output()) + .await + .ok()? + .ok()?; + if !output.status.success() { + return None; + } + + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_owned(); + let reported = if stdout.is_empty() { + String::from_utf8_lossy(&output.stderr).trim().to_owned() + } else { + stdout + }; + if reported.is_empty() { + None + } else { + Some(reported) + } +} + +/// Suppress the console window Windows would flash for each probe. +#[cfg(windows)] +fn no_console_window(command: &mut tokio::process::Command) { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + command.creation_flags(CREATE_NO_WINDOW); +} + +/// No-op off Windows. +#[cfg(not(windows))] +fn no_console_window(_command: &mut tokio::process::Command) {} + +#[cfg(test)] +mod test; diff --git a/crates/tinyruntime-python/src/system/test.rs b/crates/tinyruntime-python/src/system/test.rs new file mode 100644 index 0000000..a86fef5 --- /dev/null +++ b/crates/tinyruntime-python/src/system/test.rs @@ -0,0 +1,201 @@ +//! Unit tests for host interpreter detection. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::path::Path; + +use tinyruntime_bus::RuntimeSettings; + +use super::{candidates, detect, detect_in, locate, probe_version, windows_executable}; +use crate::version::parse_version; + +#[test] +fn the_series_specific_name_is_tried_before_the_generic_one() { + // On a machine with several interpreters, `python3` is whatever the + // distribution chose and is often older than the versioned binary next to it. + let ordered = candidates(None, parse_version("3.12").unwrap()); + assert_eq!(ordered, vec!["python3.12", "python3", "python"]); +} + +#[test] +fn a_preferred_command_is_tried_first() { + let ordered = candidates(Some("/opt/py/bin/python3"), parse_version("3.12").unwrap()); + assert_eq!(ordered[0], "/opt/py/bin/python3"); + assert_eq!(ordered[1], "python3.12"); +} + +#[test] +fn a_preferred_command_that_is_already_a_fallback_is_not_repeated() { + let ordered = candidates(Some("python3"), parse_version("3.12").unwrap()); + assert_eq!(ordered, vec!["python3", "python3.12", "python"]); +} + +#[test] +fn the_series_name_follows_the_configured_floor() { + let ordered = candidates(None, parse_version("3.14").unwrap()); + assert_eq!(ordered[0], "python3.14"); +} + +#[test] +fn an_absolute_command_that_is_not_there_does_not_resolve() { + assert!(locate("/nonexistent/path/to/python3", None).is_none()); +} + +#[cfg(unix)] +#[test] +fn a_bare_command_resolves_through_the_search_path() { + let path = std::env::var_os("PATH").expect("a host has a PATH"); + assert!( + locate("sh", Some(&path)).is_some(), + "PATH lookup found nothing at all" + ); +} + +#[test] +fn a_bare_command_with_no_search_path_does_not_resolve() { + assert!(locate("python3", None).is_none()); +} + +#[test] +fn the_windows_executable_lookup_is_checked_everywhere() { + let scratch = tempfile::tempdir().expect("scratch directory"); + let named = scratch.path().join("python.exe"); + std::fs::write(&named, b"binary").expect("the file writes"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&named, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + + assert_eq!( + windows_executable(scratch.path(), "python", true), + Some(named) + ); + assert_eq!( + windows_executable(scratch.path(), "python", false), + None, + "the lookup must not fire off Windows" + ); +} + +/// Write an executable standing in for an interpreter, printing `version`. +/// +/// Waits until the script actually runs before returning. A file written and +/// immediately executed can transiently fail — the kernel may still see a writer +/// on it — and a failed probe is reported as "no interpreter", which would +/// surface as a confusing assertion failure rather than as the flake it is. +#[cfg(unix)] +fn fake_python(directory: &Path, name: &str, version: &str) -> std::path::PathBuf { + use std::os::unix::fs::PermissionsExt; + + let path = directory.join(name); + std::fs::write(&path, format!("#!/bin/sh\necho '{version}'\n")).expect("the script writes"); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)) + .expect("the script is executable"); + + for _ in 0..50 { + if std::process::Command::new(&path) + .arg("--version") + .output() + .is_ok_and(|out| out.status.success()) + { + return path; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + panic!( + "the fake interpreter at {} never became runnable", + path.display() + ); +} + +#[cfg(unix)] +#[tokio::test] +async fn an_interpreter_inside_the_range_is_reused() { + let scratch = tempfile::tempdir().expect("scratch directory"); + fake_python(scratch.path(), "python3", "Python 3.13.1"); + + let path = std::ffi::OsString::from(scratch.path()); + let layout = detect_in(&RuntimeSettings::new("3.12"), Some(&path)) + .await + .expect("a newer interpreter satisfies a floor"); + assert_eq!(layout.version, "3.13.1"); + assert!(layout.executable("python").is_some()); +} + +#[cfg(unix)] +#[tokio::test] +async fn the_series_specific_name_is_preferred_over_the_generic_one() { + // On a machine with both, `python3` is often the older one. + let scratch = tempfile::tempdir().expect("scratch directory"); + fake_python(scratch.path(), "python3", "Python 3.12.0"); + fake_python(scratch.path(), "python3.14", "Python 3.14.1"); + + let path = std::ffi::OsString::from(scratch.path()); + let layout = detect_in(&RuntimeSettings::new("3.14"), Some(&path)) + .await + .expect("the versioned binary is found"); + assert_eq!(layout.version, "3.14.1"); +} + +#[cfg(unix)] +#[tokio::test] +async fn an_interpreter_below_the_floor_is_not_reused() { + let scratch = tempfile::tempdir().expect("scratch directory"); + fake_python(scratch.path(), "python3", "Python 3.11.9"); + + let path = std::ffi::OsString::from(scratch.path()); + assert!( + detect_in(&RuntimeSettings::new("3.12"), Some(&path)) + .await + .is_none() + ); +} + +#[cfg(unix)] +#[tokio::test] +async fn an_interpreter_that_prints_nothing_useful_is_skipped() { + let scratch = tempfile::tempdir().expect("scratch directory"); + fake_python(scratch.path(), "python3", "not-a-version"); + + let path = std::ffi::OsString::from(scratch.path()); + assert!( + detect_in(&RuntimeSettings::new("3.12"), Some(&path)) + .await + .is_none() + ); +} + +#[cfg(unix)] +#[tokio::test] +async fn a_binary_that_does_not_understand_the_flag_is_not_an_interpreter() { + if !Path::new("/bin/false").exists() { + return; + } + assert!(probe_version(Path::new("/bin/false")).await.is_none()); +} + +#[tokio::test] +async fn a_binary_that_is_not_there_is_not_probed_successfully() { + assert!( + probe_version(Path::new("/nonexistent/python3")) + .await + .is_none() + ); +} + +#[tokio::test] +async fn an_unparseable_floor_detects_nothing() { + assert!(detect(&RuntimeSettings::new("latest")).await.is_none()); +} + +#[tokio::test] +async fn a_ceiling_below_everything_installed_detects_nothing() { + // Even on a machine with Python, a range nothing satisfies must come back + // empty rather than handing over an interpreter outside it. + let mut settings = RuntimeSettings::new("3.0"); + settings.maximum_version = "3.1".to_string(); + assert!( + detect(&settings).await.is_none(), + "an interpreter outside the requested range was accepted" + ); +} diff --git a/crates/template/src/tinybus_module/README.md b/crates/tinyruntime-python/src/tinybus_module/README.md similarity index 100% rename from crates/template/src/tinybus_module/README.md rename to crates/tinyruntime-python/src/tinybus_module/README.md diff --git a/crates/tinyruntime-python/src/tinybus_module/mod.rs b/crates/tinyruntime-python/src/tinybus_module/mod.rs new file mode 100644 index 0000000..ac21e1c --- /dev/null +++ b/crates/tinyruntime-python/src/tinybus_module/mod.rs @@ -0,0 +1,110 @@ +//! The `TinyBus` module entrypoint and the provider interface. +//! +//! This module answers the router's five questions and does nothing else. It +//! downloads no archive, unpacks nothing, writes nothing to a cache, and starts +//! no worker — all of that belongs to the router, which does it identically for +//! every language. +//! +//! What is left is exactly the Python knowledge: which host interpreters count, +//! which standalone build to install from a moving release index, where the +//! interpreter sits inside it, and what a warm Python worker is. +//! +//! The interface it implements is [`names::PROVIDER_INTERFACE`], the same one +//! every provider implements — that is what makes them interchangeable. The +//! well-known name it claims is its own, because two peers cannot hold the same +//! one, and it serves at the path derived from that name: `tinybus_module!` +//! builds this module's manifest path the same way, so serving anywhere else +//! would ship a manifest that disagreed with the object exported here. + +use std::path::Path; + +use reqwest::Client; +use tinybus::{Connection, Result as TinyBusResult}; + +use tinyruntime_bus::{ + Distribution, Language, LayoutRequest, LayoutResponse, ProviderDescriptor, RuntimeSettings, + WorkerHarness, names, +}; + +use crate::{distribution, harness, layout, system}; + +/// The version floor this provider targets when a host expresses no preference. +/// +/// A floor rather than a pin, because that is what the standalone channel +/// supports: it publishes a moving set of builds, and an exact patch would stop +/// resolving the moment that build rotated out. +pub const DEFAULT_VERSION: &str = "3.12"; + +/// The object this module serves. +struct PythonProvider { + client: Client, +} + +#[tinybus::interface(name = "ai.tinyhumans.runtime.Provider")] +impl PythonProvider { + /// What this provider is and what it targets by default. + async fn describe(&self) -> TinyBusResult { + let mut descriptor = ProviderDescriptor::new(Language::python(), "Python", DEFAULT_VERSION); + for tool in layout::TOOLS { + descriptor = descriptor.with_executable(*tool); + } + std::future::ready(Ok(descriptor)).await + } + + /// Look for a compatible interpreter already on this host. + async fn detect_system(&self, settings: RuntimeSettings) -> TinyBusResult { + Ok(system::detect(&settings) + .await + .map_or_else(LayoutResponse::missing, LayoutResponse::found)) + } + + /// Pick the standalone build to install. + async fn select_distribution(&self, settings: RuntimeSettings) -> TinyBusResult { + distribution::select(&self.client, &settings) + .await + .map_err(|error| tinybus::Error::failed(error.to_string())) + } + + /// Report where the interpreter is inside an install the router unpacked. + async fn layout(&self, request: LayoutRequest) -> TinyBusResult { + Ok( + layout::describe(Path::new(&request.install_dir), &request.settings) + .await + .map_or_else(LayoutResponse::missing, LayoutResponse::found), + ) + } + + /// Supply the warm-worker harness for this language. + async fn harness(&self) -> TinyBusResult { + std::future::ready(Ok(harness::harness())).await + } +} + +/// Start serving the provider interface. +async fn setup(connection: Connection) -> TinyBusResult<()> { + connection + .serve_at( + names::providers::PYTHON_OBJECT_PATH.try_into()?, + PythonProvider { + client: Client::new(), + }, + ) + .await?; + connection.request_name(names::providers::PYTHON).await?; + tracing::info!("[tinyruntime-python] serving the python runtime provider"); + Ok(()) +} + +tinybus_module::module_export! { + setup = setup, + worker_threads = 1, + provides = ["ai.tinyhumans.runtime.python.Provider"], + methods = ["Describe", "DetectSystem", "SelectDistribution", "Layout", "Harness"], + signals = [], + requires = [], + optional = [], + lazy = false, +} + +#[cfg(test)] +mod test; diff --git a/crates/tinyruntime-python/src/tinybus_module/test.rs b/crates/tinyruntime-python/src/tinybus_module/test.rs new file mode 100644 index 0000000..a22536f --- /dev/null +++ b/crates/tinyruntime-python/src/tinybus_module/test.rs @@ -0,0 +1,164 @@ +//! Tests for the module adapter and its declared surface. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use tinybus::broker::Broker; +use tinybus::transport::memory::MemoryBus; +use tinybus::{Connection, Interface, Result as TinyBusResult}; + +use tinyruntime_bus::{ + CONTRACT_VERSION, Language, LayoutRequest, LayoutResponse, ProviderDescriptor, RuntimeSettings, + WorkerHarness, names, +}; + +use super::{DEFAULT_VERSION, PythonProvider, setup}; + +fn provider() -> PythonProvider { + PythonProvider { + client: reqwest::Client::new(), + } +} + +/// Start a broker, serve the provider, and return the client proxy to it. +/// +/// The module connection is returned alongside because dropping it disconnects +/// the peer, taking the well-known name with it. +async fn serving(bus: &MemoryBus) -> TinyBusResult<(Connection, tinybus::Proxy)> { + let module = Connection::connect(bus.connect().await?).await?; + setup(module.clone()).await?; + + let client = Connection::connect(bus.connect().await?).await?; + let proxy = client.proxy( + names::providers::PYTHON, + names::providers::PYTHON_OBJECT_PATH, + names::PROVIDER_INTERFACE, + )?; + Ok((module, proxy)) +} + +fn bus() -> MemoryBus { + let bus = MemoryBus::new(); + Broker::new().spawn(bus.clone()); + bus +} + +#[test] +fn declared_methods_match_the_dispatch_table() { + let methods = provider() + .members() + .into_iter() + .map(|member| member.to_string()) + .collect::>(); + + assert_eq!(methods, names::PROVIDER_METHODS.to_vec()); +} + +#[test] +fn the_object_path_is_the_one_the_manifest_will_declare() { + // `tinybus_module!` derives this module's manifest path from its bus name. + // Serving anywhere else ships a manifest that disagrees with the object + // actually exported, which no amount of in-process testing would catch. + assert_eq!( + names::providers::PYTHON_OBJECT_PATH, + names::object_path_for(names::providers::PYTHON) + ); +} + +#[test] +fn the_served_interface_is_the_shared_provider_interface() { + // Serving anything else would make this module unroutable: the router + // addresses every provider through one interface. + assert_eq!(provider().name().to_string(), names::PROVIDER_INTERFACE); +} + +#[test] +fn the_default_floor_is_a_version() { + assert!(crate::parse_version(DEFAULT_VERSION).is_some()); +} + +#[tokio::test] +async fn the_router_can_describe_this_provider_over_a_bus() -> TinyBusResult<()> { + let bus = bus(); + let (_module, proxy) = serving(&bus).await?; + + let descriptor: ProviderDescriptor = proxy.call(names::provider_methods::DESCRIBE, ()).await?; + assert_eq!(descriptor.language, Language::python()); + assert_eq!(descriptor.display_name, "Python"); + assert_eq!( + descriptor.contract_version, CONTRACT_VERSION, + "the router refuses a provider it cannot bind to" + ); + assert!(descriptor.executables.contains(&"pip".to_string())); + Ok(()) +} + +#[tokio::test] +async fn the_harness_crosses_the_bus_intact() -> TinyBusResult<()> { + // The harness is a script the router writes out and launches; if it did not + // survive the round trip, every worker would fail at its handshake. + let bus = bus(); + let (_module, proxy) = serving(&bus).await?; + + let harness: WorkerHarness = proxy.call(names::provider_methods::HARNESS, ()).await?; + assert_eq!(harness, crate::harness()); + assert!(!harness.source.is_empty()); + Ok(()) +} + +#[tokio::test] +async fn a_directory_that_is_not_an_install_is_reported_empty_rather_than_failing() +-> TinyBusResult<()> { + // The router scans a cache full of directories that are not installs. If + // this failed instead of answering, one leftover would break every scan. + let bus = bus(); + let (_module, proxy) = serving(&bus).await?; + + let scratch = tempfile::tempdir().expect("scratch directory"); + let response: LayoutResponse = proxy + .call( + names::provider_methods::LAYOUT, + (LayoutRequest::new( + scratch.path().to_string_lossy(), + RuntimeSettings::new(DEFAULT_VERSION), + ),), + ) + .await?; + + assert!(response.layout.is_none()); + Ok(()) +} + +#[tokio::test] +async fn detecting_a_host_interpreter_answers_rather_than_failing() -> TinyBusResult<()> { + // Whether this machine has Python is not the point: the call must complete + // either way, because "nothing here" is how the router learns to install. + let bus = bus(); + let (_module, proxy) = serving(&bus).await?; + + let _: LayoutResponse = proxy + .call( + names::provider_methods::DETECT_SYSTEM, + (RuntimeSettings::new(DEFAULT_VERSION),), + ) + .await?; + Ok(()) +} + +#[tokio::test] +async fn a_floor_that_is_not_a_version_is_refused_with_a_readable_reason() -> TinyBusResult<()> { + let bus = bus(); + let (_module, proxy) = serving(&bus).await?; + + let result = proxy + .call::( + names::provider_methods::SELECT_DISTRIBUTION, + (RuntimeSettings::new("latest"),), + ) + .await; + + let Err(error) = result else { + return Err(tinybus::Error::failed("`latest` unexpectedly resolved")); + }; + let rendered = error.to_string(); + assert!(rendered.contains("latest"), "got `{rendered}`"); + Ok(()) +} diff --git a/crates/tinyruntime-python/src/version/mod.rs b/crates/tinyruntime-python/src/version/mod.rs new file mode 100644 index 0000000..f63f2cc --- /dev/null +++ b/crates/tinyruntime-python/src/version/mod.rs @@ -0,0 +1,96 @@ +//! What counts as a compatible Python version. +//! +//! Unlike Node.js, where a request names one major line, a Python request names +//! a **floor**: `3.12` means "3.12 or newer". That difference is not a style +//! choice — it follows from what the two distribution channels publish. Node.js +//! ships one archive per exact version, so asking for one is natural. The +//! standalone Python channel publishes a moving set of builds, and pinning an +//! exact patch would break the moment that build rotated out. +//! +//! A caller that needs to stay off a newer series sets an exclusive upper bound. +//! That is what keeps selection away from, say, a 3.15 pre-release sitting in the +//! same index as the 3.12 builds it actually wants. + +use std::fmt; + +/// A parsed Python version. +/// +/// Ordered by major, then minor, then patch, which is what makes the floor and +/// ceiling comparisons a plain `<` and `>=`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct Version { + /// The major component. + pub major: u32, + /// The minor component. + pub minor: u32, + /// The patch component, `0` when the spelling omits it. + pub patch: u32, +} + +impl fmt::Display for Version { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{}.{}.{}", self.major, self.minor, self.patch) + } +} + +/// Parse a version from any spelling that turns up in practice. +/// +/// Handles what `python --version` prints (`Python 3.12.4`), a bare version +/// (`3.12.4`), a series with no patch (`3.12`), and a patch carrying a suffix +/// (`3.13.0rc1`, which parses as `3.13.0` — a release candidate is that series, +/// and treating it as unparseable would silently drop it from selection). +/// +/// # Examples +/// +/// ``` +/// # use tinyruntime_python::parse_version; +/// assert_eq!(parse_version("Python 3.12.4").map(|v| v.to_string()).as_deref(), Some("3.12.4")); +/// assert_eq!(parse_version("3.12").map(|v| v.to_string()).as_deref(), Some("3.12.0")); +/// assert_eq!(parse_version("latest"), None); +/// ``` +#[must_use] +pub fn parse_version(raw: &str) -> Option { + let trimmed = raw.trim(); + let stripped = trimmed.strip_prefix("Python ").unwrap_or(trimmed).trim(); + + let mut parts = stripped.split('.'); + let major = parts.next()?.parse::().ok()?; + let minor = parts.next()?.parse::().ok()?; + let patch = parts + .next() + .and_then(|segment| { + let digits: String = segment.chars().take_while(char::is_ascii_digit).collect(); + digits.parse::().ok() + }) + .unwrap_or(0); + + Some(Version { + major, + minor, + patch, + }) +} + +/// Whether `candidate` sits within `[minimum, maximum)`. +/// +/// The upper bound is exclusive so a ceiling of `3.15` means "anything in 3.14 +/// and below", which is how a person reading the configuration would read it. +/// An absent or blank ceiling means unbounded. +#[must_use] +pub fn satisfies(candidate: Version, minimum: &str, maximum: Option<&str>) -> bool { + let Some(minimum) = parse_version(minimum) else { + // A floor that is not a version is a configuration error. Accepting + // anything would quietly install whatever the channel offered first. + return false; + }; + if candidate < minimum { + return false; + } + match maximum.map(str::trim).filter(|value| !value.is_empty()) { + Some(maximum) => parse_version(maximum).is_some_and(|maximum| candidate < maximum), + None => true, + } +} + +#[cfg(test)] +mod test; diff --git a/crates/tinyruntime-python/src/version/test.rs b/crates/tinyruntime-python/src/version/test.rs new file mode 100644 index 0000000..87b90db --- /dev/null +++ b/crates/tinyruntime-python/src/version/test.rs @@ -0,0 +1,87 @@ +//! Unit tests for Python version handling. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use super::{Version, parse_version, satisfies}; + +fn version(raw: &str) -> Version { + parse_version(raw).unwrap_or_else(|| panic!("`{raw}` should parse")) +} + +#[test] +fn every_spelling_that_turns_up_in_practice_parses() { + assert_eq!(version("Python 3.12.4").to_string(), "3.12.4"); + assert_eq!(version("3.12.4").to_string(), "3.12.4"); + assert_eq!(version(" Python 3.12.4\n").to_string(), "3.12.4"); +} + +#[test] +fn a_series_with_no_patch_reads_as_the_first_release_of_it() { + assert_eq!(version("3.12").to_string(), "3.12.0"); +} + +#[test] +fn a_release_candidate_parses_as_its_series() { + // Treating it as unparseable would silently drop it from selection rather + // than letting the version bounds decide. + assert_eq!(version("3.13.0rc1").to_string(), "3.13.0"); + assert_eq!(version("3.13.2b1").to_string(), "3.13.2"); +} + +#[test] +fn something_that_is_not_a_version_does_not_parse() { + assert_eq!(parse_version("latest"), None); + assert_eq!( + parse_version("3"), + None, + "a bare major is not a python version" + ); + assert_eq!(parse_version(""), None); +} + +#[test] +fn versions_order_by_component_rather_than_lexically() { + // The bug this rules out: `3.9` sorting above `3.12` as text. + assert!(version("3.12.0") > version("3.9.20")); + assert!(version("3.12.10") > version("3.12.9")); + assert!(version("4.0.0") > version("3.99.99")); +} + +#[test] +fn a_request_names_a_floor_rather_than_an_exact_version() { + assert!(satisfies(version("3.12.4"), "3.12", None)); + assert!( + satisfies(version("3.13.1"), "3.12", None), + "newer satisfies a floor" + ); + assert!(!satisfies(version("3.11.9"), "3.12", None)); +} + +#[test] +fn an_exclusive_ceiling_keeps_selection_off_a_newer_series() { + // The case this exists for: pre-releases of a newer series sitting in the + // same index as the builds actually wanted. + assert!(satisfies(version("3.14.1"), "3.12", Some("3.15"))); + assert!(!satisfies(version("3.15.0"), "3.12", Some("3.15"))); + assert!( + !satisfies(version("3.15.0"), "3.12", Some("3.15")), + "the ceiling is exclusive, so 3.15.0 itself is out" + ); +} + +#[test] +fn a_blank_ceiling_is_no_ceiling() { + assert!(satisfies(version("3.99.0"), "3.12", Some(""))); + assert!(satisfies(version("3.99.0"), "3.12", Some(" "))); + assert!(satisfies(version("3.99.0"), "3.12", None)); +} + +#[test] +fn a_floor_that_is_not_a_version_accepts_nothing() { + // A misconfigured floor must not quietly install whatever came first. + assert!(!satisfies(version("3.12.4"), "latest", None)); +} + +#[test] +fn a_ceiling_that_is_not_a_version_accepts_nothing() { + assert!(!satisfies(version("3.12.4"), "3.12", Some("nonsense"))); +} diff --git a/crates/tinyruntime-python/tests/harness_protocol.rs b/crates/tinyruntime-python/tests/harness_protocol.rs new file mode 100644 index 0000000..715aa1b --- /dev/null +++ b/crates/tinyruntime-python/tests/harness_protocol.rs @@ -0,0 +1,394 @@ +//! End-to-end tests for the shipped worker harness, against a real `python`. +//! +//! The harness is the one part of this crate that is not Rust, so nothing else +//! in the suite can check that it actually speaks the protocol. These tests +//! stand in for the router: they listen on loopback, launch the harness the way +//! the router would, complete the handshake, and run jobs through it. +//! +//! They skip when the machine has no Python 3, so the suite stays hermetic on a +//! runner without one rather than failing for the wrong reason. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::path::PathBuf; +use std::process::Stdio; +use std::time::Duration; + +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, Lines}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::process::{Child, Command}; + +use tinyruntime_python::{WORKER_PROTOCOL_VERSION, harness}; + +/// How long any single step may take before the test gives up. +const STEP_TIMEOUT: Duration = Duration::from_secs(30); + +/// Read and discard a child stream, so a job writing to it never blocks. +fn drain(stream: impl tokio::io::AsyncRead + Send + Unpin + 'static) { + tokio::spawn(async move { + let mut lines = BufReader::new(stream).lines(); + while let Ok(Some(_)) = lines.next_line().await {} + }); +} + +/// The first working Python 3 on this machine, or `None`. +async fn interpreter() -> Option { + for candidate in ["python3", "python"] { + let usable = Command::new(candidate) + .arg("--version") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .await + .is_ok_and(|status| status.success()); + if usable { + return Some(candidate.to_string()); + } + } + None +} + +/// A harness process under test, plus the protocol connection to it. +struct Harness { + _child: Child, + writer: tokio::io::WriteHalf, + lines: Lines>>, + _scratch: tempfile::TempDir, + cwd: PathBuf, +} + +impl Harness { + /// Launch the harness the way the router would, or `None` without Python. + async fn launch() -> Option { + let Some(binary) = interpreter().await else { + eprintln!("skipped: this machine has no usable python"); + return None; + }; + + let scratch = tempfile::tempdir().unwrap(); + let harness = harness(); + let script = scratch.path().join(&harness.filename); + std::fs::write(&script, &harness.source).unwrap(); + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let token = "test-secret-token"; + + let mut command = Command::new(binary); + command + .args(harness.command_args(&script.to_string_lossy())) + .env("TINYRUNTIME_PROTOCOL_ADDR", address.to_string()) + .env("TINYRUNTIME_PROTOCOL_TOKEN", token) + .env("PATH", std::env::var("PATH").unwrap_or_default()); + for (name, value) in &harness.env { + command.env(name, value); + } + let mut child = command + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .unwrap(); + + let (stream, _) = tokio::time::timeout(STEP_TIMEOUT, listener.accept()) + .await + .expect("the harness connected back") + .unwrap(); + let (reader, writer) = tokio::io::split(stream); + let mut lines = BufReader::new(reader).lines(); + + let handshake: serde_json::Value = serde_json::from_str( + &tokio::time::timeout(STEP_TIMEOUT, lines.next_line()) + .await + .expect("the handshake arrived") + .unwrap() + .expect("the harness sent a handshake"), + ) + .unwrap(); + + assert_eq!(handshake["ready"], serde_json::json!(true)); + assert_eq!( + handshake["protocol"], + serde_json::json!(WORKER_PROTOCOL_VERSION) + ); + assert_eq!(handshake["language"], serde_json::json!("python")); + assert_eq!( + handshake["token"], + serde_json::json!(token), + "the harness must echo the secret it was given" + ); + + // Drain the child's own descriptors, exactly as the router does. This is + // not tidiness: a job that writes to fd 1 blocks once the pipe fills. + if let Some(stdout) = child.stdout.take() { + drain(stdout); + } + if let Some(stderr) = child.stderr.take() { + drain(stderr); + } + + let cwd = scratch.path().to_path_buf(); + Some(Self { + _child: child, + writer, + lines, + _scratch: scratch, + cwd, + }) + } + + /// Run one job and return its reply. + async fn run(&mut self, id: &str, code: &str, timeout_ms: Option) -> serde_json::Value { + let mut request = serde_json::json!({ + "id": id, + "code": code, + "cwd": self.cwd.to_string_lossy(), + }); + if let Some(timeout_ms) = timeout_ms { + request["timeout_ms"] = serde_json::json!(timeout_ms); + } + + let mut line = serde_json::to_string(&request).unwrap(); + line.push('\n'); + self.writer.write_all(line.as_bytes()).await.unwrap(); + self.writer.flush().await.unwrap(); + + let reply = tokio::time::timeout(STEP_TIMEOUT, self.lines.next_line()) + .await + .expect("the harness replied") + .unwrap() + .expect("the harness sent a reply"); + let reply: serde_json::Value = serde_json::from_str(&reply).unwrap(); + assert_eq!( + reply["id"], + serde_json::json!(id), + "reply was for another job" + ); + reply + } +} + +#[tokio::test] +async fn the_harness_runs_a_job_and_reports_its_output() { + let Some(mut harness) = Harness::launch().await else { + return; + }; + let reply = harness.run("1", "print(6 * 7)", None).await; + assert_eq!(reply["ok"], serde_json::json!(true)); + assert_eq!(reply["stdout"], serde_json::json!("42\n")); + assert_eq!(reply["exit_code"], serde_json::json!(0)); +} + +#[tokio::test] +async fn one_warm_worker_serves_many_jobs() { + // The entire reason the pool exists. If the harness exited after a job, this + // would fail on the second one. + let Some(mut harness) = Harness::launch().await else { + return; + }; + for index in 0..3 { + let reply = harness + .run(&index.to_string(), &format!("print({index} + 1)"), None) + .await; + assert_eq!( + reply["stdout"], + serde_json::json!(format!("{}\n", index + 1)) + ); + } +} + +#[tokio::test] +async fn each_job_gets_fresh_globals() { + // The only isolation a Python worker can offer. Without it, a name defined + // by one job would be visible to an unrelated later one. + let Some(mut harness) = Harness::launch().await else { + return; + }; + harness + .run("1", "leaked = 'from the first job'", None) + .await; + + let reply = harness.run("2", "print('leaked' in dir())", None).await; + assert_eq!(reply["stdout"], serde_json::json!("False\n")); +} + +#[tokio::test] +async fn a_job_that_raises_reports_a_traceback_without_killing_the_worker() { + let Some(mut harness) = Harness::launch().await else { + return; + }; + let raised = harness.run("1", "raise ValueError('boom')", None).await; + assert_eq!( + raised["ok"], + serde_json::json!(true), + "the harness ran it; the job failed" + ); + assert_eq!(raised["exit_code"], serde_json::json!(1)); + assert!(raised["stderr"].as_str().unwrap().contains("boom")); + + let after = harness.run("2", "print('still here')", None).await; + assert_eq!(after["stdout"], serde_json::json!("still here\n")); +} + +#[tokio::test] +async fn a_job_calling_sys_exit_reports_its_code_and_leaves_the_worker_running() { + // A long-lived worker must survive `sys.exit(3)`. Without the SystemExit + // arm the interpreter would exit and take every queued job with it. + let Some(mut harness) = Harness::launch().await else { + return; + }; + let exited = harness.run("1", "import sys; sys.exit(3)", None).await; + assert_eq!(exited["exit_code"], serde_json::json!(3)); + + let after = harness.run("2", "print('survived')", None).await; + assert_eq!(after["stdout"], serde_json::json!("survived\n")); +} + +#[tokio::test] +async fn output_written_past_the_python_layer_is_still_captured() { + // `os.write(1, ...)` bypasses `sys.stdout` entirely. If capture were not at + // the file-descriptor level this would land on the real descriptor instead + // of in the reply. + let Some(mut harness) = Harness::launch().await else { + return; + }; + let reply = harness + .run("1", "import os; os.write(1, b'LOW_LEVEL')", None) + .await; + assert_eq!(reply["stdout"], serde_json::json!("LOW_LEVEL")); +} + +#[tokio::test] +async fn a_subprocess_started_by_a_job_is_captured_too() { + let Some(mut harness) = Harness::launch().await else { + return; + }; + let reply = harness + .run( + "1", + "import subprocess, sys; subprocess.run([sys.executable, '-c', \"print('FROM_CHILD')\"])", + None, + ) + .await; + assert_eq!(reply["stdout"], serde_json::json!("FROM_CHILD\n")); +} + +#[tokio::test] +async fn relative_paths_resolve_against_the_job_directory() { + // A shared warm worker runs wherever the last job left it unless the harness + // enters each job's directory. Getting this wrong escapes the caller's sandbox. + let Some(mut harness) = Harness::launch().await else { + return; + }; + std::fs::write(harness.cwd.join("probe.txt"), b"RELATIVE_OK").unwrap(); + + let reply = harness + .run("1", "print(open('./probe.txt').read(), end='')", None) + .await; + assert_eq!(reply["stdout"], serde_json::json!("RELATIVE_OK")); +} + +#[tokio::test] +async fn a_job_whose_directory_is_missing_fails_rather_than_running_elsewhere() { + let Some(mut harness) = Harness::launch().await else { + return; + }; + let sentinel = harness.cwd.join("must-not-exist.txt"); + harness.cwd = harness.cwd.join("deleted-sandbox"); + + let reply = harness + .run( + "1", + "open('must-not-exist.txt', 'w').write('escaped')", + None, + ) + .await; + assert_eq!(reply["ok"], serde_json::json!(false), "the job ran anyway"); + assert!( + reply["error"] + .as_str() + .unwrap() + .contains("failed to set worker cwd") + ); + assert!(!sentinel.exists(), "the job escaped its sandbox"); +} + +#[tokio::test] +async fn the_working_directory_is_restored_between_jobs() { + // Jobs share one interpreter, so a job that changed directory and was not + // restored would silently relocate every job after it. + let Some(mut harness) = Harness::launch().await else { + return; + }; + std::fs::create_dir(harness.cwd.join("elsewhere")).unwrap(); + harness + .run("1", "import os; os.chdir('elsewhere')", None) + .await; + + std::fs::write(harness.cwd.join("probe.txt"), b"STILL_HERE").unwrap(); + let reply = harness + .run("2", "print(open('./probe.txt').read(), end='')", None) + .await; + assert_eq!(reply["stdout"], serde_json::json!("STILL_HERE")); +} + +#[cfg(unix)] +#[tokio::test] +async fn a_job_that_never_finishes_is_aborted_at_its_deadline() { + // Best effort, and only on Unix: the deadline is a SIGALRM, and there is no + // equivalent on Windows. The router's hard deadline is the backstop there. + let Some(mut harness) = Harness::launch().await else { + return; + }; + let reply = harness + .run("1", "import time; time.sleep(60)", Some(1_000)) + .await; + assert_eq!(reply["timed_out"], serde_json::json!(true)); + + // And the worker is still usable afterwards. + let after = harness.run("2", "print('alive')", None).await; + assert_eq!(after["stdout"], serde_json::json!("alive\n")); +} + +#[tokio::test] +async fn a_job_reads_end_of_file_on_standard_input() { + // Standard input must never be the protocol stream, or a job that read it + // would consume the next request. + let Some(mut harness) = Harness::launch().await else { + return; + }; + let reply = harness + .run( + "1", + "import os, sys; print(repr(sys.stdin.read())); print(os.read(0, 1))", + Some(5_000), + ) + .await; + assert_eq!(reply["stdout"], serde_json::json!("''\nb''\n")); +} + +#[tokio::test] +async fn a_job_cannot_forge_a_reply_over_its_own_descriptor() { + // The reason the protocol has its own socket: a job writing a frame-shaped + // line to fd 1 must not be able to answer its own request. Here the write is + // captured as ordinary job output instead. + let Some(mut harness) = Harness::launch().await else { + return; + }; + let reply = harness + .run( + "1", + "import os, json; os.write(1, (json.dumps({'id': '1', 'ok': True, 'stdout': 'FORGED'}) + '\\n').encode()); print('REAL')", + None, + ) + .await; + + assert_eq!(reply["ok"], serde_json::json!(true)); + let stdout = reply["stdout"].as_str().unwrap(); + assert!( + stdout.contains("FORGED"), + "the forged frame was not captured as output" + ); + assert!(stdout.ends_with("REAL\n"), "got {stdout:?}"); +} diff --git a/crates/tinyruntime-python/tests/public_api.rs b/crates/tinyruntime-python/tests/public_api.rs new file mode 100644 index 0000000..3731ce4 --- /dev/null +++ b/crates/tinyruntime-python/tests/public_api.rs @@ -0,0 +1,61 @@ +//! Integration tests for the public crate surface. +//! +//! These link against the crate as a downstream consumer would: they can only +//! use what `src/lib.rs` re-exports. Treat them as the regression suite for the +//! crate's public contract — if a change breaks a test here, it is a breaking +//! change for users. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use tinyruntime_python::{ + CONTRACT_VERSION, DEFAULT_VERSION, Error, Language, PYTHON, distribution, harness, layout, + names, parse_version, satisfies, +}; + +#[test] +fn the_provider_serves_the_shared_interface_from_the_contract() { + assert_eq!( + names::PROVIDER_INTERFACE, + tinyruntime_python::PROVIDER_INTERFACE + ); + assert_eq!( + names::providers::PYTHON_OBJECT_PATH, + names::object_path_for(names::providers::PYTHON) + ); + assert_eq!(names::PROVIDER_METHODS.len(), 5); + assert_eq!(Language::python().as_str(), PYTHON); +} + +#[test] +fn the_contract_is_re_exported_so_consumers_take_one_dependency() { + let same: tinyruntime_bus::WorkerHarness = harness(); + assert_eq!(same, harness()); + assert!(tinyruntime_python::is_compatible_with_contract()); + let _ = CONTRACT_VERSION; +} + +#[test] +fn the_default_floor_is_one_this_crate_can_reason_about() { + let floor = parse_version(DEFAULT_VERSION).expect("the default floor is a version"); + assert!(satisfies(floor, DEFAULT_VERSION, None)); +} + +#[test] +fn the_host_table_and_the_layout_agree_about_this_platform() { + // A host the channel publishes for must also be one the layout knows the + // shape of, or an install would succeed and then be unusable. + if distribution::host_suffix().is_ok() { + let scratch = tempfile::tempdir().unwrap(); + assert!( + layout::find_interpreter(scratch.path()).is_none(), + "an empty directory must not read as an install" + ); + assert!(layout::TOOLS.contains(&"python")); + } +} + +#[test] +fn an_unsupported_host_is_reported_by_name() { + let error = distribution::suffix_for("plan9", "x86_64").expect_err("no build exists"); + assert!(matches!(error, Error::UnsupportedHost { .. })); +} diff --git a/vendor/tinyruntime b/vendor/tinyruntime new file mode 160000 index 0000000..cf67fd3 --- /dev/null +++ b/vendor/tinyruntime @@ -0,0 +1 @@ +Subproject commit cf67fd38f039767cc40814f9b09d6956aee93ad9