From 883d746942f1fce91616159ea1538f970f8ca3c0 Mon Sep 17 00:00:00 2001 From: Many Kasiriha Date: Thu, 12 Nov 2026 19:11:51 +0100 Subject: [PATCH 1/3] docs(openspec): public-bundle-api change (issue #5) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FHEmzL3ErAhGQykKWDx2wP --- .../changes/public-bundle-api/.openspec.yaml | 2 + openspec/changes/public-bundle-api/design.md | 106 ++++++++++++++++++ .../changes/public-bundle-api/proposal.md | 51 +++++++++ .../specs/installer-python-api/spec.md | 105 +++++++++++++++++ openspec/changes/public-bundle-api/tasks.md | 38 +++++++ 5 files changed, 302 insertions(+) create mode 100644 openspec/changes/public-bundle-api/.openspec.yaml create mode 100644 openspec/changes/public-bundle-api/design.md create mode 100644 openspec/changes/public-bundle-api/proposal.md create mode 100644 openspec/changes/public-bundle-api/specs/installer-python-api/spec.md create mode 100644 openspec/changes/public-bundle-api/tasks.md diff --git a/openspec/changes/public-bundle-api/.openspec.yaml b/openspec/changes/public-bundle-api/.openspec.yaml new file mode 100644 index 0000000..55ab199 --- /dev/null +++ b/openspec/changes/public-bundle-api/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-11-12 diff --git a/openspec/changes/public-bundle-api/design.md b/openspec/changes/public-bundle-api/design.md new file mode 100644 index 0000000..388185a --- /dev/null +++ b/openspec/changes/public-bundle-api/design.md @@ -0,0 +1,106 @@ +## Context + +See proposal.md (Why). The parts of the current package that shape the approach: + +- `hatch_build.py` copies each active and deprecated plugin into `_assets/plugins//` and writes `_assets/catalog.json` as `{"plugins": [...]}`. Each entry is built from `catalog.yaml` and `plugin.json`: name, version, tier, status, summary, libraries, dependencies, bundle, bundled, plus replacement and deprecated_since when set. It drops `catalog.yaml`'s `robotframework` range. +- `_assets.py` reads the bundle with `importlib.resources.files`. `bundled_catalog()` returns the parsed list, `content_plugins()` the active non-bundle names with `rf-core` first, and `asset_root_path()` a real path through `as_file`, scoped to a `with` block. +- `as_file` returns the path itself for an unpacked install (wheel, editable). For a zip import it copies to a temporary location that is removed when the `with` block ends. On Python 3.10 and 3.11 it can't do this for directories: it calls `read_bytes()` on the directory. The package supports Python 3.10 and later. +- The wheel's `_assets/` is generated, so tests that check it either use the package as installed in the dev environment (editable, `_assets/` filled by the hook) or build a wheel (`test_wheel_contains_bundled_plugins`, marked slow). +- The repository has `main` and `latest` (both on the same commit today). RELEASING.md says the plugin release workflow moves `stable` and `latest`, but no plugin has been released yet, so `stable` doesn't exist. +- The installer's file-mode install makes no network call (`tests/installer/test_no_network.py`), and its README promises it sends nothing unless asked. +- `robotframework-plugins` 2026.11.0 has no tag and isn't on PyPI. The release workflow publishes on a `robotframework-plugins-v` tag whose version must match `pyproject.toml` and the current month. + +## Goals / Non-Goals + +**Goals:** +- A public surface small enough to keep stable: one module, three names for the bundle and three for snapshots. +- Paths that consumers can hand to a directory-based skill provider and keep for the whole process. +- Tests that exercise the API as a consumer sees it: from an installed wheel, in a separate environment. + +**Non-Goals:** +- Changing `_assets` or the adapters. `bundle` reads through `_assets`, and the adapters keep their own use of it. +- Making deprecated plugins' skills available through `skills_dirs()`: a consumer that wants them can read `catalog()`. +- A reader for a project's install record (issue #5, "Later"). +- Type stubs or a typed `TypedDict` for entries: entries stay plain dicts, so new fields stay non-breaking. +- Fetching automatically, or as a fallback for the bundle: the bundle is always there, and a silent network call would break the no-network promise. +- Other repositories or forks as a `fetch()` source, authentication, and cache eviction. A consumer can delete the cache folder; the README says where it is. + +## Decisions + +### D1. `bundle` wraps `_assets`; entries are copies + +`catalog()` returns `copy.deepcopy(_assets.bundled_catalog())`, so a caller can't change what the next call or the installer sees. `CATALOG_FORMAT = 1` is a module constant. The module has no import-time side effects (no extraction, no file reads), so `import robotframework_plugins.bundle` stays cheap for a server that only needs it on first use. + +Alternative: move the reading code into `bundle` and make `_assets` call it. That is more churn for the adapters, and nothing in the API needs it. + +### D2. `skills_dirs()` filters the catalogue itself + +Keys are the catalogue entries with `status == "active"`, not a bundle (`is_bundle`), and with a `skills/` folder holding at least one `*/SKILL.md`, ordered with `rf-core` first, then catalogue order. This reuses the rule of `content_plugins()` but not the function itself: `content_plugins()` falls back to `("rf-core",)` when the catalogue is missing, which would invent an entry for a broken build. With no catalogue, `skills_dirs()` returns `{}` and `catalog()` returns `[]`. + +### D3. Real paths for the life of the process + +If the `_assets` Traversable is already a `pathlib.Path` (every normal or editable install), `skills_dirs()` returns `path / "plugins" / name / "skills"`, resolved. Otherwise (zip import, frozen app), the first call copies the whole `_assets` tree once into a `tempfile.mkdtemp()` directory by walking the Traversable (`iterdir`, `read_bytes`), and removes it with an `atexit` handler. Later calls reuse it. Walking the Traversable ourselves works on Python 3.10 and 3.11, where `as_file` can't copy directories. + +Alternatives: +- `as_file` inside a module-level `ExitStack`: only works on 3.12+ for directories. +- Documenting "unpacked installs only": the issue asks for paths that stay valid, and frozen builds are a supported case in `_assets`' docstring. + +### D4. One catalogue builder, used by the build hook and by `fetch()` + +The entry-building code moves from `hatch_build.py` into a new private module `robotframework_plugins/_catalog.py` (`CATALOG_FORMAT`, `catalog_entries(plugins_dir)`, `has_content()`), using only the standard library and PyYAML (already a dependency). It adds `"robotframework": m.get("robotframework", "")` to each entry and raises a clear error for a missing manifest or required field. `hatch_build.py` loads that file by path (`importlib.util.spec_from_file_location`), because the package isn't importable at build time, and writes `{"format": CATALOG_FORMAT, "plugins": [...]}`. `bundle.CATALOG_FORMAT` re-exports the same constant, so the number lives in one place. + +Existing readers use `.get("plugins")`, so the new key doesn't affect them, and the test fixtures that write a catalogue without `format` keep working. + +Alternative: keep the builder in the hook and duplicate it in the snapshot code. Two copies of the rules would drift, and the spec requires that both build entries the same way. + +### D5. Ship `py.typed` + +Add the PEP 561 marker so a consumer's type checker sees the API's annotations (`list[dict[str, Any]]`, `dict[str, Path]`). It covers the whole package; `_assets` stays private by its name and the README, not by typing. + +### D6. Tests: API, wheel in a fresh venv, zip import + +- **Unit tests** against the dev install: names, entry fields, `rf-core` first, bundle and deprecated plugins left out (with the existing `two_plugin_bundle`-style fixtures for a deprecated entry), copies on each call, `format` in the file equals the constant. +- **Wheel test (slow):** `uv build --wheel`, `uv venv`, `uv pip install `, then a short script in that venv that calls the API and checks the spec scenarios ("Real directories of the installed package", "Names match the catalogue"). This is the issue's acceptance test, and it catches a missing `py.typed`, `__all__` or `_assets` file in the wheel. +- **Zip test:** zip the installed package directory, import it from a fresh interpreter with only the zip on `sys.path`, and check that `skills_dirs()` paths exist and hold `SKILL.md`. That runs on whichever Python the CI matrix uses, including 3.10. + +### D7. `fetch()`: resolve, download, check, cache + +`_snapshot.py` does the work and `bundle.fetch()` wraps it. Standard library only (`urllib.request`, `tarfile`, `tempfile`, `os.replace`). + +1. **Resolve.** A 40-hex `ref` is taken as the commit and looked up in the cache first, with no network call. Otherwise `GET https://api.github.com/repos/MarketSquare/robotframework-plugins/commits/` with `Accept: application/vnd.github.sha` returns the SHA as text. A 404 (for example `stable` before the first plugin release) becomes `FetchError: ref 'stable' not found`. +2. **Cache hit.** If `//` holds a finished snapshot (a `snapshot.json` written last), return it. +3. **Download.** `GET https://codeload.github.com/MarketSquare/robotframework-plugins/tar.gz/` into a temporary file, with the `timeout` and a size cap (64 MB). `urllib` honours the usual proxy environment variables. +4. **Extract safely.** Only members under `/plugins/` are extracted, by reading each member and writing it ourselves, not with `extractall`. A member that isn't a regular file or directory (symlink, hard link, device), or whose normalised path is absolute or has `..`, raises `FetchError` naming it. This works the same on Python 3.10 to 3.13, where `tarfile`'s `filter="data"` isn't available on every 3.10 or 3.11 patch release. Plugin `tests/` and `evals/` are skipped, as in the wheel. +5. **Check and build the catalogue** with `_catalog.catalog_entries()` on the extracted `plugins/`, and keep only `active` and `deprecated` plugins, as the wheel does. +6. **Commit to the cache.** Write `catalog.json` (with `format`) and `snapshot.json` (`ref`, `commit`, fetch time) into the temporary folder, then `os.replace` it to `//`. If another process won the race, use its folder and drop ours. + +A `Snapshot` holds the folder, `ref` and `commit`; its `catalog()` and `skills_dirs()` use the same filters as the bundled functions (D2), applied to the snapshot's catalogue. The default cache is `$XDG_CACHE_HOME` or `~/.cache` on Linux and macOS, and `%LOCALAPPDATA%` on Windows, each under `robotframework-plugins/snapshots/`. + +Requests are unauthenticated. The API allows 60 per hour per IP, and only branch and tag refs use it, once per `fetch()` call; a consumer that calls `fetch()` once at start-up stays far below that. + +Alternatives: +- `git clone --depth 1`: needs git on the machine and runs a program; the archive needs neither. +- Release archives from GitHub releases: only exist for tagged plugin releases, not for `main`. +- The raw files API, file by file: hundreds of requests per snapshot. + +### D8. Tests for `fetch()` never use the real network + +The test suite serves a fake GitHub on `127.0.0.1` (`http.server` in a thread) with one endpoint per step, and points `_snapshot`'s two base URLs at it. Archives are built in the test from a small plugin tree, so the tests cover a good snapshot, a cache hit, a SHA served offline (with sockets blocked as in `test_no_network.py`), a 404 ref, a timeout, a `../` member, a symlink member, and a `catalog.yaml` without `status`. One extra test, marked `network` and skipped by default, fetches `main` from GitHub for a manual check before a release. + +### D9. Release as 2026.11.0 + +The API goes into the unreleased 2026.11.0 changelog entry under "Added". Publishing is the maintainer's step: push the `robotframework-plugins-v2026.11.0` tag in November 2026 and approve the `pypi` environment. If the tag is pushed in December or later, the release workflow's month check requires `2026.12.0`, and the changelog heading moves with it. + +## Risks / Trade-offs + +- [The promise freezes field names the installer also uses internally] → Only the fields listed in the spec are promised; the installer can add fields freely, and a breaking rename bumps `CATALOG_FORMAT`. +- [Extracted copies in zip mode cost disk and a startup delay] → Only in zip/frozen mode, once per process, and the bundle is a few MB. +- [A consumer pins `<2027` but the calendar version moves every month] → `CATALOG_FORMAT` is the compatibility signal, not the version; the README says so. +- [`main` content is merged but not released: versions can be unreleased, the full evals run later, a revert can land, and skills ship scripts that agents run] → `fetch()` is opt-in and defaults to `stable`; the README states what choosing `main` means; snapshots are pinned to a SHA, so a consumer can report and reproduce what it served. +- [`stable` doesn't exist until the first plugin release] → `fetch()` raises a clear `FetchError` for it, and the README says to use `main` until then. +- [GitHub rate limits or outages] → Commit-SHA refs are served from the cache without the network; any failure is a `FetchError` and the bundle stays usable. +- [Publishing depends on the maintainer and the trusted-publishing setup on pypi.org] → The tasks end with the maintainer's release steps, and the PR description lists them. + +## Migration Plan + +No migration: the API is new and the catalogue changes are additions. Rollback is a revert before the release. After the release, removing the API would itself be a breaking change under the promise. diff --git a/openspec/changes/public-bundle-api/proposal.md b/openspec/changes/public-bundle-api/proposal.md new file mode 100644 index 0000000..acb94ff --- /dev/null +++ b/openspec/changes/public-bundle-api/proposal.md @@ -0,0 +1,51 @@ +## Why + +rf-mcp (the Robot Framework MCP server) wants to stop shipping its own library skills and serve the official ones from this repository, by depending on the `robotframework-plugins` package ([issue #5](https://github.com/MarketSquare/robotframework-plugins/issues/5)). Everything it needs is already in the wheel, but only through the private `robotframework_plugins._assets` module, and `_assets/catalog.json` has no format version. A tool can't pin a version range against private names. And the package isn't on PyPI yet, so rf-mcp can't depend on it at all. + +## What Changes + +- **New public module `robotframework_plugins.bundle`**, listed in its `__all__`: + - `CATALOG_FORMAT` (int, `1`), bumped only on a breaking change to the catalogue entries; + - `catalog()`: the bundled plugins (active and deprecated), with name, version, status, tier, summary, libraries (`name`, `package`, `versions`), the `robotframework` range, `dependencies`, `bundle` and `bundled`; + - `skills_dirs()`: `{plugin name: absolute Path of its skills/ folder}` for every active plugin with content, `rf-core` first, bundles left out. The paths stay valid for the life of the process. +- **An opt-in snapshot of the repository: `bundle.fetch(ref="stable")`.** It returns the same `catalog()` / `skills_dirs()` interface for a ref of this repository, so a tool can serve skills newer than its installed package: + - `stable` (the released content) by default; `main`, another branch, a tag or a commit SHA when named explicitly; + - the ref is resolved to a commit SHA, and the snapshot is downloaded once and cached by SHA, so repeat calls are offline and the tool can report which commit it serves; + - the catalogue is built from the snapshot's `catalog.yaml` files with the same code as the wheel's, and a snapshot that fails that check, or whose archive has unsafe paths, is refused; + - it only touches the network when called. Importing the module and the bundled API never do. On any failure it raises `bundle.FetchError`, and the caller keeps the bundled copy; + - standard library only: no new dependencies. +- **`_assets/catalog.json` gets a top-level `"format": 1`** next to `"plugins"`, so tools that read the file directly can check it. +- **Catalogue entries gain `robotframework`** (the supported Robot Framework range from each `catalog.yaml`). The issue lists it as needed, and today the build hook drops it. +- **The installer README gets a "Python API" section** with the compatibility promise: + - removing or renaming a public name, or removing a documented field, bumps `CATALOG_FORMAT` and is called out in the installer changelog; + - new fields and new plugins aren't breaking changes; + - the skill folder layout (`//SKILL.md` plus `references/`) is part of the contract. +- **`_assets` stays the installer's internal module**; `bundle` wraps it, and the adapters keep using `_assets`. +- **The first PyPI release, `2026.11.0`, includes the API.** 2026.11.0 has no tag and isn't on PyPI yet, so the API goes into its changelog entry instead of a new version. The maintainer publishes it by pushing the `robotframework-plugins-v2026.11.0` tag; the release workflow uploads through trusted publishing after the maintainer approves the `pypi` environment. + +The bundled copy stays the default and the fallback. Content from a branch is reviewed and has passed the PR checks, but it isn't released: plugin versions there can be unreleased, the full evals run later, and a revert can still land. Skills also ship scripts that agents run. So fetching is never automatic, and the README says what a consumer takes on by choosing `main`. + +Out of scope: a reader for a project's install record (`bundle.installed_skills(project)`), which the issue leaves for a follow-up. + +## Capabilities + +### New Capabilities + +- `installer-python-api`: the public `robotframework_plugins.bundle` module (catalogue, skill folders, format version, opt-in repository snapshots) and its compatibility promise. + +### Modified Capabilities + +None. The catalogue's new `format` key and `robotframework` field are additions that existing readers (`bundled_catalog()`, `catalog --json`) ignore or pass through. + +## Impact + +- **Code:** + - `installer/src/robotframework_plugins/bundle.py` (new); + - `installer/hatch_build.py` (writes `format` and `robotframework`, using catalogue code shared with `fetch()`); + - `installer/src/robotframework_plugins/_catalog.py` (new: catalogue entries from plugin folders) and `_snapshot.py` (new: ref resolution, download, safe extraction, cache); + - `installer/src/robotframework_plugins/_assets.py` (a helper that keeps a real path for the process lifetime, if needed). +- **Tests:** `tests/installer/` gets API tests, a wheel test (build the wheel, install it into a fresh venv, call the API) and snapshot tests against a local fake server (no real network in the test suite). +- **Docs:** `installer/README.md` ("Python API"), `installer/CHANGELOG.md` (2026.11.0, Added). +- **Release:** pushing the `robotframework-plugins-v2026.11.0` tag in November 2026 is the maintainer's step; this change only prepares it. +- **Network:** `fetch()` calls `api.github.com` (to resolve the ref) and `codeload.github.com` (the archive), unauthenticated, only when a consumer calls it. The installer CLI never calls it. +- **Consumers:** rf-mcp can declare `robotframework-plugins>=2026.11,<2027` and check `CATALOG_FORMAT` at runtime. diff --git a/openspec/changes/public-bundle-api/specs/installer-python-api/spec.md b/openspec/changes/public-bundle-api/specs/installer-python-api/spec.md new file mode 100644 index 0000000..1771835 --- /dev/null +++ b/openspec/changes/public-bundle-api/specs/installer-python-api/spec.md @@ -0,0 +1,105 @@ +## Purpose + +The `robotframework-plugins` package gives other tools a stable, public Python API to the plugin content it bundles: the catalogue and each plugin's skill folder, with a format version they can pin against. + +## ADDED Requirements + +### Requirement: Public bundle module + +The package SHALL provide a public module `robotframework_plugins.bundle` whose `__all__` lists `CATALOG_FORMAT`, `catalog`, `skills_dirs`, `fetch`, `Snapshot` and `FetchError`. Importing it SHALL NOT require network access or any dependency beyond the package's own, and SHALL NOT write to the user's home or project folders. + +#### Scenario: Public names +- **WHEN** a tool runs `from robotframework_plugins import bundle` +- **THEN** `bundle.__all__` contains `CATALOG_FORMAT`, `catalog`, `skills_dirs`, `fetch`, `Snapshot` and `FetchError`, and `bundle.CATALOG_FORMAT == 1` + +#### Scenario: Import makes no network call +- **WHEN** a tool imports `robotframework_plugins.bundle` and calls `catalog()` and `skills_dirs()` with every network connection refused +- **THEN** both return the bundled content and no connection is attempted + +### Requirement: Catalogue of bundled plugins + +`catalog()` SHALL return one entry per bundled plugin (status `active` or `deprecated`), in the order of the bundled catalogue. Each entry SHALL have at least `name`, `version`, `status`, `tier`, `summary`, `libraries` (a list of `{name, package, versions}`), `robotframework` (the supported Robot Framework version range), `dependencies`, `bundle` and `bundled`. It SHALL return a new list on each call, so a caller changing it doesn't change later results. + +#### Scenario: Library matching data +- **WHEN** a tool reads the `rf-web` entry of `catalog()` +- **THEN** its `libraries` contains `{"name": "Browser", "package": "robotframework-browser", "versions": ...}`, and its `robotframework` is a version range such as `>=7.0` + +#### Scenario: Bundles are marked +- **WHEN** a tool reads the `rf-agentskills` entry +- **THEN** its `bundle` is true and `bundled` names the plugins it bundles + +#### Scenario: Caller changes don't leak +- **WHEN** a tool removes an entry from the list `catalog()` returned and calls `catalog()` again +- **THEN** the second list still has every bundled plugin + +### Requirement: Skill folders of bundled plugins + +`skills_dirs()` SHALL return a mapping from plugin name to the absolute path of that plugin's `skills/` folder, for every `active` plugin that is not a bundle and has a `skills/` folder, with `rf-core` first and the others in catalogue order. Each path SHALL be an existing directory for the life of the process, also when the package is imported from a zip archive. Each folder SHALL hold one sub-folder per skill with a `SKILL.md`, following the Agent Skills format. + +#### Scenario: Real directories of the installed package +- **WHEN** a tool calls `skills_dirs()` in an environment where the built wheel is installed +- **THEN** every value is an absolute, existing directory, and each holds at least one `*/SKILL.md` + +#### Scenario: Names match the catalogue +- **WHEN** a tool compares `skills_dirs()` with `catalog()` +- **THEN** its keys are exactly the names of the active, non-bundle catalogue entries that have skills, with `rf-core` first + +#### Scenario: Bundles and deprecated plugins left out +- **WHEN** the catalogue holds the `rf-agentskills` bundle or a deprecated plugin +- **THEN** neither is a key of `skills_dirs()` + +### Requirement: Catalogue format version + +The bundled catalogue file SHALL carry a top-level `"format"` key with the same value as `CATALOG_FORMAT`, next to `"plugins"`. `CATALOG_FORMAT` SHALL change only on a breaking change to the catalogue: removing or renaming a field listed in "Catalogue of bundled plugins", or changing its meaning. Adding fields or plugins SHALL NOT change it. + +#### Scenario: File and constant agree +- **WHEN** a tool reads `_assets/catalog.json` from the installed package +- **THEN** its `format` equals `bundle.CATALOG_FORMAT` + +### Requirement: Documented compatibility promise + +The installer README SHALL have a "Python API" section that names the public module and its public names, gives a short usage example, and states the compatibility promise: what bumps `CATALOG_FORMAT`, what doesn't, that the skill folder layout is part of the contract, and that breaking changes are named in the installer changelog. The private `_assets` module SHALL be documented there as not part of the API. The section SHALL also say that `fetch()` uses the network, that the bundled copy is the default, and that content from a branch other than `stable` is merged but not released. + +#### Scenario: Promise in the README +- **WHEN** a tool author reads the installer README +- **THEN** it explains `catalog()`, `skills_dirs()` and `CATALOG_FORMAT`, and when `CATALOG_FORMAT` changes + +### Requirement: Opt-in snapshot of a repository ref + +`fetch(ref="stable", *, cache_dir=None, timeout=30)` SHALL return a `Snapshot` of the plugins at a ref of the `MarketSquare/robotframework-plugins` repository. `ref` SHALL accept a branch, a tag or a full commit SHA, with `stable` as the default. A `Snapshot` SHALL have `ref`, `commit` (the full commit SHA it was built from), `catalog()` and `skills_dirs()`, which follow the requirements "Catalogue of bundled plugins" and "Skill folders of bundled plugins" for the plugins at that commit (status taken from each plugin's `catalog.yaml`). The network SHALL be used only inside `fetch()`; the installer's own commands SHALL NOT call it. + +#### Scenario: Snapshot of main +- **WHEN** a tool calls `fetch("main")` +- **THEN** it gets a `Snapshot` whose `commit` is the SHA `main` pointed to, and whose `skills_dirs()` lists the active content plugins of that commit, with existing `*/SKILL.md` files + +#### Scenario: Same interface as the bundle +- **WHEN** a tool compares a snapshot's `catalog()` entries with `bundle.catalog()`'s +- **THEN** they have the same fields, built by the same rules + +### Requirement: Snapshots are pinned and cached + +`fetch()` SHALL resolve `ref` to a commit SHA before downloading, and store each snapshot under a cache folder keyed by that SHA (default: the user's cache directory, `robotframework-plugins/snapshots/`; `cache_dir` overrides it). A snapshot already in the cache SHALL be reused without downloading again; a full commit SHA as `ref` SHALL be served from the cache without any network call. A snapshot SHALL be written to the cache only when complete, so an interrupted fetch leaves no partial snapshot behind. + +#### Scenario: Second fetch reuses the cache +- **WHEN** a tool calls `fetch("main")` twice while `main` hasn't moved +- **THEN** the archive is downloaded once, and both snapshots have the same `commit` and the same paths + +#### Scenario: Commit SHA offline +- **WHEN** a snapshot for a commit is cached and a tool calls `fetch()` with every network connection refused +- **THEN** it returns that snapshot + +### Requirement: Snapshots are checked and failures are explicit + +`fetch()` SHALL raise `FetchError`, with a message naming the cause, and leave the cache unchanged when the ref can't be resolved, the download fails or times out, the archive holds an entry that is not a regular file or directory or whose path would leave the snapshot folder, or a plugin's `catalog.yaml` or `plugin.json` is missing or lacks a field the catalogue requires. A `FetchError` SHALL NOT affect the bundled `catalog()` and `skills_dirs()`. `fetch()` SHALL NOT run any code from the snapshot. + +#### Scenario: Network down +- **WHEN** a tool calls `fetch("main")` with every network connection refused and nothing cached for it +- **THEN** it raises `FetchError`, and `bundle.skills_dirs()` still returns the bundled folders + +#### Scenario: Unsafe archive refused +- **WHEN** the downloaded archive holds an entry `../outside.txt` or a symbolic link +- **THEN** `fetch()` raises `FetchError` naming the entry, and nothing is written outside a temporary folder or into the cache + +#### Scenario: Broken plugin manifest refused +- **WHEN** a plugin in the snapshot has a `catalog.yaml` without `status` +- **THEN** `fetch()` raises `FetchError` naming the plugin and the field diff --git a/openspec/changes/public-bundle-api/tasks.md b/openspec/changes/public-bundle-api/tasks.md new file mode 100644 index 0000000..75c3437 --- /dev/null +++ b/openspec/changes/public-bundle-api/tasks.md @@ -0,0 +1,38 @@ +## 1. Branch + +- [ ] 1.1 Branch `change/public-bundle-api` from `main` and commit this change's artifacts there; verify `git log origin/main..HEAD` shows only this change. + +## 2. Shared catalogue builder (D4) + +- [ ] 2.1 Move the entry-building code from `installer/hatch_build.py` into `installer/src/robotframework_plugins/_catalog.py` (`CATALOG_FORMAT`, `catalog_entries()`, `has_content()`), add `robotframework` to each entry and a clear error for a missing manifest or required field; have the hook load it by path and write `{"format": CATALOG_FORMAT, "plugins": [...]}`. Verify a rebuilt `_assets/catalog.json` (`uv build --wheel`, or reinstalling `installer` editable) has `format: 1` and `rf-web`'s `robotframework` is `>=7.0`, and that `uv build --sdist` followed by a wheel build from the sdist still works. +- [ ] 2.2 Check that existing readers still work; verify `uv run pytest tests/installer` passes, including fixtures that write a catalogue without `format`. + +## 3. Bundled API (D1, D2, D3, D5) + +- [ ] 3.1 Add `installer/src/robotframework_plugins/bundle.py` with `CATALOG_FORMAT` (from `_catalog`), `catalog()`, `skills_dirs()` and `__all__`, wrapping `_assets`; add `py.typed`. Verify unit tests in `tests/installer/test_bundle_api.py` for "Public names", "Import makes no network call", "Library matching data", "Bundles are marked", "Caller changes don't leak", "Names match the catalogue", "Bundles and deprecated plugins left out" (a fixture bundle with a deprecated plugin) and "File and constant agree", plus an empty result for a bundle without a catalogue. +- [ ] 3.2 Implement the zip/frozen path of `skills_dirs()` (one extraction per process, removed at exit); verify a test that zips the installed package, imports it in a fresh interpreter with only the zip on `sys.path`, and finds existing `SKILL.md` files under every returned path. + +## 4. Repository snapshots (D7, D8) + +- [ ] 4.1 Add `installer/src/robotframework_plugins/_snapshot.py` (resolve, cache lookup, download with timeout and size cap, safe extraction of `plugins/`, catalogue check with `_catalog`, atomic move into the cache) and `bundle.fetch()`, `bundle.Snapshot` and `bundle.FetchError`. Verify tests in `tests/installer/test_bundle_fetch.py` against a fake GitHub on `127.0.0.1` for "Snapshot of main", "Same interface as the bundle", "Second fetch reuses the cache", "Commit SHA offline" (sockets blocked), "Network down", "Unsafe archive refused" (a `../` member and a symlink member), "Broken plugin manifest refused", a 404 ref (`stable` not found) and a timeout. +- [ ] 4.2 Check that the installer never fetches; verify `tests/installer/test_no_network.py` still passes and a test asserts that no CLI module imports `_snapshot`. +- [ ] 4.3 Add a test marked `network`, skipped by default, that fetches `main` from GitHub and checks the scenario "Snapshot of main"; verify it passes when run with `-m network` locally. + +## 5. Wheel test (D6) + +- [ ] 5.1 Add a slow test that builds the wheel, installs it into a fresh `uv venv`, and runs a script there checking "Real directories of the installed package" and "Names match the catalogue", that `bundle.fetch` is importable, that `py.typed` is in the wheel, and that `catalog.json` has `format: 1`; verify it passes locally and in the CI job that runs the slow installer tests (or add it to one). + +## 6. Docs and changelog + +- [ ] 6.1 Add a "Python API" section to `installer/README.md`: the public names, a short example (library matching, skill folders, `fetch("main")` with the bundle as fallback), the compatibility promise from the spec, that `_assets` is private, that `fetch()` is the only API that uses the network, where the cache lives, and that content from a branch other than `stable` is merged but not released (and `stable` only exists after the first plugin release). Verify `scripts/check-docs-voice.py` passes. +- [ ] 6.2 Add the API, `fetch()`, the `format` key and the `robotframework` field to the 2026.11.0 entry of `installer/CHANGELOG.md` under "Added"; verify `scripts/check-docs-voice.py` passes. + +## 7. Checks and PR + +- [ ] 7.1 Run `uv run pytest tests/installer` (with the slow tests), `ruff check installer`, `scripts/check-drift.sh` and `scripts/build-catalog.py --check`; verify all pass. +- [ ] 7.2 Open a PR against `main` that closes #5 (ask before posting); verify the required checks pass. + +## 8. Release (maintainer) + +- [ ] 8.1 After the merge, the maintainer pushes the `robotframework-plugins-v2026.11.0` tag in November 2026 (or bumps to `2026.12.0` first, if later) and approves the `pypi` environment; verify `pip install robotframework-plugins==2026.11.0` in a fresh venv, then `python -c "from robotframework_plugins import bundle; print(bundle.CATALOG_FORMAT, list(bundle.skills_dirs()), bundle.fetch('main').commit)"`. +- [ ] 8.2 Comment on #5 with the released version and the API names (ask before posting); verify the comment is on the issue. From 073f5ddb574fb43c0d73bd6b186c7de0998b3dcd Mon Sep 17 00:00:00 2001 From: Many Kasiriha Date: Thu, 12 Nov 2026 19:21:23 +0100 Subject: [PATCH 2/3] feat(installer): public Python API robotframework_plugins.bundle (#5) - bundle.catalog(), bundle.skills_dirs(), bundle.CATALOG_FORMAT over the bundled plugins; real folders also from a zip import - bundle.fetch(ref="stable"): opt-in snapshot of a repository ref, pinned to a commit, cached, checked (safe extraction, valid manifests), FetchError on any failure; the only network use, never from the installer's commands - one catalogue builder (_catalog.py) for the wheel's build hook and fetch(); catalog.json gains "format": 1 and each entry its robotframework range - py.typed; README "Python API" with the compatibility promise; changelog - CI test job gets uv so the wheel tests run Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FHEmzL3ErAhGQykKWDx2wP --- .github/workflows/ci.yml | 3 + installer/CHANGELOG.md | 2 + installer/README.md | 45 +++- installer/hatch_build.py | 74 ++---- .../src/robotframework_plugins/_catalog.py | 123 +++++++++ .../src/robotframework_plugins/_snapshot.py | 230 +++++++++++++++++ .../src/robotframework_plugins/bundle.py | 85 ++++++ installer/src/robotframework_plugins/py.typed | 0 openspec/changes/public-bundle-api/tasks.md | 24 +- pyproject.toml | 1 + tests/installer/conftest.py | 10 + tests/installer/test_bundle_api.py | 159 ++++++++++++ tests/installer/test_bundle_fetch.py | 243 ++++++++++++++++++ 13 files changed, 935 insertions(+), 64 deletions(-) create mode 100644 installer/src/robotframework_plugins/_catalog.py create mode 100644 installer/src/robotframework_plugins/_snapshot.py create mode 100644 installer/src/robotframework_plugins/bundle.py create mode 100644 installer/src/robotframework_plugins/py.typed create mode 100644 tests/installer/test_bundle_api.py create mode 100644 tests/installer/test_bundle_fetch.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9fe0bcc..f309239 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,6 +47,9 @@ jobs: - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + # uv builds the installer wheel for the slow wheel tests in + # tests/installer/ (they skip without it). + - uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6 - name: Install dependencies # tests/installer/ imports robotframework_plugins; install it from the # in-repo package so the test collector can resolve it. The diff --git a/installer/CHANGELOG.md b/installer/CHANGELOG.md index a829a40..661ede4 100644 --- a/installer/CHANGELOG.md +++ b/installer/CHANGELOG.md @@ -9,6 +9,8 @@ The installer has calendar versions (`YYYY.M.PATCH`), independent of the plugins - **The marketplace is `robotframework-plugins`.** `--mode plugin` writes the new marketplace entry and source, and re-installing replaces an old `robotframework-agentskills` entry, keeping its `--ref` pin. ### Added +- **A public Python API, `robotframework_plugins.bundle`** ([#5](https://github.com/MarketSquare/robotframework-plugins/issues/5)), so other tools such as rf-mcp can depend on this package and serve the bundled skills: `catalog()` (the bundled plugins with their libraries and supported Robot Framework range), `skills_dirs()` (each active plugin's `skills/` folder, also when the package is imported from a zip) and `CATALOG_FORMAT` (`1`). `fetch(ref="stable")` returns the same for a branch, tag or commit of the repository, downloaded once into the user cache and checked before use; it is the only part of the package that uses the network, and only when called. See "Python API" in the README for the compatibility promise. +- **`catalog.json` carries `"format": 1`**, and each entry its `robotframework` range. - **Several plugins.** The installer bundles every listed plugin (`rf-core`, `rf-web`, `rf-api`, `rf-mobile`, `rf-desktop` and the `rf-agentskills` bundle). By default it installs `rf-core` plus the plugins for the libraries the project declares in `pyproject.toml` or `requirements*.txt`, or every verified plugin when it declares none. `--plugins |all` chooses them; an unknown or removed plugin is an error, a deprecated one a warning. - **`robotframework-plugins catalog [--json]`** lists the bundled plugins with version, tier and status. `version` prints every bundled plugin's version. - **Upgrades keep what is installed.** On an upgrade, `install` covers the plugins already installed and adds the ones the project now declares. The summary names the skills and plugins an install removed, including a plugin the catalogue no longer has; files you changed are kept and reported. diff --git a/installer/README.md b/installer/README.md index 36dc10a..8ef3458 100644 --- a/installer/README.md +++ b/installer/README.md @@ -90,9 +90,52 @@ robotframework-plugins doctor # what works and what needs your att robotframework-plugins version # this version and the bundled plugin versions ``` +## Python API + +Other tools can depend on `robotframework-plugins` and read the plugins it bundles, for example to serve the official skills from an MCP server. The public module is `robotframework_plugins.bundle`: + +| Name | What it gives you | +|---|---| +| `CATALOG_FORMAT` | The catalogue format, `1`. Check it at runtime. | +| `catalog()` | The bundled plugins (active and deprecated): `name`, `version`, `status`, `tier`, `summary`, `libraries` (`name`, `package`, `versions`), `robotframework`, `dependencies`, `bundle`, `bundled`. A new list on each call. | +| `skills_dirs()` | `{plugin: Path}` of each active plugin's `skills/` folder, `rf-core` first; bundles such as `rf-agentskills` are left out. The folders exist for the life of the process. | +| `fetch(ref="stable")` | The same for a branch, tag or commit of this repository: a `Snapshot` with `ref`, `commit`, `catalog()` and `skills_dirs()`. Raises `FetchError` on any failure. | + +```python +from packaging.specifiers import SpecifierSet +from robotframework_plugins import bundle + +assert bundle.CATALOG_FORMAT == 1 + +# Which plugins match the libraries a project has installed? +installed = {"robotframework-browser": "20.1.0"} +for plugin in bundle.catalog(): + for lib in plugin["libraries"]: + if lib["package"] in installed: + print(plugin["name"], lib["name"], installed[lib["package"]] in SpecifierSet(lib["versions"])) + +# Skill folders, each holding /SKILL.md and references/ +skills = bundle.skills_dirs() + +# Optional: newer content from GitHub, with the bundled copy as the fallback +try: + skills = bundle.fetch("main").skills_dirs() +except bundle.FetchError: + pass +``` + +**Compatibility.** The names above are public. Removing or renaming one, or removing or changing the meaning of a listed catalogue field, bumps `CATALOG_FORMAT` and is named in the [changelog](CHANGELOG.md). New fields and new plugins don't. The skill folder layout (`//SKILL.md`, with `references/` and other files next to it) follows the [Agent Skills format](https://agentskills.io) and is part of the contract. The calendar version moves every month, so pin a range such as `robotframework-plugins>=2026.11,<2027` and rely on `CATALOG_FORMAT`. Everything else in the package, including `robotframework_plugins._assets`, is private and can change in any release. + +**`fetch()` and the network.** `fetch()` is the only part of the API that uses the network, and only when you call it: importing the module, `catalog()` and `skills_dirs()` read the installed package. It resolves the ref to a commit on GitHub, downloads that commit's archive once, checks it (only plain files and folders, no paths outside the snapshot, a valid `catalog.yaml` and `plugin.json` for every plugin), and keeps it in `~/.cache/robotframework-plugins/snapshots//` (`%LOCALAPPDATA%` on Windows; `cache_dir=` changes it). A commit SHA that is already cached needs no network. Nothing in a snapshot is run. + +Choose the ref deliberately: + +- `stable` holds released plugins. It only exists after the first plugin release; until then, `fetch()` raises `FetchError: ref 'stable' not found`. +- `main` holds everything merged: reviewed and checked by CI, but not released. Plugin versions there can be ahead of any release, the full evaluations run later, and a change can still be reverted. Skills ship scripts that agents run, so serve `main` only where that is acceptable, and report `Snapshot.commit` so you know what you served. + ## No telemetry -The installer sends nothing anywhere. It only touches the network for the marketplace and package operations you ask for. +The installer sends nothing anywhere. It only touches the network for the marketplace and package operations you ask for. The `bundle.fetch()` function of the Python API downloads from GitHub only when a program calls it; the installer's commands never do. ## More diff --git a/installer/hatch_build.py b/installer/hatch_build.py index 973abe4..1f7c71b 100644 --- a/installer/hatch_build.py +++ b/installer/hatch_build.py @@ -20,15 +20,14 @@ from __future__ import annotations -import json +import importlib.util import shutil +import sys from pathlib import Path from typing import Any -import yaml from hatchling.builders.hooks.plugin.interface import BuildHookInterface - # The path layout assumed: # /plugins// ← canonical sources # /installer/ ← this package; hatch_build.py lives here @@ -39,14 +38,28 @@ # level up. PLUGINS_REL = Path("..") / "plugins" ASSETS_REL = Path("src") / "robotframework_plugins" / "_assets" -#: Statuses the installer bundles; ``removed`` plugins stay out. -BUNDLED_STATUSES = ("active", "deprecated") _IGNORE_ANYWHERE = shutil.ignore_patterns( "__pycache__", "*.pyc", ".pytest_cache", ".mypy_cache", ".ruff_cache", "*.egg-info", ) -#: Plugin tests and evals are for this repository's CI, not for users. Only a -#: plugin's own top-level folders: skills ship example ``tests/`` folders too. -_PLUGIN_ONLY = {"tests", "evals"} +CATALOG_SRC_REL = Path("src") / "robotframework_plugins" / "_catalog.py" + + +def _load_catalog_module(): + """The package's ``_catalog`` module, loaded by path: the package isn't + importable while its own wheel is being built. ``bundle.fetch()`` builds + snapshot catalogues with the same code.""" + path = Path(__file__).resolve().parent / CATALOG_SRC_REL + spec = importlib.util.spec_from_file_location("_robotframework_plugins_catalog", path) + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +_catalog = _load_catalog_module() +#: Re-exported for scripts and tests that read them from the hook. +BUNDLED_STATUSES = _catalog.BUNDLED_STATUSES +catalog_entries = _catalog.catalog_entries def plugin_ignore(plugin_root: Path): @@ -56,44 +69,12 @@ def plugin_ignore(plugin_root: Path): def ignore(directory: str, names: list[str]) -> set[str]: ignored = set(_IGNORE_ANYWHERE(directory, names)) if Path(directory).resolve() == root: - ignored |= _PLUGIN_ONLY & set(names) + ignored |= _catalog.PLUGIN_ONLY_DIRS & set(names) return ignored return ignore -def catalog_entries(plugins_dir: Path) -> list[dict[str, Any]]: - """One entry per bundled plugin, from its catalog.yaml and plugin.json.""" - entries = [] - for d in sorted(p for p in plugins_dir.iterdir() if p.is_dir()): - manifest_path = d / "catalog.yaml" - if not manifest_path.is_file(): - raise FileNotFoundError(f"{manifest_path} missing; run scripts/build-catalog.py --check") - m = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) - if m.get("status") not in BUNDLED_STATUSES: - continue - pj = json.loads((d / ".claude-plugin" / "plugin.json").read_text(encoding="utf-8")) - deps = [x if isinstance(x, str) else x["name"] for x in pj.get("dependencies", [])] - entry = { - "name": d.name, - "version": pj.get("version"), - "tier": m["tier"], - "status": m["status"], - "summary": m["summary"], - "libraries": m.get("libraries", []), - "dependencies": deps, - # A bundle ships a copy of other plugins (catalog.yaml ``bundle``) and - # no content of its own; file mode installs those plugins instead. - "bundle": bool(m.get("bundle")) and not _has_content(d), - "bundled": list(m.get("bundle", [])), - } - for key in ("replacement", "deprecated_since"): - if key in m: - entry[key] = str(m[key]) - entries.append(entry) - return entries - - class CustomBuildHook(BuildHookInterface): # type: ignore[misc] PLUGIN_NAME = "custom" @@ -136,21 +117,12 @@ def initialize(self, version: str, build_data: dict[str, Any]) -> None: for entry in entries: src = plugins_src / entry["name"] shutil.copytree(src, assets_dst / "plugins" / entry["name"], ignore=plugin_ignore(src)) - (assets_dst / "catalog.json").write_text( - json.dumps({"plugins": entries}, indent=2, ensure_ascii=False) + "\n", encoding="utf-8" - ) + (assets_dst / "catalog.json").write_text(_catalog.catalog_document(entries), encoding="utf-8") self.app.display_info( f"robotframework-plugins: bundled {', '.join(e['name'] for e in entries)} → {assets_dst} " f"({_count_files(assets_dst)} files)" ) -def _has_content(plugin_dir: Path) -> bool: - """Ships a skill, a subagent or a hook.""" - return (any((s / "SKILL.md").is_file() for s in (plugin_dir / "skills").glob("*")) - or any((plugin_dir / "agents").glob("*.md")) - or (plugin_dir / "hooks" / "hooks.json").is_file()) - - def _count_files(root: Path) -> int: return sum(1 for p in root.rglob("*") if p.is_file()) diff --git a/installer/src/robotframework_plugins/_catalog.py b/installer/src/robotframework_plugins/_catalog.py new file mode 100644 index 0000000..bcab8d0 --- /dev/null +++ b/installer/src/robotframework_plugins/_catalog.py @@ -0,0 +1,123 @@ +"""Build catalogue entries from plugin folders. + +Shared by the wheel's build hook (``hatch_build.py`` loads this file by path, +because the package isn't importable at build time) and by ``bundle.fetch()``, +which builds the catalogue of a downloaded repository snapshot. Both must +produce the same entries, so the rules live here once. + +Only the standard library and PyYAML (a package dependency) are used. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import yaml + +#: Version of the catalogue's shape (``catalog.json`` ``format``). Bumped only +#: on a breaking change to the documented entry fields; see the README's +#: "Python API" section. +CATALOG_FORMAT = 1 +#: Statuses that are bundled; ``removed`` plugins stay out. +BUNDLED_STATUSES = ("active", "deprecated") +#: A plugin's own top-level folders that are for this repository's CI, not for +#: users. Skills ship example ``tests/`` folders too, so only the plugin root. +PLUGIN_ONLY_DIRS = frozenset({"tests", "evals"}) +_REQUIRED_MANIFEST = ("tier", "status", "summary") + + +class CatalogError(ValueError): + """A plugin folder whose manifest can't be turned into a catalogue entry.""" + + +def has_content(plugin_dir: Path) -> bool: + """Ships a skill, a subagent or a hook.""" + return (any((s / "SKILL.md").is_file() for s in (plugin_dir / "skills").glob("*")) + or any((plugin_dir / "agents").glob("*.md")) + or (plugin_dir / "hooks" / "hooks.json").is_file()) + + +def _manifest(plugin_dir: Path) -> dict[str, Any]: + path = plugin_dir / "catalog.yaml" + if not path.is_file(): + raise CatalogError(f"{plugin_dir.name}: catalog.yaml missing; run scripts/build-catalog.py --check") + try: + m = yaml.safe_load(path.read_text(encoding="utf-8")) + except yaml.YAMLError as exc: + raise CatalogError(f"{plugin_dir.name}: catalog.yaml is not valid YAML ({exc})") from exc + if not isinstance(m, dict): + raise CatalogError(f"{plugin_dir.name}: catalog.yaml is not a mapping") + missing = [k for k in _REQUIRED_MANIFEST if not m.get(k)] + if missing: + raise CatalogError(f"{plugin_dir.name}: catalog.yaml lacks {', '.join(missing)}") + return m + + +def _plugin_json(plugin_dir: Path) -> dict[str, Any]: + path = plugin_dir / ".claude-plugin" / "plugin.json" + if not path.is_file(): + raise CatalogError(f"{plugin_dir.name}: .claude-plugin/plugin.json missing") + try: + pj = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise CatalogError(f"{plugin_dir.name}: plugin.json is not valid JSON ({exc})") from exc + if not isinstance(pj, dict) or not pj.get("version"): + raise CatalogError(f"{plugin_dir.name}: plugin.json lacks version") + return pj + + +def catalog_entries(plugins_dir: Path) -> list[dict[str, Any]]: + """One entry per bundled plugin under ``plugins_dir``, from its catalog.yaml and plugin.json.""" + entries = [] + for d in sorted(p for p in plugins_dir.iterdir() if p.is_dir()): + m = _manifest(d) + if m["status"] not in BUNDLED_STATUSES: + continue + pj = _plugin_json(d) + deps = [x if isinstance(x, str) else x["name"] for x in pj.get("dependencies", [])] + entry = { + "name": d.name, + "version": pj.get("version"), + "tier": m["tier"], + "status": m["status"], + "summary": m["summary"], + "libraries": m.get("libraries", []), + "robotframework": str(m.get("robotframework", "")), + "dependencies": deps, + # A bundle ships a copy of other plugins (catalog.yaml ``bundle``) and + # no content of its own; file mode installs those plugins instead. + "bundle": bool(m.get("bundle")) and not has_content(d), + "bundled": list(m.get("bundle", [])), + } + for key in ("replacement", "deprecated_since"): + if key in m: + entry[key] = str(m[key]) + entries.append(entry) + return entries + + +def catalog_document(entries: list[dict[str, Any]]) -> str: + """The ``catalog.json`` text for ``entries``.""" + return json.dumps({"format": CATALOG_FORMAT, "plugins": entries}, indent=2, ensure_ascii=False) + "\n" + + +#: The plugin listed first by ``skills_dirs()``: the others depend on it. +CORE_PLUGIN = "rf-core" + + +def skill_dirs(entries: list[dict[str, Any]], root: Path) -> dict[str, Path]: + """``{name: /plugins//skills}`` for every active plugin in ``entries`` + that isn't a bundle and has at least one ``*/SKILL.md``; ``rf-core`` first, + then catalogue order.""" + names = [e["name"] for e in entries if e.get("status") == "active" and not e.get("bundle")] + if CORE_PLUGIN in names: + names.remove(CORE_PLUGIN) + names.insert(0, CORE_PLUGIN) + out: dict[str, Path] = {} + for name in names: + skills = root / "plugins" / name / "skills" + if any(skills.glob("*/SKILL.md")): + out[name] = skills.resolve() + return out diff --git a/installer/src/robotframework_plugins/_snapshot.py b/installer/src/robotframework_plugins/_snapshot.py new file mode 100644 index 0000000..596249e --- /dev/null +++ b/installer/src/robotframework_plugins/_snapshot.py @@ -0,0 +1,230 @@ +"""Snapshots of the plugins at a ref of the repository (``bundle.fetch()``). + +The only code in the package that uses the network, and only when +``fetch()`` is called. The installer's commands never import it. + +1. Resolve the ref to a commit SHA (a full SHA is taken as is). +2. Reuse ``//`` when a finished snapshot is there. +3. Download the commit's archive from GitHub, with a timeout and size caps. +4. Check every archive entry, and extract ``plugins/`` by hand: only regular + files and directories, no absolute paths, no ``..``. +5. Build the catalogue with the wheel's rules (``_catalog``), then move the + finished folder into the cache in one step. + +Nothing from the snapshot is imported or run. +""" + +from __future__ import annotations + +import copy +import datetime as _dt +import http.client +import json +import os +import re +import shutil +import tarfile +import tempfile +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import dataclass +from pathlib import Path, PurePosixPath +from typing import Any + +from . import __version__, _catalog + +REPOSITORY = "MarketSquare/robotframework-plugins" +#: Base URLs; tests point them at a local server. +GITHUB_API = "https://api.github.com" +CODELOAD = "https://codeload.github.com" +#: Caps on the compressed download and on what it may expand to. +MAX_ARCHIVE_BYTES = 64 * 1024 * 1024 +MAX_EXTRACTED_BYTES = 512 * 1024 * 1024 +_SHA = re.compile(r"[0-9a-f]{40}") +_DONE = "snapshot.json" + + +class FetchError(RuntimeError): + """A snapshot couldn't be fetched or failed its checks; the cache is unchanged.""" + + +@dataclass(frozen=True) +class Snapshot: + """The plugins at one commit of the repository, extracted in the cache.""" + + ref: str + commit: str + path: Path + + def catalog(self) -> list[dict[str, Any]]: + """The snapshot's plugins (active and deprecated), as ``bundle.catalog()``.""" + doc = json.loads((self.path / "catalog.json").read_text(encoding="utf-8")) + return copy.deepcopy(list(doc.get("plugins", []))) + + def skills_dirs(self) -> dict[str, Path]: + """``{plugin: skills folder}`` of the snapshot, as ``bundle.skills_dirs()``.""" + return _catalog.skill_dirs(self.catalog(), self.path) + + +def default_cache_dir() -> Path: + if os.name == "nt": + base = Path(os.environ.get("LOCALAPPDATA") or Path.home() / "AppData" / "Local") + else: + base = Path(os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache") + return base / "robotframework-plugins" / "snapshots" + + +def fetch(ref: str = "stable", *, cache_dir: str | os.PathLike[str] | None = None, + timeout: float = 30.0) -> Snapshot: + ref = str(ref).strip() + if not ref: + raise FetchError("ref is empty") + cache = Path(cache_dir) if cache_dir is not None else default_cache_dir() + sha = ref.lower() if _SHA.fullmatch(ref.lower()) else None + if sha is None: + sha = _resolve(ref, timeout) + hit = _cached(cache, sha, ref) + if hit is not None: + return hit + return _build(ref, sha, cache, timeout) + + +def _cached(cache: Path, sha: str, ref: str) -> Snapshot | None: + folder = cache / sha + if (folder / _DONE).is_file() and (folder / "catalog.json").is_file(): + return Snapshot(ref=ref, commit=sha, path=folder.resolve()) + return None + + +def _request(url: str, accept: str) -> urllib.request.Request: + return urllib.request.Request(url, headers={"Accept": accept, "User-Agent": f"robotframework-plugins/{__version__}"}) + + +def _resolve(ref: str, timeout: float) -> str: + url = f"{GITHUB_API}/repos/{REPOSITORY}/commits/{urllib.parse.quote(ref, safe='/')}" + try: + with urllib.request.urlopen(_request(url, "application/vnd.github.sha"), timeout=timeout) as resp: + body = resp.read(200).decode("ascii", "replace").strip() + except urllib.error.HTTPError as exc: + if exc.code in (404, 422): + raise FetchError(f"ref {ref!r} not found in {REPOSITORY}") from exc + raise FetchError(f"resolving ref {ref!r} failed: HTTP {exc.code}") from exc + except (urllib.error.URLError, OSError, http.client.HTTPException) as exc: + raise FetchError(f"resolving ref {ref!r} failed: {_reason(exc)}") from exc + if not _SHA.fullmatch(body): + raise FetchError(f"resolving ref {ref!r} returned no commit SHA") + return body + + +def _reason(exc: BaseException) -> str: + reason = getattr(exc, "reason", None) or exc + return f"{type(reason).__name__}: {reason}" + + +def _build(ref: str, sha: str, cache: Path, timeout: float) -> Snapshot: + try: + cache.mkdir(parents=True, exist_ok=True) + work = Path(tempfile.mkdtemp(prefix=f".tmp-{sha[:12]}-", dir=cache)) + except OSError as exc: + raise FetchError(f"cache folder {cache} is not writable: {exc}") from exc + try: + archive = work / "archive.tar.gz" + _download(sha, archive, timeout) + snap = work / "snapshot" + _extract_plugins(archive, snap) + try: + entries = _catalog.catalog_entries(snap / "plugins") + except _catalog.CatalogError as exc: + raise FetchError(f"snapshot {sha[:12]}: {exc}") from exc + (snap / "catalog.json").write_text(_catalog.catalog_document(entries), encoding="utf-8") + (snap / _DONE).write_text(json.dumps({ + "repository": REPOSITORY, "ref": ref, "commit": sha, + "fetched_at": _dt.datetime.now(_dt.timezone.utc).isoformat(timespec="seconds"), # noqa: UP017 (3.10) + }, indent=2) + "\n", encoding="utf-8") + target = cache / sha + try: + os.replace(snap, target) + except OSError: + # Another process finished the same snapshot first: use its folder. + hit = _cached(cache, sha, ref) + if hit is None: + raise + return hit + return Snapshot(ref=ref, commit=sha, path=target.resolve()) + finally: + shutil.rmtree(work, ignore_errors=True) + + +def _download(sha: str, dest: Path, timeout: float) -> None: + url = f"{CODELOAD}/{REPOSITORY}/tar.gz/{sha}" + size = 0 + try: + with urllib.request.urlopen(_request(url, "application/octet-stream"), timeout=timeout) as resp, \ + dest.open("wb") as out: + while chunk := resp.read(1024 * 1024): + size += len(chunk) + if size > MAX_ARCHIVE_BYTES: + raise FetchError(f"archive of {sha[:12]} is larger than {MAX_ARCHIVE_BYTES // 2**20} MB") + out.write(chunk) + except urllib.error.HTTPError as exc: + raise FetchError(f"downloading {sha[:12]} failed: HTTP {exc.code}") from exc + except (urllib.error.URLError, OSError, http.client.HTTPException) as exc: + raise FetchError(f"downloading {sha[:12]} failed: {_reason(exc)}") from exc + + +def _safe_parts(name: str) -> tuple[str, ...]: + path = PurePosixPath(name.replace("\\", "/")) + parts = tuple(p for p in path.parts if p not in ("", ".")) + if path.is_absolute() or ".." in parts or (parts and ":" in parts[0]): + raise FetchError(f"archive entry {name!r} leaves the snapshot folder") + return parts + + +def _extract_plugins(archive: Path, dest: Path) -> None: + """Check every entry, then write ``/plugins/**`` (minus plugin tests/evals) to ``dest/plugins``.""" + try: + with tarfile.open(archive, "r:gz") as tf: + members = tf.getmembers() + _check_members(members) + written = _write_plugins(tf, members, dest) + except (tarfile.TarError, EOFError) as exc: + raise FetchError(f"archive is not a readable gzip tarball: {exc}") from exc + except OSError as exc: + raise FetchError(f"extracting the archive failed: {exc}") from exc + if not written or not (dest / "plugins").is_dir(): + raise FetchError("archive has no plugins/ folder") + + +def _check_members(members: list[tarfile.TarInfo]) -> None: + total = 0 + for m in members: + _safe_parts(m.name) + if not (m.isfile() or m.isdir()): + raise FetchError(f"archive entry {m.name!r} is a link or a special file") + total += m.size if m.isfile() else 0 + if total > MAX_EXTRACTED_BYTES: + raise FetchError(f"archive expands to more than {MAX_EXTRACTED_BYTES // 2**20} MB") + + +def _write_plugins(tf: tarfile.TarFile, members: list[tarfile.TarInfo], dest: Path) -> int: + written = 0 + for m in members: + parts = _safe_parts(m.name) + # /plugins//...; a plugin's own tests/ and evals/ stay out. + if len(parts) < 3 or parts[1] != "plugins": + continue + if len(parts) > 3 and parts[3] in _catalog.PLUGIN_ONLY_DIRS: + continue + target = dest.joinpath("plugins", *parts[2:]) + if m.isdir(): + target.mkdir(parents=True, exist_ok=True) + continue + src = tf.extractfile(m) + if src is None: + raise FetchError(f"archive entry {m.name!r} can't be read") + target.parent.mkdir(parents=True, exist_ok=True) + with src, target.open("wb") as out: + shutil.copyfileobj(src, out) + written += 1 + return written diff --git a/installer/src/robotframework_plugins/bundle.py b/installer/src/robotframework_plugins/bundle.py new file mode 100644 index 0000000..14e801c --- /dev/null +++ b/installer/src/robotframework_plugins/bundle.py @@ -0,0 +1,85 @@ +"""Public Python API to the plugin content this package bundles. + + from robotframework_plugins import bundle + + bundle.CATALOG_FORMAT # 1; changes only on a breaking change to catalog() entries + bundle.catalog() # the bundled plugins (active and deprecated) + bundle.skills_dirs() # {plugin: Path of its skills/ folder}, rf-core first + bundle.fetch("main") # opt-in: the same for a ref of the repository (network) + +Only these names (and ``Snapshot``, ``FetchError``) are public; see the +"Python API" section of the README for the compatibility promise. Importing +this module, ``catalog()`` and ``skills_dirs()`` never use the network. +""" + +from __future__ import annotations + +import atexit +import copy +import shutil +import tempfile +import threading +from pathlib import Path +from typing import Any + +from . import _assets +from ._catalog import CATALOG_FORMAT, skill_dirs +from ._snapshot import FetchError, Snapshot, fetch + +__all__ = ("CATALOG_FORMAT", "FetchError", "Snapshot", "catalog", "fetch", "skills_dirs") + +_lock = threading.Lock() +_extracted: Path | None = None + + +def catalog() -> list[dict[str, Any]]: + """The bundled plugins (status active or deprecated), in catalogue order. + + Each entry has at least ``name``, ``version``, ``status``, ``tier``, + ``summary``, ``libraries`` (``[{name, package, versions}]``), + ``robotframework``, ``dependencies``, ``bundle`` and ``bundled``. The list + is a copy: changing it doesn't change later results. Empty for a broken + build without a catalogue. + """ + return copy.deepcopy(_assets.bundled_catalog()) + + +def skills_dirs() -> dict[str, Path]: + """``{plugin name: absolute Path of its skills/ folder}``. + + Every active plugin that isn't a bundle and has skills, ``rf-core`` first, + then catalogue order. The folders exist for the life of the process, also + when the package is imported from a zip archive (then the bundle is copied + once to a temporary folder, removed at exit). + """ + entries = _assets.bundled_catalog() + if not entries: + return {} + return skill_dirs(entries, _bundle_root()) + + +def _bundle_root() -> Path: + """The bundle (``_assets/``) as a real directory.""" + global _extracted + root = _assets.bundle_root_traversable() + if isinstance(root, Path): + return root + with _lock: + if _extracted is None: + target = Path(tempfile.mkdtemp(prefix="robotframework-plugins-bundle-")) + atexit.register(shutil.rmtree, target, True) + _copy_tree(root, target) + _extracted = target + return _extracted + + +def _copy_tree(src: Any, dest: Path) -> None: + """Copy an importlib Traversable tree; works where ``as_file`` can't copy + directories (Python 3.10 and 3.11).""" + for child in src.iterdir(): + if child.is_dir(): + sub = dest / child.name + sub.mkdir() + _copy_tree(child, sub) + else: + (dest / child.name).write_bytes(child.read_bytes()) diff --git a/installer/src/robotframework_plugins/py.typed b/installer/src/robotframework_plugins/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/openspec/changes/public-bundle-api/tasks.md b/openspec/changes/public-bundle-api/tasks.md index 75c3437..9e6484c 100644 --- a/openspec/changes/public-bundle-api/tasks.md +++ b/openspec/changes/public-bundle-api/tasks.md @@ -1,35 +1,35 @@ ## 1. Branch -- [ ] 1.1 Branch `change/public-bundle-api` from `main` and commit this change's artifacts there; verify `git log origin/main..HEAD` shows only this change. +- [x] 1.1 Branch `change/public-bundle-api` from `main` and commit this change's artifacts there; verify `git log origin/main..HEAD` shows only this change. ## 2. Shared catalogue builder (D4) -- [ ] 2.1 Move the entry-building code from `installer/hatch_build.py` into `installer/src/robotframework_plugins/_catalog.py` (`CATALOG_FORMAT`, `catalog_entries()`, `has_content()`), add `robotframework` to each entry and a clear error for a missing manifest or required field; have the hook load it by path and write `{"format": CATALOG_FORMAT, "plugins": [...]}`. Verify a rebuilt `_assets/catalog.json` (`uv build --wheel`, or reinstalling `installer` editable) has `format: 1` and `rf-web`'s `robotframework` is `>=7.0`, and that `uv build --sdist` followed by a wheel build from the sdist still works. -- [ ] 2.2 Check that existing readers still work; verify `uv run pytest tests/installer` passes, including fixtures that write a catalogue without `format`. +- [x] 2.1 Move the entry-building code from `installer/hatch_build.py` into `installer/src/robotframework_plugins/_catalog.py` (`CATALOG_FORMAT`, `catalog_entries()`, `has_content()`), add `robotframework` to each entry and a clear error for a missing manifest or required field; have the hook load it by path and write `{"format": CATALOG_FORMAT, "plugins": [...]}`. Verify a rebuilt `_assets/catalog.json` (`uv build --wheel`, or reinstalling `installer` editable) has `format: 1` and `rf-web`'s `robotframework` is `>=7.0`, and that `uv build --sdist` followed by a wheel build from the sdist still works. +- [x] 2.2 Check that existing readers still work; verify `uv run pytest tests/installer` passes, including fixtures that write a catalogue without `format`. ## 3. Bundled API (D1, D2, D3, D5) -- [ ] 3.1 Add `installer/src/robotframework_plugins/bundle.py` with `CATALOG_FORMAT` (from `_catalog`), `catalog()`, `skills_dirs()` and `__all__`, wrapping `_assets`; add `py.typed`. Verify unit tests in `tests/installer/test_bundle_api.py` for "Public names", "Import makes no network call", "Library matching data", "Bundles are marked", "Caller changes don't leak", "Names match the catalogue", "Bundles and deprecated plugins left out" (a fixture bundle with a deprecated plugin) and "File and constant agree", plus an empty result for a bundle without a catalogue. -- [ ] 3.2 Implement the zip/frozen path of `skills_dirs()` (one extraction per process, removed at exit); verify a test that zips the installed package, imports it in a fresh interpreter with only the zip on `sys.path`, and finds existing `SKILL.md` files under every returned path. +- [x] 3.1 Add `installer/src/robotframework_plugins/bundle.py` with `CATALOG_FORMAT` (from `_catalog`), `catalog()`, `skills_dirs()` and `__all__`, wrapping `_assets`; add `py.typed`. Verify unit tests in `tests/installer/test_bundle_api.py` for "Public names", "Import makes no network call", "Library matching data", "Bundles are marked", "Caller changes don't leak", "Names match the catalogue", "Bundles and deprecated plugins left out" (a fixture bundle with a deprecated plugin) and "File and constant agree", plus an empty result for a bundle without a catalogue. +- [x] 3.2 Implement the zip/frozen path of `skills_dirs()` (one extraction per process, removed at exit); verify a test that zips the installed package, imports it in a fresh interpreter with only the zip on `sys.path`, and finds existing `SKILL.md` files under every returned path. ## 4. Repository snapshots (D7, D8) -- [ ] 4.1 Add `installer/src/robotframework_plugins/_snapshot.py` (resolve, cache lookup, download with timeout and size cap, safe extraction of `plugins/`, catalogue check with `_catalog`, atomic move into the cache) and `bundle.fetch()`, `bundle.Snapshot` and `bundle.FetchError`. Verify tests in `tests/installer/test_bundle_fetch.py` against a fake GitHub on `127.0.0.1` for "Snapshot of main", "Same interface as the bundle", "Second fetch reuses the cache", "Commit SHA offline" (sockets blocked), "Network down", "Unsafe archive refused" (a `../` member and a symlink member), "Broken plugin manifest refused", a 404 ref (`stable` not found) and a timeout. -- [ ] 4.2 Check that the installer never fetches; verify `tests/installer/test_no_network.py` still passes and a test asserts that no CLI module imports `_snapshot`. -- [ ] 4.3 Add a test marked `network`, skipped by default, that fetches `main` from GitHub and checks the scenario "Snapshot of main"; verify it passes when run with `-m network` locally. +- [x] 4.1 Add `installer/src/robotframework_plugins/_snapshot.py` (resolve, cache lookup, download with timeout and size cap, safe extraction of `plugins/`, catalogue check with `_catalog`, atomic move into the cache) and `bundle.fetch()`, `bundle.Snapshot` and `bundle.FetchError`. Verify tests in `tests/installer/test_bundle_fetch.py` against a fake GitHub on `127.0.0.1` for "Snapshot of main", "Same interface as the bundle", "Second fetch reuses the cache", "Commit SHA offline" (sockets blocked), "Network down", "Unsafe archive refused" (a `../` member and a symlink member), "Broken plugin manifest refused", a 404 ref (`stable` not found) and a timeout. +- [x] 4.2 Check that the installer never fetches; verify `tests/installer/test_no_network.py` still passes and a test asserts that no CLI module imports `_snapshot`. +- [x] 4.3 Add a test marked `network`, skipped by default, that fetches `main` from GitHub and checks the scenario "Snapshot of main"; verify it passes when run with `-m network` locally. ## 5. Wheel test (D6) -- [ ] 5.1 Add a slow test that builds the wheel, installs it into a fresh `uv venv`, and runs a script there checking "Real directories of the installed package" and "Names match the catalogue", that `bundle.fetch` is importable, that `py.typed` is in the wheel, and that `catalog.json` has `format: 1`; verify it passes locally and in the CI job that runs the slow installer tests (or add it to one). +- [ ] 5.1 Add a slow test that builds the wheel, installs it into a fresh `uv venv`, and runs a script there checking "Real directories of the installed package" and "Names match the catalogue", that `bundle.fetch` is importable, that `py.typed` is in the wheel, and that `catalog.json` has `format: 1`; verify it passes locally and in the CI job that runs the slow installer tests (or add it to one). Local: passes. CI: the Test job didn't have uv, so the existing wheel test skipped there; this change adds `astral-sh/setup-uv` to it. ## 6. Docs and changelog -- [ ] 6.1 Add a "Python API" section to `installer/README.md`: the public names, a short example (library matching, skill folders, `fetch("main")` with the bundle as fallback), the compatibility promise from the spec, that `_assets` is private, that `fetch()` is the only API that uses the network, where the cache lives, and that content from a branch other than `stable` is merged but not released (and `stable` only exists after the first plugin release). Verify `scripts/check-docs-voice.py` passes. -- [ ] 6.2 Add the API, `fetch()`, the `format` key and the `robotframework` field to the 2026.11.0 entry of `installer/CHANGELOG.md` under "Added"; verify `scripts/check-docs-voice.py` passes. +- [x] 6.1 Add a "Python API" section to `installer/README.md`: the public names, a short example (library matching, skill folders, `fetch("main")` with the bundle as fallback), the compatibility promise from the spec, that `_assets` is private, that `fetch()` is the only API that uses the network, where the cache lives, and that content from a branch other than `stable` is merged but not released (and `stable` only exists after the first plugin release). Verify `scripts/check-docs-voice.py` passes. +- [x] 6.2 Add the API, `fetch()`, the `format` key and the `robotframework` field to the 2026.11.0 entry of `installer/CHANGELOG.md` under "Added"; verify `scripts/check-docs-voice.py` passes. ## 7. Checks and PR -- [ ] 7.1 Run `uv run pytest tests/installer` (with the slow tests), `ruff check installer`, `scripts/check-drift.sh` and `scripts/build-catalog.py --check`; verify all pass. +- [x] 7.1 Run `uv run pytest tests/installer` (with the slow tests), `ruff check installer`, `scripts/check-drift.sh` and `scripts/build-catalog.py --check`; verify all pass. Done: `tests` (819 passed), `tests/installer` with slow tests (274 passed), the new tests on Python 3.10, drift, catalogue and docs voice pass. `ruff check installer` reports 58 findings in files this change doesn't touch (CI doesn't lint the installer); the new and changed files are clean. - [ ] 7.2 Open a PR against `main` that closes #5 (ask before posting); verify the required checks pass. ## 8. Release (maintainer) diff --git a/pyproject.toml b/pyproject.toml index 2b164ae..f817de9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -259,6 +259,7 @@ markers = [ "requires_claude: marks tests that require the `claude` CLI on PATH", "slow: marks slow benchmark tests (hook latency budget)", "rf61: marks tests that build a Robot Framework 6.1 venv with uv (skipped offline)", + "network: reaches the real network (GitHub); skipped unless selected with -m network", ] # --- Coverage ---------------------------------------------------------------- diff --git a/tests/installer/conftest.py b/tests/installer/conftest.py index 5bbdff9..e1dfd34 100644 --- a/tests/installer/conftest.py +++ b/tests/installer/conftest.py @@ -74,3 +74,13 @@ def two_plugin_bundle(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: ]})) monkeypatch.setattr(_assets, "bundle_root_traversable", lambda: bundle) return bundle + + +def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None: + """Tests marked ``network`` reach GitHub: run them only with ``-m network``.""" + if "network" in (config.getoption("-m") or ""): + return + skip = pytest.mark.skip(reason="reaches the real network; run with -m network") + for item in items: + if "network" in item.keywords: + item.add_marker(skip) diff --git a/tests/installer/test_bundle_api.py b/tests/installer/test_bundle_api.py new file mode 100644 index 0000000..526417d --- /dev/null +++ b/tests/installer/test_bundle_api.py @@ -0,0 +1,159 @@ +"""Public Python API: robotframework_plugins.bundle (spec installer-python-api; public-bundle-api 3.1, 3.2).""" +from __future__ import annotations + +import json +import socket +import subprocess +import sys +import textwrap +import zipfile +from pathlib import Path + +import pytest +from robotframework_plugins import _assets, bundle + +ACTIVE_CONTENT = ("rf-core", "rf-api", "rf-desktop", "rf-mobile", "rf-web") + + +def test_public_names() -> None: + assert set(bundle.__all__) == {"CATALOG_FORMAT", "catalog", "skills_dirs", "fetch", "Snapshot", "FetchError"} + assert bundle.CATALOG_FORMAT == 1 + + +def test_import_makes_no_network_call(monkeypatch: pytest.MonkeyPatch) -> None: + def refuse(*args, **kwargs): + raise AssertionError(f"network call: {args[:2]!r}") + + for name in ("create_connection", "getaddrinfo"): + monkeypatch.setattr(socket, name, refuse) + monkeypatch.setattr(socket.socket, "connect", refuse) + code = "from robotframework_plugins import bundle; bundle.catalog(); bundle.skills_dirs()" + # A fresh interpreter: the import itself must not connect either. + guard = textwrap.dedent(""" + import socket + def refuse(*a, **k): raise SystemExit("network call") + socket.create_connection = socket.getaddrinfo = refuse + socket.socket.connect = refuse + """) + subprocess.run([sys.executable, "-c", guard + code], check=True) + assert bundle.catalog() and bundle.skills_dirs() + + +def test_library_matching_data() -> None: + web = next(p for p in bundle.catalog() if p["name"] == "rf-web") + assert {"name": "Browser", "package": "robotframework-browser", "versions": web["libraries"][0]["versions"]} \ + in web["libraries"] + assert web["robotframework"].startswith(">=") + for entry in bundle.catalog(): + assert {"name", "version", "status", "tier", "summary", "libraries", "robotframework", + "dependencies", "bundle", "bundled"} <= set(entry), entry["name"] + + +def test_bundles_are_marked() -> None: + agentskills = next(p for p in bundle.catalog() if p["name"] == "rf-agentskills") + assert agentskills["bundle"] is True + assert set(agentskills["bundled"]) == set(ACTIVE_CONTENT) + + +def test_caller_changes_dont_leak() -> None: + first = bundle.catalog() + first.pop() + first[0]["name"] = "changed" + second = bundle.catalog() + assert len(second) == len(_assets.bundled_catalog()) + assert "changed" not in {p["name"] for p in second} + + +def test_skills_dirs_are_real_and_named_like_the_catalogue() -> None: + dirs = bundle.skills_dirs() + assert tuple(dirs) == ACTIVE_CONTENT + for name, path in dirs.items(): + assert path.is_absolute() and path.is_dir(), name + assert any(path.glob("*/SKILL.md")), name + expected = [p["name"] for p in bundle.catalog() if p["status"] == "active" and not p["bundle"]] + assert set(dirs) == set(expected) + + +def test_bundles_and_deprecated_plugins_left_out(two_plugin_bundle: Path) -> None: + dirs = bundle.skills_dirs() + assert "rf-example" in dirs + assert "rf-old" not in dirs and "rf-agentskills" not in dirs + assert next(iter(dirs)) == "rf-core" + assert {p["name"] for p in bundle.catalog()} >= {"rf-old", "rf-agentskills"} + + +def test_file_and_constant_agree() -> None: + doc = json.loads(_assets.bundle_root_traversable().joinpath("catalog.json").read_text(encoding="utf-8")) + assert doc["format"] == bundle.CATALOG_FORMAT + assert [p["name"] for p in doc["plugins"]] == [p["name"] for p in bundle.catalog()] + + +def test_no_catalogue_gives_empty_results(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + (tmp_path / "plugins" / "rf-core" / "skills" / "x").mkdir(parents=True) + (tmp_path / "plugins" / "rf-core" / "skills" / "x" / "SKILL.md").write_text("x") + monkeypatch.setattr(_assets, "bundle_root_traversable", lambda: tmp_path) + assert bundle.catalog() == [] + assert bundle.skills_dirs() == {} + + +def test_skills_dirs_from_a_zip_import(tmp_path: Path) -> None: + """Zip/frozen installs: the folders exist although the package lives in a zip.""" + pkg = Path(bundle.__file__).parent + archive = tmp_path / "pkg.zip" + with zipfile.ZipFile(archive, "w") as zf: + for f in pkg.rglob("*"): + if f.is_file() and "__pycache__" not in f.parts: + zf.write(f, Path("robotframework_plugins") / f.relative_to(pkg)) + script = textwrap.dedent(f""" + import sys + sys.path = [{str(archive)!r}] + [p for p in sys.path if "robotframework_plugins" not in p + and "site-packages" not in p or p.endswith("site-packages")] + import robotframework_plugins + assert robotframework_plugins.__file__.startswith({str(archive)!r}), robotframework_plugins.__file__ + from robotframework_plugins import bundle + dirs = bundle.skills_dirs() + assert list(dirs) == {list(ACTIVE_CONTENT)!r}, list(dirs) + for path in dirs.values(): + assert path.is_dir() and any(path.glob("*/SKILL.md")), path + assert dirs == bundle.skills_dirs() # one extraction, reused + print("ok") + """) + out = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True) + assert out.returncode == 0, out.stderr + assert out.stdout.strip() == "ok" + + +@pytest.mark.slow +def test_api_from_an_installed_wheel(tmp_path: Path) -> None: + """The issue's acceptance test: build the wheel, install it into a fresh + venv and use the API from there (public-bundle-api 5.1).""" + import shutil + + if shutil.which("uv") is None: + pytest.skip("uv not on PATH") + repo = Path(__file__).resolve().parents[2] + dist, venv = tmp_path / "dist", tmp_path / "venv" + subprocess.run(["uv", "build", "--wheel", "-q", "-o", str(dist)], cwd=repo / "installer", check=True) + (wheel,) = dist.glob("robotframework_plugins-*.whl") + names = zipfile.ZipFile(wheel).namelist() + assert "robotframework_plugins/py.typed" in names + assert "robotframework_plugins/bundle.py" in names + assert json.loads(zipfile.ZipFile(wheel).read("robotframework_plugins/_assets/catalog.json"))["format"] == 1 + subprocess.run(["uv", "venv", "-q", "--python", sys.executable, str(venv)], check=True) + python = venv / ("Scripts/python.exe" if sys.platform == "win32" else "bin/python") + subprocess.run(["uv", "pip", "install", "-q", "--python", str(python), str(wheel)], check=True) + script = textwrap.dedent(f""" + from robotframework_plugins import bundle + assert bundle.CATALOG_FORMAT == 1 and callable(bundle.fetch) + dirs = bundle.skills_dirs() + assert list(dirs) == {list(ACTIVE_CONTENT)!r}, list(dirs) + for path in dirs.values(): + assert path.is_absolute() and path.is_dir() and any(path.glob("*/SKILL.md")), path + assert "site-packages" in str(path), path + active = [p["name"] for p in bundle.catalog() if p["status"] == "active" and not p["bundle"]] + assert set(dirs) == set(active) + print("ok") + """) + out = subprocess.run([str(python), "-I", "-c", script], capture_output=True, text=True) + assert out.returncode == 0, out.stderr + assert out.stdout.strip() == "ok" diff --git a/tests/installer/test_bundle_fetch.py b/tests/installer/test_bundle_fetch.py new file mode 100644 index 0000000..76c8462 --- /dev/null +++ b/tests/installer/test_bundle_fetch.py @@ -0,0 +1,243 @@ +"""bundle.fetch(): snapshots of a repository ref (spec installer-python-api; public-bundle-api 4.1-4.3). + +A fake GitHub on 127.0.0.1 serves the two endpoints fetch() uses, so the +suite never touches the real network (except the opt-in ``network`` test). +""" +from __future__ import annotations + +import io +import json +import socket +import tarfile +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +import pytest +from robotframework_plugins import _snapshot, bundle + +SHA = "a" * 40 +OTHER = "b" * 40 + + +def _plugin(files: dict[str, bytes], name: str, *, status: str = "active", skill: bool = True, + manifest_extra: str = "") -> None: + files[f"plugins/{name}/catalog.yaml"] = ( + f"name: {name}\nsummary: The {name} plugin.\ntier: verified\nstatus: {status}\n" + f"libraries: []\nrobotframework: \">=7.0\"\n{manifest_extra}").encode() + files[f"plugins/{name}/.claude-plugin/plugin.json"] = json.dumps({"name": name, "version": "1.2.3"}).encode() + if skill: + files[f"plugins/{name}/skills/{name}-skill/SKILL.md"] = b"---\nname: x\ndescription: y\n---\n" + files[f"plugins/{name}/skills/{name}-skill/references/a.md"] = b"ref" + files[f"plugins/{name}/tests/test_x.py"] = b"raise SystemExit('never extracted')" + + +def _tree() -> dict[str, bytes]: + files: dict[str, bytes] = {"README.md": b"repo"} + _plugin(files, "rf-web") + _plugin(files, "rf-core") + _plugin(files, "rf-old", status="deprecated") + _plugin(files, "rf-agentskills", skill=False, manifest_extra="bundle: [rf-core, rf-web]\n") + return files + + +def _tarball(files: dict[str, bytes], sha: str = SHA, extra=None) -> bytes: + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w:gz") as tf: + top = f"robotframework-plugins-{sha}" + for name, data in files.items(): + info = tarfile.TarInfo(f"{top}/{name}") + info.size = len(data) + tf.addfile(info, io.BytesIO(data)) + if extra: + extra(tf, top) + return buf.getvalue() + + +class FakeGitHub: + def __init__(self) -> None: + self.refs: dict[str, str] = {"main": SHA} + self.archives: dict[str, bytes] = {SHA: _tarball(_tree())} + self.requests: list[str] = [] + self.delay = 0.0 + fake = self + + class Handler(BaseHTTPRequestHandler): + def log_message(self, *args) -> None: + pass + + def do_GET(self) -> None: + fake.requests.append(self.path) + time.sleep(fake.delay) + prefix = f"/repos/{_snapshot.REPOSITORY}/commits/" + if self.path.startswith(prefix): + sha = fake.refs.get(self.path[len(prefix):]) + body = sha.encode() if sha else b'{"message": "No commit found"}' + self._send(200 if sha else 422, body) + elif self.path.startswith(f"/{_snapshot.REPOSITORY}/tar.gz/"): + data = fake.archives.get(self.path.rsplit("/", 1)[1]) + self._send(200 if data else 404, data or b"") + else: + self._send(404, b"") + + def _send(self, code: int, body: bytes) -> None: + self.send_response(code) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + self.url = f"http://127.0.0.1:{self.server.server_address[1]}" + threading.Thread(target=self.server.serve_forever, daemon=True).start() + + def downloads(self) -> int: + return sum("/tar.gz/" in r for r in self.requests) + + +@pytest.fixture +def github(monkeypatch: pytest.MonkeyPatch): + fake = FakeGitHub() + monkeypatch.setattr(_snapshot, "GITHUB_API", fake.url) + monkeypatch.setattr(_snapshot, "CODELOAD", fake.url) + yield fake + fake.server.shutdown() + + +@pytest.fixture +def cache(tmp_path: Path) -> Path: + return tmp_path / "cache" + + +def _block_network(monkeypatch: pytest.MonkeyPatch) -> list[str]: + attempts: list[str] = [] + + def refuse(*args, **kwargs): + attempts.append(repr(args[:2])) + raise ConnectionRefusedError("network blocked by the test") + + monkeypatch.setattr(socket, "create_connection", refuse) + monkeypatch.setattr(socket.socket, "connect", refuse) + return attempts + + +def test_snapshot_of_main(github: FakeGitHub, cache: Path) -> None: + snap = bundle.fetch("main", cache_dir=cache) + assert (snap.ref, snap.commit) == ("main", SHA) + dirs = snap.skills_dirs() + assert list(dirs) == ["rf-core", "rf-web"] # rf-core first; bundle and deprecated left out + for path in dirs.values(): + assert path.is_dir() and any(path.glob("*/SKILL.md")) + assert path.is_relative_to(cache.resolve()) + # Plugin tests/ are not extracted, as in the wheel. + assert not (snap.path / "plugins" / "rf-web" / "tests").exists() + assert json.loads((snap.path / "snapshot.json").read_text())["commit"] == SHA + + +def test_same_interface_as_the_bundle(github: FakeGitHub, cache: Path) -> None: + snap = bundle.fetch("main", cache_dir=cache) + entries = {p["name"]: p for p in snap.catalog()} + assert set(entries) == {"rf-agentskills", "rf-core", "rf-old", "rf-web"} + assert set(entries["rf-web"]) == set(bundle.catalog()[0]) + assert entries["rf-web"]["robotframework"] == ">=7.0" + assert entries["rf-agentskills"]["bundle"] is True + assert json.loads((snap.path / "catalog.json").read_text())["format"] == bundle.CATALOG_FORMAT + snap.catalog().clear() + assert len(snap.catalog()) == 4 + + +def test_second_fetch_reuses_the_cache(github: FakeGitHub, cache: Path) -> None: + first = bundle.fetch("main", cache_dir=cache) + second = bundle.fetch("main", cache_dir=cache) + assert github.downloads() == 1 + assert (first.commit, first.path, first.skills_dirs()) == (second.commit, second.path, second.skills_dirs()) + assert not [p for p in cache.iterdir() if p.name.startswith(".tmp-")] + + +def test_commit_sha_offline(github: FakeGitHub, cache: Path, monkeypatch: pytest.MonkeyPatch) -> None: + bundle.fetch("main", cache_dir=cache) + attempts = _block_network(monkeypatch) + snap = bundle.fetch(SHA.upper(), cache_dir=cache) + assert snap.commit == SHA and snap.skills_dirs() + assert attempts == [] + + +def test_network_down(cache: Path, monkeypatch: pytest.MonkeyPatch) -> None: + _block_network(monkeypatch) + with pytest.raises(bundle.FetchError, match="resolving ref 'main' failed"): + bundle.fetch("main", cache_dir=cache) + with pytest.raises(bundle.FetchError, match="downloading"): + bundle.fetch(OTHER, cache_dir=cache) + assert bundle.skills_dirs() # the bundled copy is unaffected + assert not cache.exists() or not any(cache.iterdir()) + + +def test_unknown_ref(github: FakeGitHub, cache: Path) -> None: + with pytest.raises(bundle.FetchError, match="ref 'stable' not found"): + bundle.fetch(cache_dir=cache) + + +def test_timeout(github: FakeGitHub, cache: Path) -> None: + github.delay = 1.0 + with pytest.raises(bundle.FetchError, match="failed"): + bundle.fetch("main", cache_dir=cache, timeout=0.2) + + +def _add_parent_entry(tf: tarfile.TarFile, top: str) -> None: + info = tarfile.TarInfo(f"{top}/../outside.txt") + info.size = 1 + tf.addfile(info, io.BytesIO(b"x")) + + +def _add_symlink(tf: tarfile.TarFile, top: str) -> None: + info = tarfile.TarInfo(f"{top}/plugins/rf-web/skills/link") + info.type = tarfile.SYMTYPE + info.linkname = "/etc/passwd" + tf.addfile(info) + + +@pytest.mark.parametrize(("extra", "message"), [ + (_add_parent_entry, "leaves the snapshot folder"), + (_add_symlink, "is a link or a special file"), +]) +def test_unsafe_archive_refused(github: FakeGitHub, cache: Path, tmp_path: Path, extra, message: str) -> None: + github.archives[SHA] = _tarball(_tree(), extra=extra) + with pytest.raises(bundle.FetchError, match=message): + bundle.fetch("main", cache_dir=cache) + assert not (cache / SHA).exists() + assert not (tmp_path / "outside.txt").exists() and not (cache.parent / "outside.txt").exists() + assert not [p for p in cache.iterdir() if p.name.startswith(".tmp-")] + + +def test_broken_plugin_manifest_refused(github: FakeGitHub, cache: Path) -> None: + files = _tree() + files["plugins/rf-web/catalog.yaml"] = b"name: rf-web\nsummary: x\ntier: verified\n" + github.archives[SHA] = _tarball(files) + with pytest.raises(bundle.FetchError, match=r"rf-web: catalog\.yaml lacks status"): + bundle.fetch("main", cache_dir=cache) + assert not (cache / SHA).exists() + + +def test_archive_without_plugins_refused(github: FakeGitHub, cache: Path) -> None: + github.archives[SHA] = _tarball({"README.md": b"x"}) + with pytest.raises(bundle.FetchError, match="no plugins/ folder"): + bundle.fetch("main", cache_dir=cache) + + +def test_the_installer_never_fetches() -> None: + """Only bundle.py may import _snapshot; the CLI and adapters never do.""" + pkg = Path(bundle.__file__).parent + offenders = [p.relative_to(pkg).as_posix() for p in pkg.rglob("*.py") + if p.name not in ("bundle.py", "_snapshot.py") and "_snapshot" in p.read_text(encoding="utf-8")] + assert offenders == [] + + +@pytest.mark.network +def test_fetch_main_from_github(tmp_path: Path) -> None: + """Manual check before a release: `pytest -m network`.""" + snap = bundle.fetch("main", cache_dir=tmp_path) + assert len(snap.commit) == 40 + dirs = snap.skills_dirs() + assert next(iter(dirs)) == "rf-core" + for path in dirs.values(): + assert any(path.glob("*/SKILL.md")) From 1b2607399f5e9130c9531dfbdd3fc27981e0c48f Mon Sep 17 00:00:00 2001 From: Many Kasiriha Date: Thu, 12 Nov 2026 20:34:17 +0100 Subject: [PATCH 3/3] docs(openspec): public-bundle-api 5.1 verified in CI Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FHEmzL3ErAhGQykKWDx2wP --- openspec/changes/public-bundle-api/tasks.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openspec/changes/public-bundle-api/tasks.md b/openspec/changes/public-bundle-api/tasks.md index 9e6484c..e113f45 100644 --- a/openspec/changes/public-bundle-api/tasks.md +++ b/openspec/changes/public-bundle-api/tasks.md @@ -20,7 +20,7 @@ ## 5. Wheel test (D6) -- [ ] 5.1 Add a slow test that builds the wheel, installs it into a fresh `uv venv`, and runs a script there checking "Real directories of the installed package" and "Names match the catalogue", that `bundle.fetch` is importable, that `py.typed` is in the wheel, and that `catalog.json` has `format: 1`; verify it passes locally and in the CI job that runs the slow installer tests (or add it to one). Local: passes. CI: the Test job didn't have uv, so the existing wheel test skipped there; this change adds `astral-sh/setup-uv` to it. +- [x] 5.1 Add a slow test that builds the wheel, installs it into a fresh `uv venv`, and runs a script there checking "Real directories of the installed package" and "Names match the catalogue", that `bundle.fetch` is importable, that `py.typed` is in the wheel, and that `catalog.json` has `format: 1`; verify it passes locally and in the CI job that runs the slow installer tests (or add it to one). Local: passes. CI: the Test job didn't have uv, so the existing wheel test skipped there; this change adds `astral-sh/setup-uv` to it. CI on #6: passes on Windows with Python 3.10 (with `test_wheel_contains_bundled_plugins` and the zip test, which ran in CI for the first time). ## 6. Docs and changelog @@ -30,7 +30,7 @@ ## 7. Checks and PR - [x] 7.1 Run `uv run pytest tests/installer` (with the slow tests), `ruff check installer`, `scripts/check-drift.sh` and `scripts/build-catalog.py --check`; verify all pass. Done: `tests` (819 passed), `tests/installer` with slow tests (274 passed), the new tests on Python 3.10, drift, catalogue and docs voice pass. `ruff check installer` reports 58 findings in files this change doesn't touch (CI doesn't lint the installer); the new and changed files are clean. -- [ ] 7.2 Open a PR against `main` that closes #5 (ask before posting); verify the required checks pass. +- [ ] 7.2 Open a PR against `main` that closes #5 (ask before posting); verify the required checks pass. Opened as #6: all checks pass except `required-reviews` (needs a review or the admin bypass). ## 8. Release (maintainer)