From d4e0f83dd829539defdc218b80f6aa618be6070f Mon Sep 17 00:00:00 2001 From: scosman Date: Thu, 24 Sep 2026 02:13:39 +0000 Subject: [PATCH 1/4] Make a hub scaffold deployable by stock OpenEnv hosts; document from_hub(skip_install=True) A published Seahaven world is consumed with `AutoEnv.from_hub("/", skip_install=True)` (or `base_url=` for a self-hosted server), which returns OpenEnv's `GenericEnvClient` and never installs the world's package or runs discovery. Plain `from_hub` without `skip_install` is documented as unsupported. Scaffold (`seahaven new --hub`): - openenv.yaml gains `app: .openenv_app:app`, which OpenEnv's Modal and Daytona providers read from the image to start the server; its comment now says what reads the file, and why there is no `validation:` block. - Dockerfile puts `/app/.venv/bin` on PATH, so the providers' `python -m uvicorn ` uses the world's interpreter, and adds a HEALTHCHECK on /health. How it installs Seahaven is unchanged. - README.md starts with Hugging Face Space settings (`sdk: docker`, `app_port: 8000`, `base_path: /console`) and gains a section on connecting, both from new `templates/hub_readme/` fragments. `base_path` is /console because every image serves the console, while /web exists only when push enables its web interface; push keeps an existing `base_path`. A world without `--hub` gets the README it had. Docs: serving_and_openenv.md "Publishing to a hub" covers the Space settings, connecting with from_hub, and the image; reference/cli.md lists the three deviations from an `openenv init` environment (no `validation:` block, no server/app.py, no openenv-* name or discoverable client). Tests: tests/test_hub_scaffold.py (serve extra) renders a hub scaffold and checks it against upstream's own code: run_validation reports only the missing `validation:` block, validate_env_structure passes, the `app:` value (read with parse_openenv_app_field) imports and serves /health and the world's metadata on a real port, the Dockerfile CMD, HEALTHCHECK and PATH agree with it, the Space settings' port and base_path are served, and `AutoEnv.from_hub(..., base_url=url, skip_install=True)` drives reset, list tools, two tool calls, state and close. tests/serving.py gains serving_app(). Not verifiable until Seahaven is on PyPI: the image booting, and from_hub against a real Space. uv.lock unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QWJSvm3Hep8BHqyKWBkKvp --- src/seahaven/cli/new.py | 39 +++- .../cli/templates/base/README.md.tmpl | 4 +- .../cli/templates/hub/Dockerfile.tmpl | 6 + .../cli/templates/hub/openenv.yaml.tmpl | 11 +- .../templates/hub_readme/connecting.md.tmpl | 20 ++ .../templates/hub_readme/space_card.md.tmpl | 10 + src/seahaven/docs/reference/cli.md | 15 +- src/seahaven/docs/serving_and_openenv.md | 56 +++++- tests/serving.py | 12 +- tests/test_hub_scaffold.py | 173 ++++++++++++++++++ 10 files changed, 322 insertions(+), 24 deletions(-) create mode 100644 src/seahaven/cli/templates/hub_readme/connecting.md.tmpl create mode 100644 src/seahaven/cli/templates/hub_readme/space_card.md.tmpl create mode 100644 tests/test_hub_scaffold.py diff --git a/src/seahaven/cli/new.py b/src/seahaven/cli/new.py index 17ce578..ae62cf2 100644 --- a/src/seahaven/cli/new.py +++ b/src/seahaven/cli/new.py @@ -5,12 +5,13 @@ directory is there and complete, or it was never created. The templates are ordinary files with a `.tmpl` suffix, substituted with -`string.Template` -- `$name`, `$package`, `$seahaven_requirement` -- and written -out without it. The suffix is what keeps a directory full of `$package` out of -the repository's own ruff, ty and pytest runs: a `.py` holding -`from $package.world import world` is not Python, and every tool in the tree -would have an opinion about it. The package directory is spelled `PACKAGE` in -the template tree for the same reason, and is renamed on the way out. +`string.Template` -- `$name`, `$package`, `$seahaven_requirement` and the +README's two `--hub` sections -- and written out without it. The suffix is what +keeps a directory full of `$package` out of the repository's own ruff, ty and +pytest runs: a `.py` holding `from $package.world import world` is not Python, +and every tool in the tree would have an opinion about it. The package directory +is spelled `PACKAGE` in the template tree for the same reason, and is renamed on +the way out. The result passes `seahaven check` with no findings and `pytest` with no fixture on disk, which is the point: an author's first run of both succeeds, so the first @@ -40,11 +41,17 @@ BASE_TEMPLATES = "base" HUB_TEMPLATES = "hub" -# What `--hub` adds, and the whole of what it adds: the files `openenv push` -# validates a pushed directory for (`components/openenv.md` §6). A world that -# does not publish to a hub carries none of them. +# The files `--hub` adds: the ones `openenv push` validates a pushed directory +# for (`components/openenv.md` §6). A world that does not publish to a hub +# carries none of them. HUB_FILES = ("Dockerfile", "__init__.py", "client.py", "models.py", "openenv.yaml") +# What `--hub` writes into `README.md`, which every world has, rather than as +# files of its own: a Hugging Face Space reads its settings from the README's +# front matter, and the README is the card the Space shows. +HUB_README_TEMPLATES = "hub_readme" +HUB_README_PARTS = {"space_card": "space_card.md", "hub_connecting": "connecting.md"} + # `seahaven~=X.Y`: a world is built against the framework's authoring API, which # moves with the minor version, and a world that pinned the patch would need a # release for every one of ours. @@ -113,6 +120,7 @@ def render(name: str, target: Path, *, hub: bool = False) -> Path: "package": package, "seahaven_requirement": seahaven_requirement(), } + substitutions |= _readme_parts(substitutions, hub=hub) try: for group in (BASE_TEMPLATES, *((HUB_TEMPLATES,) if hub else ())): _render_group(group, target, package, substitutions) @@ -132,6 +140,19 @@ def seahaven_requirement() -> str: return f"seahaven~={release.group(1)}.{release.group(2)}" +def _readme_parts(substitutions: dict[str, str], *, hub: bool) -> dict[str, str]: + """The README's hub sections, rendered, or empty strings for a world without `--hub`.""" + if not hub: + return dict.fromkeys(HUB_README_PARTS, "") + root = resources.files(__package__) / "templates" / HUB_README_TEMPLATES + return { + key: Template((root / f"{part}{TEMPLATE_SUFFIX}").read_text(encoding="utf-8")).substitute( + substitutions + ) + for key, part in HUB_README_PARTS.items() + } + + def _render_group(group: str, target: Path, package: str, substitutions: dict[str, str]) -> None: root = resources.files(__package__) / "templates" / group for source, relative in _templates(root, Path()): diff --git a/src/seahaven/cli/templates/base/README.md.tmpl b/src/seahaven/cli/templates/base/README.md.tmpl index 8ba4566..7177dad 100644 --- a/src/seahaven/cli/templates/base/README.md.tmpl +++ b/src/seahaven/cli/templates/base/README.md.tmpl @@ -1,4 +1,4 @@ -# $name +$space_card# $name A Seahaven world. @@ -24,7 +24,7 @@ with $package.world.instance(None) as instance: Serve it over OpenEnv with `uv run seahaven serve`, and run its tests with `uv run pytest`. -## Making a fixture +$hub_connecting## Making a fixture `fixtures_src/generate.py` is the recipe for every fixture. Its `build` freezes one from a blank instance on a `fixed` clock, so a rebuild writes the same bytes. From this directory: diff --git a/src/seahaven/cli/templates/hub/Dockerfile.tmpl b/src/seahaven/cli/templates/hub/Dockerfile.tmpl index 96c5b0e..c461fd0 100644 --- a/src/seahaven/cli/templates/hub/Dockerfile.tmpl +++ b/src/seahaven/cli/templates/hub/Dockerfile.tmpl @@ -9,5 +9,11 @@ COPY . /app # does not need. RUN uv sync --extra serve +# OpenEnv's container providers start the server as `python -m uvicorn `, +# with `app` from openenv.yaml, so `python` has to be this world's interpreter. +ENV PATH="/app/.venv/bin:$$PATH" + EXPOSE 8000 +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 CMD \ + python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" CMD ["uv", "run", "uvicorn", "$package.openenv_app:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/src/seahaven/cli/templates/hub/openenv.yaml.tmpl b/src/seahaven/cli/templates/hub/openenv.yaml.tmpl index 80a78cd..cbcf748 100644 --- a/src/seahaven/cli/templates/hub/openenv.yaml.tmpl +++ b/src/seahaven/cli/templates/hub/openenv.yaml.tmpl @@ -1,8 +1,15 @@ -# Required by `openenv push`: the environment card a hub reads. Only `name` is -# read by push itself; the rest describes the Space it creates. +# The environment card OpenEnv's tooling reads. `openenv push` reads `name`. +# OpenEnv's Modal and Daytona providers find this file inside the running image +# and start the server with `python -m uvicorn ` from this directory, so +# `app` names this world's ASGI app. +# +# There is no `validation:` block, so `openenv validate` reports one failure. +# That block declares a reward range and a reward oracle, and a Seahaven world +# has no reward to declare. spec_version: 1 name: $name type: space runtime: fastapi +app: $package.openenv_app:app port: 8000 version: 0.1.0 diff --git a/src/seahaven/cli/templates/hub_readme/connecting.md.tmpl b/src/seahaven/cli/templates/hub_readme/connecting.md.tmpl new file mode 100644 index 0000000..526bdff --- /dev/null +++ b/src/seahaven/cli/templates/hub_readme/connecting.md.tmpl @@ -0,0 +1,20 @@ +## Connecting to the published world + +Once this world is pushed to a Hugging Face Space, connect to it with OpenEnv's generic client. Your +side needs the `openenv` package (`pip install openenv`) and nothing from this repository: + +```py +from openenv import AutoEnv + +with AutoEnv.from_hub("/$name", skip_install=True) as env: + env.reset(seed=7) + tools = env.step({"type": "list_tools"}).observation["tools"] + created = env.step( + {"type": "call_tool", "tool_name": "create_item", "arguments": {"name": "First"}} + ).observation + print(created["result"], env.state()) +``` + +Always pass `skip_install=True`. Without it, `from_hub` tries to install this repository as a +Python package, and that fails. For a server you run yourself, add `base_url="http://host:8000"`. + diff --git a/src/seahaven/cli/templates/hub_readme/space_card.md.tmpl b/src/seahaven/cli/templates/hub_readme/space_card.md.tmpl new file mode 100644 index 0000000..382aa52 --- /dev/null +++ b/src/seahaven/cli/templates/hub_readme/space_card.md.tmpl @@ -0,0 +1,10 @@ +--- +title: $name +sdk: docker +app_port: 8000 +base_path: /console +tags: + - openenv + - seahaven +--- + diff --git a/src/seahaven/docs/reference/cli.md b/src/seahaven/docs/reference/cli.md index 771837b..14293cd 100644 --- a/src/seahaven/docs/reference/cli.md +++ b/src/seahaven/docs/reference/cli.md @@ -57,7 +57,7 @@ the generator script. No fixture is built and nothing is imported. | Option | What it does | |---|---| | `--dir ` | the directory to create the world in; the default is here | -| `--hub` | also write the five files `openenv push` requires: `openenv.yaml`, a root `Dockerfile`, a root `__init__.py`, `client.py` and `models.py` | +| `--hub` | also write the five files `openenv push` requires: `openenv.yaml`, a root `Dockerfile`, a root `__init__.py`, `client.py` and `models.py`; start `README.md` with a Hugging Face Space's settings, and add a section on connecting to the world | The package name is the world's name, normalised. `AGENTS.md` is written once and never touched again. It is an ordinary file the world owns, and it points an authoring agent at these docs. @@ -80,6 +80,19 @@ placeholder ships no console script. Install the framework from a checkout until same applies to `--hub`: the `Dockerfile` it writes runs `uv sync --extra serve`, which today builds an image whose container cannot start. +A `--hub` world differs from the environment `openenv init` makes in three ways: + +- `openenv.yaml` has no `validation:` block, so `openenv validate` reports one failure, + ``openenv.yaml has no `validation:` block``. The block declares a reward range and a reward + oracle, and a Seahaven world has no reward + ([why](../serving_and_openenv.md#why-there-are-no-rewards)). +- There is no `server/app.py`. The root `Dockerfile` and the `app` key in `openenv.yaml` name + `.openenv_app:app` instead. +- The package is named after the world, not `openenv-`, and has no client class that + OpenEnv's lookup finds. `AutoEnv.from_hub` connects only with `skip_install=True` + ([Connecting to a published world](../serving_and_openenv.md#connecting-to-a-published-world)), + and `AutoEnv.from_env("")` and `AutoAction` do not find the world. + ## `seahaven check` Runs every lint over the world and prints each finding, sorted by code, then path, then line. diff --git a/src/seahaven/docs/serving_and_openenv.md b/src/seahaven/docs/serving_and_openenv.md index 2cdce48..23960c1 100644 --- a/src/seahaven/docs/serving_and_openenv.md +++ b/src/seahaven/docs/serving_and_openenv.md @@ -29,7 +29,7 @@ learn. A person working against a world by hand has a third command, | [The wire protocol](#the-wire-protocol) | Every frame and model, for a client in any language | | [The concurrency gate](#the-concurrency-gate) | What bounds tool calls, and a known defect in it | | [Running it in production](#running-it-in-production) | Reaping, disconnects, and scaling out | -| [Publishing to a hub](#publishing-to-a-hub) | `seahaven new --hub`, and what does not work yet | +| [Publishing to a hub](#publishing-to-a-hub) | `seahaven new --hub`, connecting with `AutoEnv.from_hub`, and the image | | [Evaluating with Kiln](#evaluating-with-kiln) | Where the scenario and the grader belong | | [Serving one world to an MCP client](#serving-one-world-to-an-mcp-client) | `seahaven mcp`, and who it is for | | [Known problems in OpenEnv](#known-problems-in-openenv) | Routes Seahaven refuses, and why | @@ -558,20 +558,62 @@ Seahaven does not ship keeps OpenEnv's 20 seconds. ## Publishing to a hub -A world can be published as an OpenEnv environment — a Docker image or a Hugging Face Space — and -driven by anyone with an OpenEnv client. +A world can be published as an OpenEnv environment, on a Hugging Face Space or as a Docker image, +and driven by anyone with an OpenEnv client. `seahaven new --hub` adds the five files `openenv push` validates a directory for: `openenv.yaml`, a -root `Dockerfile`, a root `__init__.py`, `client.py` and `models.py`. It adds nothing else, and a -world that does not publish to a hub carries none of them. `client.py` is a single re-export, -because the typed client for every Seahaven world is `SeahavenClient`. +root `Dockerfile`, a root `__init__.py`, `client.py` and `models.py`. It also starts `README.md` +with the settings a Space reads, and adds a section that tells a visitor how to connect. A world +that does not publish to a hub carries none of this. +[`seahaven new`](reference/cli.md#seahaven-new-name) lists where the scaffold differs from +OpenEnv's own. -**That `Dockerfile` does not build a working image today.** Its build step is `RUN uv sync --extra +In those settings, `sdk: docker` makes the Space run the image. `app_port: 8000` sends the Space's +traffic to the port the server listens on. `base_path: /console` opens the Space's page on the +[web console](#the-web-console), which every served world has. `openenv push` adds +`base_path: /web` only to a README that has no `base_path`, so it leaves this one alone. + +### Connecting to a published world + +Connect with OpenEnv's `AutoEnv.from_hub` and `skip_install=True`: + +```py +from openenv import AutoEnv + +with AutoEnv.from_hub("/", skip_install=True) as env: + env.reset(fixture="small_startup", seed=7) + tools = env.step({"type": "list_tools"}).observation["tools"] + observation = env.step( + {"type": "call_tool", "tool_name": "get_issue", "arguments": {"key": "ENG-12"}} + ).observation + print(observation["result"], env.state()["now"]) +``` + +The call returns OpenEnv's `GenericEnvClient`, which sends and receives plain dictionaries in the +shapes [The wire protocol](#the-wire-protocol) gives. The client side needs only the `openenv` +package, which runs on Python 3.10 or newer; it does not need Seahaven or CPython 3.14. When the +Space is running, the client connects to it. When it is not, `from_hub` pulls the Space's image and +runs it with Docker. For a server you run yourself, add `base_url="http://127.0.0.1:8000"`. + +**Always pass `skip_install=True`.** Without it, `from_hub` tries to install the Space's repository +into your environment as a Python package and then looks in it for a client class. A Seahaven world +has none that OpenEnv's lookup finds, so the call fails. + +A client that has Seahaven installed can use `SeahavenClient` with typed observations instead. Its +`base_url` for a Space is `https://-.hf.space`. + +### The image + +**The `Dockerfile` does not build a working image today.** Its build step is `RUN uv sync --extra serve`, and the world's `serve` extra is `seahaven[serve]`, which resolves to the placeholder release described at the top of this page. The image builds, and the container cannot start: there is no `seahaven.openenv` in it. Until publication, an image has to get the framework from a checkout or a private index, which means editing that `RUN` line. +The image serves `.openenv_app:app` on port 8000, and answers its `HEALTHCHECK` on +`/health`. The `app` key in `openenv.yaml` names the same app, because OpenEnv's Modal and Daytona +providers start the server from that key rather than from the `Dockerfile`. + **The image is a checkout, and has to stay one.** The generated `Dockerfile` does `COPY . /app` and then `uv sync`, so the container holds the world's whole directory with the framework installed into its environment. Replace those two lines with a plain install of the world and the image builds, the diff --git a/tests/serving.py b/tests/serving.py index ba2cb5a..9eca053 100644 --- a/tests/serving.py +++ b/tests/serving.py @@ -24,6 +24,7 @@ from typing import Any import uvicorn +from fastapi import FastAPI from seahaven.openenv import app from seahaven.world import World @@ -35,9 +36,14 @@ @contextmanager def serving(world: World, **options: Any) -> Iterator[str]: """Serve a world on a free port for the block, and answer its base URL.""" - server = uvicorn.Server( - uvicorn.Config(app(world, **options), host="127.0.0.1", port=0, log_level="warning") - ) + with serving_app(app(world, **options)) as url: + yield url + + +@contextmanager +def serving_app(served: FastAPI) -> Iterator[str]: + """Serve an app that is already built, such as a scaffold's `openenv_app:app`.""" + server = uvicorn.Server(uvicorn.Config(served, host="127.0.0.1", port=0, log_level="warning")) thread = threading.Thread(target=server.run, name="test-uvicorn", daemon=True) thread.start() try: diff --git a/tests/test_hub_scaffold.py b/tests/test_hub_scaffold.py new file mode 100644 index 0000000..08a813b --- /dev/null +++ b/tests/test_hub_scaffold.py @@ -0,0 +1,173 @@ +"""A `seahaven new --hub` scaffold, judged by OpenEnv's own code. + +`test_cli_new.py` asserts what the scaffold contains. This module asserts what +upstream does with it: `openenv validate`, the structure check `openenv push` +runs, the `app:` field and Dockerfile `CMD` the container providers read, the +front matter a Hugging Face Space reads, and the consumer call the docs name, +`AutoEnv.from_hub(..., skip_install=True)`, against the scaffold's own app on a +real port. Each of these was once wrong in a scaffold whose file set was right. +""" + +import json +import re +import urllib.request +from collections.abc import Iterator +from pathlib import Path +from typing import Any + +import pytest + +from seahaven.cli.new import render + +pytest.importorskip( + "seahaven.openenv", exc_type=ImportError, reason="the serve extra does not import here" +) + +import yaml +from fastapi import FastAPI +from openenv import AutoEnv, GenericEnvClient +from openenv.cli._cli_utils import validate_env_structure +from openenv.core.containers.runtime._server_config import ( + parse_dockerfile_cmd, + parse_openenv_app_field, +) +from openenv.validation import CheckStatus, load_policy, run_validation +from uvicorn.importer import import_from_string + +from tests.serving import serving_app + +pytestmark = pytest.mark.usefixtures("isolated_imports") + +WORLD = "hubbed" + +# The one finding `openenv validate` has on a scaffold, and the deviation +# `serving_and_openenv.md` documents: the block declares a reward range and a +# reward oracle, and a Seahaven world has no reward. +NO_VALIDATION_BLOCK = "openenv.yaml has no `validation:` block" + + +@pytest.fixture +def published(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """A hub scaffold, importable the way `uv sync` makes it inside the image.""" + root = render(WORLD, tmp_path / WORLD, hub=True) + monkeypatch.syspath_prepend(str(root / "src")) + return root + + +@pytest.fixture +def manifest(published: Path) -> dict[str, Any]: + return dict(yaml.safe_load((published / "openenv.yaml").read_text(encoding="utf-8"))) + + +@pytest.fixture +def served_app(published: Path) -> FastAPI: + """The app `openenv.yaml` names, read with upstream's reader and imported as uvicorn does.""" + reference = parse_openenv_app_field((published / "openenv.yaml").read_text(encoding="utf-8")) + assert reference is not None, "openenv.yaml has no `app:` for the container providers" + served = import_from_string(reference) + assert isinstance(served, FastAPI) + return served + + +@pytest.fixture +def url(served_app: FastAPI) -> Iterator[str]: + with serving_app(served_app) as url: + yield url + + +def get(url: str) -> bytes: + """The body of a `GET`; `urlopen` raises on any status from 400 up.""" + with urllib.request.urlopen(url) as response: + return bytes(response.read()) + + +def test_openenv_validate_reports_only_the_missing_validation_block(published: Path) -> None: + report = run_validation(published, policy=load_policy()) + assert [(result.check_id, result.status, result.evidence) for result in report.results] == [ + ("static.manifest", CheckStatus.FAIL, [NO_VALIDATION_BLOCK]) + ] + + +def test_the_scaffold_has_the_structure_openenv_push_requires(published: Path) -> None: + """Raises on a missing required file; the one warning is for a directory of eval output.""" + assert validate_env_structure(published) == ["Recommended directory missing: outputs/"] + + +def test_the_app_in_openenv_yaml_is_this_worlds_server(url: str) -> None: + assert json.loads(get(url + "/health"))["status"] == "healthy" + assert json.loads(get(url + "/metadata"))["name"] == WORLD + + +def test_the_dockerfile_serves_the_same_app_on_the_same_port( + published: Path, manifest: dict[str, Any] +) -> None: + dockerfile = (published / "Dockerfile").read_text(encoding="utf-8") + command = parse_dockerfile_cmd(dockerfile) + assert command is not None + assert manifest["app"] in command.split() + assert f"--port {manifest['port']}" in command + assert f"EXPOSE {manifest['port']}" in dockerfile + + +def test_the_healthcheck_asks_a_route_the_server_has( + published: Path, manifest: dict[str, Any], url: str +) -> None: + dockerfile = (published / "Dockerfile").read_text(encoding="utf-8") + probe = re.search(r"^HEALTHCHECK .*?http://localhost:(\d+)(/[\w/]*)", dockerfile, re.M | re.S) + assert probe is not None, "the Dockerfile has no HEALTHCHECK that asks the server" + assert int(probe[1]) == manifest["port"] + get(url + probe[2]) + + +def test_the_worlds_interpreter_is_first_on_the_images_path(published: Path) -> None: + """The providers run `python -m uvicorn `; `uv sync` puts the venv in the workdir.""" + instructions = [ + line.split(maxsplit=1) + for line in (published / "Dockerfile").read_text(encoding="utf-8").splitlines() + if line and not line.startswith((" ", "#")) + ] + workdir = next(argument for keyword, argument in instructions if keyword == "WORKDIR") + assert ["ENV", f'PATH="{workdir}/.venv/bin:$PATH"'] in instructions + + +def test_the_space_card_points_hugging_face_at_the_server( + published: Path, manifest: dict[str, Any], url: str +) -> None: + readme = (published / "README.md").read_text(encoding="utf-8") + assert readme.startswith("---\n") + card = yaml.safe_load(readme.split("---\n", 2)[1]) + assert card["sdk"] == "docker" + assert card["app_port"] == manifest["port"] + assert b" None: + readme = (render(WORLD, tmp_path / WORLD) / "README.md").read_text(encoding="utf-8") + assert readme.startswith(f"# {WORLD}\n") + assert "from_hub" not in readme + + +def test_from_hub_with_skip_install_drives_the_published_world(url: str) -> None: + """The consumer call the docs and the scaffold README name, with no Seahaven client side.""" + env = AutoEnv.from_hub(f"someone/{WORLD}", base_url=url, skip_install=True) + assert isinstance(env, GenericEnvClient) + with env: + reset = env.reset(seed=7) + assert reset.observation["metadata"]["tools"] == 2 + listed = env.step({"type": "list_tools"}).observation + assert [tool["name"] for tool in listed["tools"]] == ["create_item", "get_item"] + created = env.step( + {"type": "call_tool", "tool_name": "create_item", "arguments": {"name": "First"}} + ).observation + assert created["error"] is None + fetched = env.step( + { + "type": "call_tool", + "tool_name": "get_item", + "arguments": {"item_id": created["result"]["id"]}, + } + ).observation + assert fetched["result"] == created["result"] + state = env.state() + assert state["world"]["name"] == WORLD + assert state["step_count"] == 3 From 563944a31c037cb780be8e3fb3e3038c5d1ef62f Mon Sep 17 00:00:00 2001 From: scosman Date: Thu, 24 Sep 2026 03:16:45 +0000 Subject: [PATCH 2/4] Replace `seahaven new --hub` with a `seahaven hub` command Publishing to a hub now has one way in: `seahaven new x`, then `seahaven hub` in the world's directory. - `seahaven hub` finds the world's root as the other commands do (nearest pyproject.toml), reads `[project] name` and imports nothing. It writes the five files `openenv push` requires, puts the Space front matter at the top of README.md, and adds the connecting section after `## Using it` (or at the end). A missing README.md is created with both sections. - All or nothing, never overwrite: every target is checked first; an existing hub file, existing front matter or an existing connecting section refuses with every obstacle named and nothing written. A world without src//openenv_app.py is refused. A failed write rolls back the files it created. - `seahaven new` loses `--hub` and the README placeholders; its next steps mention `seahaven hub`. Template rendering moves to cli/scaffold.py, shared by both commands. `project_root` and `project_name` are public in seahaven.cli, with a remedy for commands that have no `--world`. - Output is byte-identical to the old `new --hub` scaffold. - Docs: reference/cli.md gains a `seahaven hub` section (the three OpenEnv deviations move with it); serving_and_openenv.md and index.md updated. - Tests: tests/test_cli_hub.py covers placement, every refusal with a nothing-written snapshot, rollback, and the CLI entry point; test_hub_scaffold.py keeps every check, now driving `new` then `hub`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QWJSvm3Hep8BHqyKWBkKvp --- src/seahaven/cli/__init__.py | 41 ++-- src/seahaven/cli/hub.py | 165 +++++++++++++ src/seahaven/cli/new.py | 98 ++------ src/seahaven/cli/scaffold.py | 60 +++++ .../cli/templates/base/README.md.tmpl | 4 +- src/seahaven/docs/index.md | 9 +- src/seahaven/docs/reference/cli.md | 44 +++- src/seahaven/docs/serving_and_openenv.md | 19 +- tests/test_cli.py | 2 +- tests/test_cli_hub.py | 219 ++++++++++++++++++ tests/test_cli_new.py | 31 ++- tests/test_hub_scaffold.py | 10 +- tests/test_packaging.py | 3 +- 13 files changed, 561 insertions(+), 144 deletions(-) create mode 100644 src/seahaven/cli/hub.py create mode 100644 src/seahaven/cli/scaffold.py create mode 100644 tests/test_cli_hub.py diff --git a/src/seahaven/cli/__init__.py b/src/seahaven/cli/__init__.py index f49cdea..3545ea1 100644 --- a/src/seahaven/cli/__init__.py +++ b/src/seahaven/cli/__init__.py @@ -1,6 +1,6 @@ """`seahaven`: the command, its parser, and how it finds a world. -Six subcommands, one module each, and nothing in any of them that is not +Seven subcommands, one module each, and nothing in any of them that is not argument parsing, world discovery or output. The work is done by code that already exists -- `World.instance`, `Instance.freeze`, `seahaven.openenv.serve`, `seahaven.lint` -- which is what keeps the CLI from becoming a second API beside @@ -38,7 +38,17 @@ from seahaven.errors import SeahavenError from seahaven.world import DDL_DOES_NOT_EXECUTE, World -__all__ = ["CliError", "Discovery", "build_parser", "discover", "find_world", "main"] +__all__ = [ + "CliError", + "Discovery", + "build_parser", + "discover", + "find_world", + "main", + "package_name", + "project_name", + "project_root", +] PROJECT_FILE = "pyproject.toml" SOURCE_DIRNAME = "src" @@ -99,11 +109,11 @@ def build_parser() -> argparse.ArgumentParser: """ # Imported here rather than at module top: every subcommand module imports # this one for `CliError` and `find_world`. - from seahaven.cli import check, docs, fixture, mcp, new, serve + from seahaven.cli import check, docs, fixture, hub, mcp, new, serve parser = argparse.ArgumentParser(prog="seahaven", description="Build and run Seahaven worlds.") subcommands = parser.add_subparsers(dest="command", required=True, metavar="") - for module in (new, check, docs, fixture, serve, mcp): + for module in (new, hub, check, docs, fixture, serve, mcp): module.add_parser(subcommands) return parser @@ -157,24 +167,27 @@ def discover(explicit: str | None, start: Path | None = None) -> Discovery: module_name, attribute = _split_world_option(explicit) _make_importable(start, start / SOURCE_DIRNAME) return _import(module_name, attribute, root=start) - root = _project_root(start) + root = project_root(start) _make_importable(root, root / SOURCE_DIRNAME) - return _import(_package_name(root), WORLD_ATTRIBUTE, root=root) + return _import(package_name(project_name(root)), WORLD_ATTRIBUTE, root=root) -def _project_root(start: Path) -> Path: - """The nearest directory at or above `start` holding a `pyproject.toml`.""" +def project_root(start: Path, *, remedy: str = _OVERRIDE) -> Path: + """The nearest directory at or above `start` holding a `pyproject.toml`. + + `remedy` ends the refusal, for a command that has no `--world` to offer. + """ for directory in (start, *start.parents): if (directory / PROJECT_FILE).is_file(): return directory raise CliError( f"no {PROJECT_FILE} in {start} or any directory above it, so there is no world here; " - f"run seahaven from inside a world's project, {_OVERRIDE}" + f"run seahaven from inside a world's project, {remedy}" ) -def _package_name(root: Path) -> str: - """The package a project's `[project] name` normalises to.""" +def project_name(root: Path, *, remedy: str = _OVERRIDE) -> str: + """The `[project] name` of the project at `root`, as written.""" path = root / PROJECT_FILE try: data = tomllib.loads(path.read_text(encoding="utf-8")) @@ -182,10 +195,8 @@ def _package_name(root: Path) -> str: raise CliError(f"{path}: cannot be read as TOML: {error}") from error name = data.get("project", {}).get("name") if not isinstance(name, str) or not name: - raise CliError( - f"{path} has no [project] name, so there is no package to import; {_OVERRIDE}" - ) - return package_name(name) + raise CliError(f"{path} has no [project] name, so there is no package to import; {remedy}") + return name def package_name(name: str) -> str: diff --git a/src/seahaven/cli/hub.py b/src/seahaven/cli/hub.py new file mode 100644 index 0000000..8c2a242 --- /dev/null +++ b/src/seahaven/cli/hub.py @@ -0,0 +1,165 @@ +"""`seahaven hub`: add what publishing to a hub needs to a world that exists. + +Run in a world `seahaven new` made. It reads the world's name from +`pyproject.toml`, writes the five files `openenv push` validates a directory for, +and adds two sections to `README.md`: a Hugging Face Space's settings as front +matter at the top, and a section on connecting to the published world. Nothing +is imported, so the world does not have to be installed. + +All or nothing, and never over anything: every target is checked before the +first file is written, and one that is already there refuses the whole command +with a message naming it. A world that publishes has usually edited these files, +and an edit is not something to guess a merge for. +""" + +import argparse +import re +from pathlib import Path + +from seahaven.cli import SOURCE_DIRNAME, CliError, package_name, project_name, project_root +from seahaven.cli.scaffold import render_group, render_part + +__all__ = ["HUB_FILES", "README", "add_hub_files", "add_parser", "run"] + +HUB_TEMPLATES = "hub" +HUB_README_TEMPLATES = "hub_readme" + +# The files `openenv push` validates a pushed directory for, at the world's root. +HUB_FILES = ("Dockerfile", "__init__.py", "client.py", "models.py", "openenv.yaml") + +README = "README.md" +# The connecting section follows the scaffold README's usage section, the one a +# visitor to the Space reads first; a README without it gets the section at the end. +USAGE_HEADING = "## Using it" +APP_MODULE = "openenv_app.py" + +_REMEDY = "seahaven hub adds to a world made by seahaven new" +_FENCE = ("```", "~~~") +_SECTION_HEADING = re.compile(r"#{1,2} ") + + +def add_parser(subcommands: argparse._SubParsersAction[argparse.ArgumentParser]) -> None: + parser = subcommands.add_parser( + "hub", + help="add the files a world needs to publish to a hub", + description=( + "Add the files `openenv push` requires, and a Hugging Face Space's README " + "sections, to this world. Nothing that exists is overwritten." + ), + ) + parser.set_defaults(handler=run) + + +def run(args: argparse.Namespace) -> int: + """Add the hub files to the world here, and say what was written.""" + root, written = add_hub_files(Path.cwd()) + print(f"added to {root}:") + for relative in written: + print(f" {relative}") + return 0 + + +def add_hub_files(start: Path) -> tuple[Path, list[Path]]: + """Add the hub files to the world at or above `start`. + + Returns the world's root and what was written, relative to it. + """ + root = project_root(start.resolve(), remedy=_REMEDY) + name = project_name(root, remedy=_REMEDY) + package = package_name(name) + _require_the_app(root, package) + substitutions = {"name": name, "package": package} + files = render_group(HUB_TEMPLATES, package, substitutions) + card = render_part(HUB_README_TEMPLATES, "space_card.md", substitutions) + connecting = render_part(HUB_README_TEMPLATES, "connecting.md", substitutions) + + readme_path = root / README + readme = readme_path.read_text(encoding="utf-8") if readme_path.is_file() else None + in_the_way = [f"{relative} exists" for relative in files if (root / relative).exists()] + if readme is None and readme_path.exists(): + in_the_way.append(f"{README} is not a file") + in_the_way += _readme_conflicts(readme or "", connecting) + if in_the_way: + raise CliError( + f"seahaven hub never overwrites, so it wrote nothing: {'; '.join(in_the_way)}. " + f"Move these aside and run it again, or add the rest by hand" + ) + + updated: dict[Path, str] = {} + if readme is None: + # `pyproject.toml` names the README, so a world without one does not build. + files[Path(README)] = card + f"# {name}\n\n" + connecting.rstrip("\n") + "\n" + else: + updated[Path(README)] = card + _with_section(readme, connecting) + _write_all(root, files, updated) + return root, [*files, *updated] + + +def _require_the_app(root: Path, package: str) -> None: + """Refuse a world whose served app is not where the hub files will point.""" + source = root / SOURCE_DIRNAME / package + if not source.is_dir(): + raise CliError( + f"{source} is not a directory, and it is where the world's package {package!r} " + f"has to be; {_REMEDY}" + ) + if not (source / APP_MODULE).is_file(): + raise CliError( + f"{source / APP_MODULE} does not exist, and the Dockerfile and openenv.yaml serve " + f"{package}.openenv_app:app; create it holding `app = seahaven.openenv.app(world)`, " + f"as seahaven new does" + ) + + +def _readme_conflicts(readme: str, connecting: str) -> list[str]: + conflicts = [] + if readme.partition("\n")[0].rstrip() == "---": + conflicts.append(f"{README} already starts with front matter") + heading = connecting.partition("\n")[0] + if heading in readme.splitlines(): + conflicts.append(f"{README} already has a {heading.lstrip('# ')!r} section") + return conflicts + + +def _write_all(root: Path, new: dict[Path, str], updated: dict[Path, str]) -> None: + """Create every file in `new` and rewrite every one in `updated`, or change nothing.""" + created: list[Path] = [] + try: + for relative, text in new.items(): + path = root / relative + # Exclusive creation: a file that appeared since the check is refused, not replaced. + with path.open("x", encoding="utf-8") as handle: + created.append(path) + handle.write(text) + for relative, text in updated.items(): + (root / relative).write_text(text, encoding="utf-8") + except BaseException: + for path in created: + path.unlink(missing_ok=True) + raise + + +def _with_section(readme: str, section: str) -> str: + """`readme` with `section` after its usage section, or at its end.""" + lines = readme.splitlines(keepends=True) + after = _next_section(lines, USAGE_HEADING) + if after is None: + return readme.rstrip("\n") + "\n\n" + section.rstrip("\n") + "\n" + return "".join(lines[:after]) + section + "".join(lines[after:]) + + +def _next_section(lines: list[str], heading: str) -> int | None: + """The line the section after `heading`'s starts on, skipping code fences.""" + in_fence = False + found = False + for index, line in enumerate(lines): + text = line.rstrip("\r\n") + if text.startswith(_FENCE): + in_fence = not in_fence + elif in_fence: + continue + elif found and _SECTION_HEADING.match(text): + return index + elif text == heading: + found = True + return None diff --git a/src/seahaven/cli/new.py b/src/seahaven/cli/new.py index ae62cf2..738613f 100644 --- a/src/seahaven/cli/new.py +++ b/src/seahaven/cli/new.py @@ -1,17 +1,10 @@ """`seahaven new `: the scaffold, and nothing more than the scaffold. -Renders `templates/` and stops. Nothing is imported, no world is built and no -fixture is made, so `new` cannot fail halfway through something: either the -directory is there and complete, or it was never created. - -The templates are ordinary files with a `.tmpl` suffix, substituted with -`string.Template` -- `$name`, `$package`, `$seahaven_requirement` and the -README's two `--hub` sections -- and written out without it. The suffix is what -keeps a directory full of `$package` out of the repository's own ruff, ty and -pytest runs: a `.py` holding `from $package.world import world` is not Python, -and every tool in the tree would have an opinion about it. The package directory -is spelled `PACKAGE` in the template tree for the same reason, and is renamed on -the way out. +Renders `templates/base/` (`cli/scaffold.py` says how) and stops. Nothing is +imported, no world is built and no fixture is made, so `new` cannot fail halfway +through something: either the directory is there and complete, or it was never +created. The files a world needs to publish to a hub are `seahaven hub`'s, run +afterwards in the world's directory. The result passes `seahaven check` with no findings and `pytest` with no fixture on disk, which is the point: an author's first run of both succeeds, so the first @@ -22,35 +15,16 @@ import keyword import re import shutil -from collections.abc import Iterator -from importlib import resources -from importlib.resources.abc import Traversable from pathlib import Path -from string import Template import seahaven from seahaven.cli import CliError, package_name +from seahaven.cli.scaffold import render_group from seahaven.names import NAME_RULE, why_not_a_name -__all__ = ["HUB_FILES", "TEMPLATE_SUFFIX", "add_parser", "render", "run"] - -TEMPLATE_SUFFIX = ".tmpl" -# The directory the world's package is spelled as inside `templates/`. -PACKAGE_PLACEHOLDER = "PACKAGE" +__all__ = ["add_parser", "render", "run"] BASE_TEMPLATES = "base" -HUB_TEMPLATES = "hub" - -# The files `--hub` adds: the ones `openenv push` validates a pushed directory -# for (`components/openenv.md` §6). A world that does not publish to a hub -# carries none of them. -HUB_FILES = ("Dockerfile", "__init__.py", "client.py", "models.py", "openenv.yaml") - -# What `--hub` writes into `README.md`, which every world has, rather than as -# files of its own: a Hugging Face Space reads its settings from the README's -# front matter, and the README is the card the Space shows. -HUB_README_TEMPLATES = "hub_readme" -HUB_README_PARTS = {"space_card": "space_card.md", "hub_connecting": "connecting.md"} # `seahaven~=X.Y`: a world is built against the framework's authoring API, which # moves with the minor version, and a world that pinned the patch would need a @@ -69,28 +43,24 @@ def add_parser(subcommands: argparse._SubParsersAction[argparse.ArgumentParser]) metavar="", help="the directory to create the world in (default: here)", ) - parser.add_argument( - "--hub", - action="store_true", - help="also write the files `openenv push` requires, for a world that publishes to a hub", - ) parser.set_defaults(handler=run) def run(args: argparse.Namespace) -> int: """Render the scaffold and print what to do next.""" target = Path(args.dir) / args.name - render(args.name, target, hub=args.hub) + render(args.name, target) print(f"created {target}") print("next:") print(f" cd {target}") print(" uv sync") print(" uv run pytest") print(" uv run seahaven check") + print("to publish it to a hub: uv run seahaven hub") return 0 -def render(name: str, target: Path, *, hub: bool = False) -> Path: +def render(name: str, target: Path) -> Path: """Write a world named `name` into `target`, and return it. Refuses an existing directory rather than merging into one: a scaffold is a @@ -120,10 +90,11 @@ def render(name: str, target: Path, *, hub: bool = False) -> Path: "package": package, "seahaven_requirement": seahaven_requirement(), } - substitutions |= _readme_parts(substitutions, hub=hub) try: - for group in (BASE_TEMPLATES, *((HUB_TEMPLATES,) if hub else ())): - _render_group(group, target, package, substitutions) + for relative, text in render_group(BASE_TEMPLATES, package, substitutions).items(): + destination = target / relative + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text(text, encoding="utf-8") except BaseException: # Either a whole world or nothing: a directory holding three of its # seventeen files is worse than no directory at all. @@ -138,44 +109,3 @@ def seahaven_requirement() -> str: if release is None: # pragma: no cover - a version string setuptools cannot produce return "seahaven" return f"seahaven~={release.group(1)}.{release.group(2)}" - - -def _readme_parts(substitutions: dict[str, str], *, hub: bool) -> dict[str, str]: - """The README's hub sections, rendered, or empty strings for a world without `--hub`.""" - if not hub: - return dict.fromkeys(HUB_README_PARTS, "") - root = resources.files(__package__) / "templates" / HUB_README_TEMPLATES - return { - key: Template((root / f"{part}{TEMPLATE_SUFFIX}").read_text(encoding="utf-8")).substitute( - substitutions - ) - for key, part in HUB_README_PARTS.items() - } - - -def _render_group(group: str, target: Path, package: str, substitutions: dict[str, str]) -> None: - root = resources.files(__package__) / "templates" / group - for source, relative in _templates(root, Path()): - destination = target / _destination(relative, package) - destination.parent.mkdir(parents=True, exist_ok=True) - destination.write_text( - Template(source.read_text(encoding="utf-8")).substitute(substitutions), - encoding="utf-8", - ) - - -def _templates(root: Traversable, prefix: Path) -> Iterator[tuple[Traversable, Path]]: - """Every template under `root`, with its path relative to the group.""" - for entry in sorted(root.iterdir(), key=lambda entry: entry.name): - here = prefix / entry.name - if entry.is_dir(): - yield from _templates(entry, here) - elif entry.name.endswith(TEMPLATE_SUFFIX): - yield entry, here - - -def _destination(relative: Path, package: str) -> Path: - """Where a template lands: the suffix dropped, `PACKAGE` renamed.""" - parts = [package if part == PACKAGE_PLACEHOLDER else part for part in relative.parts] - parts[-1] = parts[-1][: -len(TEMPLATE_SUFFIX)] - return Path(*parts) diff --git a/src/seahaven/cli/scaffold.py b/src/seahaven/cli/scaffold.py new file mode 100644 index 0000000..b0a93fb --- /dev/null +++ b/src/seahaven/cli/scaffold.py @@ -0,0 +1,60 @@ +"""The templates `seahaven new` and `seahaven hub` render, and how they are rendered. + +The templates are ordinary files with a `.tmpl` suffix under `templates//`, +substituted with `string.Template` and written out without the suffix. The suffix +is what keeps a directory full of `$package` out of the repository's own ruff, ty +and pytest runs: a `.py` holding `from $package.world import world` is not Python, +and every tool in the tree would have an opinion about it. The package directory +is spelled `PACKAGE` in the template tree for the same reason, and is renamed on +the way out. +""" + +from collections.abc import Iterator, Mapping +from importlib import resources +from importlib.resources.abc import Traversable +from pathlib import Path +from string import Template + +__all__ = ["TEMPLATE_SUFFIX", "render_group", "render_part"] + +TEMPLATE_SUFFIX = ".tmpl" +# The directory the world's package is spelled as inside `templates/`. +PACKAGE_PLACEHOLDER = "PACKAGE" + + +def render_group(group: str, package: str, substitutions: Mapping[str, str]) -> dict[Path, str]: + """Every template in `templates//`, rendered, by the path it is written to.""" + return { + _destination(relative, package): _substitute(source, substitutions) + for source, relative in _templates(_root(group), Path()) + } + + +def render_part(group: str, name: str, substitutions: Mapping[str, str]) -> str: + """One template, `templates//.tmpl`, rendered.""" + return _substitute(_root(group) / f"{name}{TEMPLATE_SUFFIX}", substitutions) + + +def _root(group: str) -> Traversable: + return resources.files(__package__) / "templates" / group + + +def _substitute(source: Traversable, substitutions: Mapping[str, str]) -> str: + return Template(source.read_text(encoding="utf-8")).substitute(substitutions) + + +def _templates(root: Traversable, prefix: Path) -> Iterator[tuple[Traversable, Path]]: + """Every template under `root`, with its path relative to the group.""" + for entry in sorted(root.iterdir(), key=lambda entry: entry.name): + here = prefix / entry.name + if entry.is_dir(): + yield from _templates(entry, here) + elif entry.name.endswith(TEMPLATE_SUFFIX): + yield entry, here + + +def _destination(relative: Path, package: str) -> Path: + """Where a template lands: the suffix dropped, `PACKAGE` renamed.""" + parts = [package if part == PACKAGE_PLACEHOLDER else part for part in relative.parts] + parts[-1] = parts[-1][: -len(TEMPLATE_SUFFIX)] + return Path(*parts) diff --git a/src/seahaven/cli/templates/base/README.md.tmpl b/src/seahaven/cli/templates/base/README.md.tmpl index 7177dad..8ba4566 100644 --- a/src/seahaven/cli/templates/base/README.md.tmpl +++ b/src/seahaven/cli/templates/base/README.md.tmpl @@ -1,4 +1,4 @@ -$space_card# $name +# $name A Seahaven world. @@ -24,7 +24,7 @@ with $package.world.instance(None) as instance: Serve it over OpenEnv with `uv run seahaven serve`, and run its tests with `uv run pytest`. -$hub_connecting## Making a fixture +## Making a fixture `fixtures_src/generate.py` is the recipe for every fixture. Its `build` freezes one from a blank instance on a `fixed` clock, so a rebuild writes the same bytes. From this directory: diff --git a/src/seahaven/docs/index.md b/src/seahaven/docs/index.md index a97fd20..2f1e818 100644 --- a/src/seahaven/docs/index.md +++ b/src/seahaven/docs/index.md @@ -79,14 +79,15 @@ seahaven new # scaffold a world seahaven check # run every lint; do this before a commit seahaven fixture list # every fixture: id, parent, now, description seahaven serve # run this world's server +seahaven hub # add the files for publishing to a hub seahaven mcp # serve this world to one MCP client seahaven docs # print the directory holding these pages ``` -Every command except `new` and `docs` finds the world by convention: the project's package, taken -from `[project] name` in the nearest `pyproject.toml`, exporting an attribute called `world`. Pass -`--world module:attr` to name it yourself. [reference/cli.md](reference/cli.md) has every command -and option. +Every command except `new`, `hub` and `docs` finds the world by convention: the project's package, +taken from `[project] name` in the nearest `pyproject.toml`, exporting an attribute called `world`. +Pass `--world module:attr` to name it yourself. [reference/cli.md](reference/cli.md) has every +command and option. ## Where to go next diff --git a/src/seahaven/docs/reference/cli.md b/src/seahaven/docs/reference/cli.md index 14293cd..1ed7fdf 100644 --- a/src/seahaven/docs/reference/cli.md +++ b/src/seahaven/docs/reference/cli.md @@ -3,7 +3,8 @@ The whole command tree, as a synopsis. Every command is spelled out below it. ```text -seahaven new [--dir ] [--hub] +seahaven new [--dir ] +seahaven hub seahaven check [--world module:attr] seahaven docs seahaven fixture list [--world module:attr] @@ -24,6 +25,7 @@ argparse's own usage error and exits 2. | Command | What it does | |---|---| | [`seahaven new`](#seahaven-new-name) | scaffold a world | +| [`seahaven hub`](#seahaven-hub) | add the files a world needs to publish to a hub | | [`seahaven check`](#seahaven-check) | run every lint | | [`seahaven docs`](#seahaven-docs) | print the bundled docs directory | | [`seahaven fixture list`](#seahaven-fixture-list) | every fixture of this world | @@ -34,10 +36,11 @@ argparse's own usage error and exits 2. ## Finding the world -Every command except `new` and `docs` acts on a world, and finds it the same way: the nearest +Every command except `new`, `hub` and `docs` acts on a world, and finds it the same way: the nearest `pyproject.toml` from the working directory, the package its `[project] name` normalises to, and the attribute `world` on that package. A package that is not importable, or whose `world` is missing or -is not a `seahaven.World`, is an error naming the package and the fix. +is not a `seahaven.World`, is an error naming the package and the fix. `seahaven hub` finds the +project the same way, but reads only `pyproject.toml` and does not import the world. `--world module:attr` overrides the convention for a layout it misses. `--world mypkg.world:world` names the module the `World` is built in, and the lints climb out of it to the package around it, @@ -57,14 +60,13 @@ the generator script. No fixture is built and nothing is imported. | Option | What it does | |---|---| | `--dir ` | the directory to create the world in; the default is here | -| `--hub` | also write the five files `openenv push` requires: `openenv.yaml`, a root `Dockerfile`, a root `__init__.py`, `client.py` and `models.py`; start `README.md` with a Hugging Face Space's settings, and add a section on connecting to the world | The package name is the world's name, normalised. `AGENTS.md` is written once and never touched again. It is an ordinary file the world owns, and it points an authoring agent at these docs. ```sh seahaven new notes -seahaven new notes --dir ~/projects --hub +seahaven new notes --dir ~/projects ``` The command refuses an existing directory rather than merging into one. It also refuses a name that @@ -76,11 +78,35 @@ The command finishes by printing what to do next. While Seahaven is unpublished, that list is a trap: the scaffold's `seahaven~=0.0` resolves to a placeholder release that contains none of the framework, so the sync succeeds and the world fails at import with `ModuleNotFoundError: No module named 'seahaven.world'`. `seahaven check` itself cannot start either, because the -placeholder ships no console script. Install the framework from a checkout until publication. The -same applies to `--hub`: the `Dockerfile` it writes runs `uv sync --extra serve`, which today builds -an image whose container cannot start. +placeholder ships no console script. Install the framework from a checkout until publication. -A `--hub` world differs from the environment `openenv init` makes in three ways: +## `seahaven hub` + +Adds what a world needs to publish to a hub, such as a Hugging Face Space. Run it in a world that +`seahaven new` made: + +```sh +seahaven new notes +cd notes +seahaven hub +``` + +The command writes the five files `openenv push` requires at the world's root: `openenv.yaml`, a +`Dockerfile`, an `__init__.py`, `client.py` and `models.py`. It adds a Space's settings as front +matter at the top of `README.md`, and a section on connecting to the published world after the +`## Using it` section, or at the end of a README that has no such section. A missing `README.md` is +created with both. [Publishing to a hub](../serving_and_openenv.md#publishing-to-a-hub) explains +the settings and the image. + +The command never overwrites. It checks every file first, and if one of the five exists, or +`README.md` already has front matter or the connecting section, it names each one and writes +nothing. It also refuses a world with no `src//openenv_app.py`, because the `Dockerfile` +and `openenv.yaml` serve `.openenv_app:app`. + +While Seahaven is unpublished, the `Dockerfile` does not build a working image: its +`uv sync --extra serve` installs the same placeholder release as `uv sync` does for `seahaven new`. + +A world with the hub files differs from the environment `openenv init` makes in three ways: - `openenv.yaml` has no `validation:` block, so `openenv validate` reports one failure, ``openenv.yaml has no `validation:` block``. The block declares a reward range and a reward diff --git a/src/seahaven/docs/serving_and_openenv.md b/src/seahaven/docs/serving_and_openenv.md index 23960c1..fd89d42 100644 --- a/src/seahaven/docs/serving_and_openenv.md +++ b/src/seahaven/docs/serving_and_openenv.md @@ -29,7 +29,7 @@ learn. A person working against a world by hand has a third command, | [The wire protocol](#the-wire-protocol) | Every frame and model, for a client in any language | | [The concurrency gate](#the-concurrency-gate) | What bounds tool calls, and a known defect in it | | [Running it in production](#running-it-in-production) | Reaping, disconnects, and scaling out | -| [Publishing to a hub](#publishing-to-a-hub) | `seahaven new --hub`, connecting with `AutoEnv.from_hub`, and the image | +| [Publishing to a hub](#publishing-to-a-hub) | `seahaven hub`, connecting with `AutoEnv.from_hub`, and the image | | [Evaluating with Kiln](#evaluating-with-kiln) | Where the scenario and the grader belong | | [Serving one world to an MCP client](#serving-one-world-to-an-mcp-client) | `seahaven mcp`, and who it is for | | [Known problems in OpenEnv](#known-problems-in-openenv) | Routes Seahaven refuses, and why | @@ -561,12 +561,17 @@ Seahaven does not ship keeps OpenEnv's 20 seconds. A world can be published as an OpenEnv environment, on a Hugging Face Space or as a Docker image, and driven by anyone with an OpenEnv client. -`seahaven new --hub` adds the five files `openenv push` validates a directory for: `openenv.yaml`, a -root `Dockerfile`, a root `__init__.py`, `client.py` and `models.py`. It also starts `README.md` -with the settings a Space reads, and adds a section that tells a visitor how to connect. A world -that does not publish to a hub carries none of this. -[`seahaven new`](reference/cli.md#seahaven-new-name) lists where the scaffold differs from -OpenEnv's own. +Run `seahaven hub` in the world's directory to add what publishing needs: + +```sh +seahaven hub +``` + +The command adds the five files `openenv push` validates a directory for: `openenv.yaml`, a root +`Dockerfile`, a root `__init__.py`, `client.py` and `models.py`. It also starts `README.md` with the +settings a Space reads, and adds a section that tells a visitor how to connect. It never replaces +anything already there. A world that does not publish to a hub carries none of this. +[`seahaven hub`](reference/cli.md#seahaven-hub) lists where the result differs from OpenEnv's own. In those settings, `sdk: docker` makes the Space run the image. `app_port: 8000` sends the Space's traffic to the port the server listens on. `base_path: /console` opens the Space's page on the diff --git a/tests/test_cli.py b/tests/test_cli.py index 8c10447..f97ff66 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -26,7 +26,7 @@ def test_the_entry_point_is_declared() -> None: assert data["project"]["scripts"] == {"seahaven": "seahaven.cli:main"} -@pytest.mark.parametrize("name", ["new", "check", "docs", "fixture", "serve", "mcp"]) +@pytest.mark.parametrize("name", ["new", "hub", "check", "docs", "fixture", "serve", "mcp"]) def test_every_subcommand_the_spec_names_is_there(name: str) -> None: with pytest.raises(SystemExit) as raised: build_parser().parse_args([name, "--help"]) diff --git a/tests/test_cli_hub.py b/tests/test_cli_hub.py new file mode 100644 index 0000000..33d41ba --- /dev/null +++ b/tests/test_cli_hub.py @@ -0,0 +1,219 @@ +"""`seahaven hub`: the hub files, added to a world `seahaven new` made. + +`test_hub_scaffold.py` judges the result with OpenEnv's own code. This module is +about the command: what it writes, where the README sections go, and that every +refusal leaves the world exactly as it was. +""" + +from pathlib import Path + +import pytest + +from seahaven.cli import CliError +from seahaven.cli import hub as hub_command +from seahaven.cli.hub import HUB_FILES, README, add_hub_files +from seahaven.cli.new import render +from tests.conftest import run_cli + +pytestmark = pytest.mark.usefixtures("isolated_imports") + +CONNECTING = "## Connecting to the published world" + + +def tree(root: Path) -> dict[str, bytes]: + """Every file under `root` and its bytes: what "nothing was written" is checked against.""" + return { + str(path.relative_to(root)): path.read_bytes() for path in root.rglob("*") if path.is_file() + } + + +@pytest.fixture +def world(tmp_path: Path) -> Path: + return render("my-world", tmp_path / "my-world") + + +def refused(start: Path) -> str: + """The refusal `seahaven hub` gives at `start`, after checking it wrote nothing.""" + before = tree(start) + with pytest.raises(CliError) as raised: + add_hub_files(start) + assert tree(start) == before + return str(raised.value) + + +def test_hub_adds_five_files_and_changes_only_the_readme(world: Path) -> None: + before = tree(world) + root, written = add_hub_files(world) + after = tree(world) + assert root == world.resolve() + assert set(after) - set(before) == set(HUB_FILES) + assert {name for name in before if before[name] != after[name]} == {README} + assert written == [*(Path(name) for name in sorted(HUB_FILES)), Path(README)] + + +def test_the_hub_files_re_export_the_framework(world: Path) -> None: + """Not placeholders: every Seahaven world's client and models are the same.""" + add_hub_files(world) + assert "SeahavenClient as Client" in (world / "client.py").read_text(encoding="utf-8") + models = (world / "models.py").read_text(encoding="utf-8") + assert "SeahavenObservation" in models + assert "SeahavenState" in models + assert "my_world.openenv_app:app" in (world / "Dockerfile").read_text(encoding="utf-8") + assert "name: my-world" in (world / "openenv.yaml").read_text(encoding="utf-8") + + +def test_the_readme_gets_front_matter_on_top_and_connecting_after_usage(world: Path) -> None: + original = (world / README).read_text(encoding="utf-8") + add_hub_files(world) + readme = (world / README).read_text(encoding="utf-8") + lines = readme.splitlines() + assert lines[0] == "---" + assert "title: my-world" in lines + assert lines.index("## Using it") < lines.index(CONNECTING) < lines.index("## Making a fixture") + assert "/my-world" in readme + body = readme.split("\n---\n\n", 1)[1] + before_section, _, after_section = body.partition(CONNECTING) + assert before_section + after_section[after_section.index("## Making a fixture") :] == original + + +def test_a_readme_without_a_usage_section_gets_the_connecting_section_at_the_end( + world: Path, +) -> None: + (world / README).write_text("# my-world\n\nMine.\n", encoding="utf-8") + add_hub_files(world) + readme = (world / README).read_text(encoding="utf-8") + assert readme.split("\n---\n\n", 1)[1].startswith(f"# my-world\n\nMine.\n\n{CONNECTING}\n") + assert readme.endswith("`.\n") + + +def test_a_heading_inside_a_code_fence_does_not_end_the_usage_section(world: Path) -> None: + (world / README).write_text( + "# my-world\n\n## Using it\n\n```sh\n# a comment\n```\n\n## Next\n", encoding="utf-8" + ) + add_hub_files(world) + lines = (world / README).read_text(encoding="utf-8").splitlines() + assert lines.index("# a comment") < lines.index(CONNECTING) < lines.index("## Next") + + +def test_a_missing_readme_is_created_with_both_sections(world: Path) -> None: + (world / README).unlink() + _, written = add_hub_files(world) + assert Path(README) in written + readme = (world / README).read_text(encoding="utf-8") + assert readme.startswith("---\n") + assert "\n# my-world\n" in readme + assert CONNECTING in readme + assert readme.endswith(".\n") + + +def test_a_readme_that_is_not_a_file_is_refused(world: Path) -> None: + (world / README).unlink() + (world / README).mkdir() + assert "README.md is not a file" in refused(world) + + +@pytest.mark.parametrize("name", HUB_FILES) +def test_an_existing_hub_file_refuses_everything(world: Path, name: str) -> None: + (world / name).write_text("mine\n", encoding="utf-8") + message = refused(world) + assert f"{name} exists" in message + assert "wrote nothing" in message + + +def test_existing_front_matter_refuses_everything(world: Path) -> None: + readme = world / README + readme.write_text("---\ntitle: mine\n---\n\n" + readme.read_text(encoding="utf-8")) + assert "README.md already starts with front matter" in refused(world) + + +def test_an_existing_connecting_section_refuses_everything(world: Path) -> None: + readme = world / README + readme.write_text(readme.read_text(encoding="utf-8") + f"\n{CONNECTING}\n\nMine.\n") + assert "'Connecting to the published world' section" in refused(world) + + +def test_every_obstacle_is_named_at_once(world: Path) -> None: + (world / "Dockerfile").write_text("FROM mine\n", encoding="utf-8") + (world / "openenv.yaml").write_text("name: mine\n", encoding="utf-8") + message = refused(world) + assert "Dockerfile exists" in message + assert "openenv.yaml exists" in message + + +def test_a_hub_run_twice_refuses_the_second_time(world: Path) -> None: + add_hub_files(world) + message = refused(world) + assert all(f"{name} exists" in message for name in HUB_FILES) + assert "front matter" in message + + +def test_a_directory_that_is_not_a_project_is_refused(tmp_path: Path) -> None: + message = refused(tmp_path) + assert "no pyproject.toml" in message + assert "--world" not in message + + +def test_a_world_without_its_package_is_refused(world: Path) -> None: + (world / "src" / "my_world").rename(world / "src" / "elsewhere") + assert "where the world's package 'my_world' has to be" in refused(world) + + +def test_a_world_without_its_app_module_is_refused(world: Path) -> None: + (world / "src" / "my_world" / "openenv_app.py").unlink() + assert "my_world.openenv_app:app" in refused(world) + + +def test_a_file_that_cannot_be_written_takes_the_others_with_it( + world: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + rendered = hub_command.render_group + + def with_an_unwritable_file( + group: str, package: str, substitutions: dict[str, str] + ) -> dict[Path, str]: + return {**rendered(group, package, substitutions), Path("missing/file"): ""} + + monkeypatch.setattr(hub_command, "render_group", with_an_unwritable_file) + before = tree(world) + with pytest.raises(FileNotFoundError): + add_hub_files(world) + assert tree(world) == before + + +def test_a_readme_that_cannot_be_written_takes_the_new_files_with_it( + world: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + def refuse(path: Path, *args: object, **kwargs: object) -> int: + raise PermissionError(f"{path} is read-only") + + before = tree(world) + monkeypatch.setattr(Path, "write_text", refuse) + with pytest.raises(PermissionError): + add_hub_files(world) + assert tree(world) == before + + +def test_seahaven_hub_from_inside_the_world( + world: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + """The entry point, from a directory below the root, as `check` finds the world from.""" + monkeypatch.chdir(world / "src" / "my_world") + result = run_cli(capsys, "hub") + assert result.code == 0 + assert result.lines[0] == f"added to {world.resolve()}:" + assert sorted(line.strip() for line in result.lines[1:]) == sorted([*HUB_FILES, README]) + assert all((world / name).is_file() for name in HUB_FILES) + assert not (world / "src" / "my_world" / "Dockerfile").exists() + + +def test_a_refusal_through_the_entry_point_is_one_line_and_exit_one( + world: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + (world / "client.py").write_text("mine\n", encoding="utf-8") + monkeypatch.chdir(world) + result = run_cli(capsys, "hub") + assert result.code == 1 + assert result.out == "" + assert result.err.count("\n") == 1 + assert "client.py exists" in result.err + assert (world / "client.py").read_text(encoding="utf-8") == "mine\n" diff --git a/tests/test_cli_new.py b/tests/test_cli_new.py index 590b60e..e781b76 100644 --- a/tests/test_cli_new.py +++ b/tests/test_cli_new.py @@ -16,8 +16,8 @@ import pytest -from seahaven.cli import CliError -from seahaven.cli.new import HUB_FILES, render, seahaven_requirement +from seahaven.cli import CliError, main +from seahaven.cli.new import render, seahaven_requirement from seahaven.fixtures import load from tests.conftest import CliResult, run_cli @@ -207,23 +207,19 @@ def test_a_name_that_is_not_a_world_name_is_refused(tmp_path: Path, name: str) - assert not (tmp_path / "world").exists() -def test_hub_adds_five_files_and_nothing_else(tmp_path: Path) -> None: - """One world, rendered twice: the difference is exactly what `openenv push` wants.""" - plain = render("hubbed", tmp_path / "plain" / "hubbed") - hubbed = render("hubbed", tmp_path / "hub" / "hubbed", hub=True) - assert files(hubbed) - files(plain) == set(HUB_FILES) - assert files(plain) - files(hubbed) == set() +def test_new_has_no_hub_flag(tmp_path: Path) -> None: + """The hub files are `seahaven hub`'s, run in the world afterwards.""" + with pytest.raises(SystemExit) as raised: + main(["new", "my-world", "--dir", str(tmp_path), "--hub"]) + assert raised.value.code == 2 + assert not (tmp_path / "my-world").exists() -def test_the_hub_files_re_export_the_framework(tmp_path: Path) -> None: - """Not placeholders: every Seahaven world's client and models are the same.""" - hubbed = render("hubbed", tmp_path / "hub" / "hubbed", hub=True) - assert "SeahavenClient as Client" in (hubbed / "client.py").read_text(encoding="utf-8") - models = (hubbed / "models.py").read_text(encoding="utf-8") - assert "SeahavenObservation" in models - assert "SeahavenState" in models - assert "hubbed.openenv_app:app" in (hubbed / "Dockerfile").read_text(encoding="utf-8") - assert "name: hubbed" in (hubbed / "openenv.yaml").read_text(encoding="utf-8") +def test_a_world_without_the_hub_files_has_no_hub_readme_sections(scaffold: Path) -> None: + readme = (scaffold / "README.md").read_text(encoding="utf-8") + assert readme.startswith("# my-world\n") + assert "`uv run pytest`.\n\n## Making a fixture" in readme + assert "from_hub" not in readme def test_new_prints_the_next_steps( @@ -235,6 +231,7 @@ def test_new_prints_the_next_steps( assert "cd my-world" in result.out assert "uv run pytest" in result.out assert "uv run seahaven check" in result.out + assert "uv run seahaven hub" in result.out def test_new_writes_into_the_directory_it_is_given( diff --git a/tests/test_hub_scaffold.py b/tests/test_hub_scaffold.py index 08a813b..6b86a63 100644 --- a/tests/test_hub_scaffold.py +++ b/tests/test_hub_scaffold.py @@ -1,6 +1,6 @@ -"""A `seahaven new --hub` scaffold, judged by OpenEnv's own code. +"""A world after `seahaven new` and `seahaven hub`, judged by OpenEnv's own code. -`test_cli_new.py` asserts what the scaffold contains. This module asserts what +`test_cli_hub.py` asserts what `seahaven hub` writes. This module asserts what upstream does with it: `openenv validate`, the structure check `openenv push` runs, the `app:` field and Dockerfile `CMD` the container providers read, the front matter a Hugging Face Space reads, and the consumer call the docs name, @@ -17,6 +17,7 @@ import pytest +from seahaven.cli.hub import add_hub_files from seahaven.cli.new import render pytest.importorskip( @@ -48,8 +49,9 @@ @pytest.fixture def published(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: - """A hub scaffold, importable the way `uv sync` makes it inside the image.""" - root = render(WORLD, tmp_path / WORLD, hub=True) + """A world with the hub files, importable the way `uv sync` makes it inside the image.""" + root = render(WORLD, tmp_path / WORLD) + add_hub_files(root) monkeypatch.syspath_prepend(str(root / "src")) return root diff --git a/tests/test_packaging.py b/tests/test_packaging.py index 4ed00ad..bde7612 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -31,7 +31,8 @@ import pytest -from seahaven.cli.new import TEMPLATE_SUFFIX, render +from seahaven.cli.new import render +from seahaven.cli.scaffold import TEMPLATE_SUFFIX from tests.test_docs import PAGES REPO_ROOT = Path(__file__).resolve().parents[1] From bc90c96918d8662d8c812b8d129e5c34efcebda4 Mon Sep 17 00:00:00 2001 From: scosman Date: Fri, 25 Sep 2026 03:23:16 +0000 Subject: [PATCH 3/4] Stop a hub world's root __init__.py from shadowing its package under pytest `seahaven hub` writes an `__init__.py` at the world's root because `openenv push` requires one. When the directory has the package's name (any one-word world), pytest collected the root as a package and imported that file as the world's package, so every test of a fresh `seahaven new demo` + `seahaven hub` scaffold failed with "demo has no 'world'". - The pytest plugin collects a directory holding an `__init__.py` and `src//` as a plain directory (optionalhook, so pytest 7 runs are unaffected). - `find_world` recognises the root `__init__.py` standing in for the package, which a `tests/__init__.py` or a root `conftest.py` still causes, and fails with a one-line message naming both causes and the fixes. - testing.md states the exception and the one layout rule. - Tests: the scaffold's own pytest after `seahaven new demo` and `seahaven hub`, pytester cases for the shadowing, the collector chosen per layout and both causes of the new message; the scaffold subprocess run is a shared helper. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QWJSvm3Hep8BHqyKWBkKvp --- src/seahaven/cli/__init__.py | 20 +++++++++ src/seahaven/docs/testing.md | 7 ++- src/seahaven/pytest_plugin.py | 21 ++++++++- tests/conftest.py | 23 +++++++++- tests/test_cli_hub.py | 15 ++++++- tests/test_cli_new.py | 14 +----- tests/test_pytest_plugin.py | 85 +++++++++++++++++++++++++++++++++++ 7 files changed, 166 insertions(+), 19 deletions(-) diff --git a/src/seahaven/cli/__init__.py b/src/seahaven/cli/__init__.py index 3545ea1..dd09f66 100644 --- a/src/seahaven/cli/__init__.py +++ b/src/seahaven/cli/__init__.py @@ -224,6 +224,8 @@ def _import(module_name: str, attribute: str, *, root: Path) -> Discovery: imported = frozenset(sys.modules) world = getattr(module, attribute, None) if not isinstance(world, World): + if _is_the_project_roots_init(module, root): + raise CliError(_root_init_imported(module_name, root), code="SH501", path=root) raise CliError(_no_world(module_name, attribute, world), code="SH501", path=root) package = _package_of(module) if package is None: @@ -265,6 +267,24 @@ def _no_world(module_name: str, attribute: str, found: object) -> str: ) +def _is_the_project_roots_init(module: ModuleType, root: Path) -> bool: + """Whether `module` is the `__init__.py` `seahaven hub` writes at the project root.""" + file = getattr(module, "__file__", None) + return file is not None and Path(file).resolve() == root / "__init__.py" + + +def _root_init_imported(module_name: str, root: Path) -> str: + # pytest names a module after every package above it: a test module in a + # `tests/` that has an `__init__.py`, and a `conftest.py` beside the root's + # `__init__.py`, both import that `__init__.py` as `module_name`. + return ( + f"{module_name} was imported from {root / '__init__.py'} and not from " + f"{SOURCE_DIRNAME}/{module_name}/; under pytest a tests/__init__.py or a conftest.py " + f"beside that file does this: delete tests/__init__.py, move conftest.py into tests/, " + f"or rename the directory {root.name} so it is not the package's name" + ) + + def _last_traceback_line(error: BaseException) -> str: """The exception as Python would print it, without the frames above it.""" return traceback.format_exception_only(error)[-1].strip() diff --git a/src/seahaven/docs/testing.md b/src/seahaven/docs/testing.md index a5962de..ced91e9 100644 --- a/src/seahaven/docs/testing.md +++ b/src/seahaven/docs/testing.md @@ -84,8 +84,11 @@ uv run pytest --seahaven-world mypackage:world `--seahaven-world module:attr` is the plugin's namespace-qualified version of the CLI's `--world`, for a layout the convention misses. Everything else — `-k`, `-x`, markers of your own — is ordinary -pytest. The plugin adds two fixtures, one marker and that one option, and does nothing at all to a -run that does not use them. +pytest. The plugin adds two fixtures, one marker and that one option. It does not change a run that +does not use them, with one exception: pytest collects a directory that holds an `__init__.py` and +`src//` as a plain directory, so that in a world `seahaven hub` has been run +on, the `world` fixture gets the package in `src/`. Such a world must not have a +`tests/__init__.py` or a `conftest.py` at its root; put a `conftest.py` in `tests/`. ## What is worth testing in a world diff --git a/src/seahaven/pytest_plugin.py b/src/seahaven/pytest_plugin.py index 31e18d7..70cc42e 100644 --- a/src/seahaven/pytest_plugin.py +++ b/src/seahaven/pytest_plugin.py @@ -1,8 +1,11 @@ """The pytest fixtures a world's own tests are written against. Registered through the `pytest11` entry point, so installing `seahaven` activates -it in any project and nothing has to be added to a `conftest.py`. It does nothing -to a run that does not use its fixtures: two fixtures, one marker and one option. +it in any project and nothing has to be added to a `conftest.py`. It adds two +fixtures, one marker and one option, and does nothing to a run that does not use +them, with one exception: a directory that holds an `__init__.py` and +`src//` is collected as a plain directory and not as a +package, as in a world `seahaven hub` has been run on. A world's test says which fixture it starts from and nothing else:: @@ -31,6 +34,7 @@ def test_an_issue_can_be_created(instance: seahaven.Instance) -> None: """ from collections.abc import Iterator +from pathlib import Path from typing import Any import pytest @@ -82,6 +86,19 @@ def pytest_configure(config: pytest.Config) -> None: config.addinivalue_line("markers", _MARKER_SIGNATURE) +@pytest.hookimpl(tryfirst=True, optionalhook=True) +def pytest_collect_directory(path: Path, parent: pytest.Collector) -> pytest.Collector | None: + """A directory holding an `__init__.py` and `src/` is a directory to pytest, + not a package.""" + # optionalhook: pytest 7 has no pytest_collect_directory, and an unknown hook fails every run. + # `openenv push` requires an `__init__.py` at a hub world's root. Collected as a + # package, pytest imports that file under the directory's name, and when that + # is the world's package name `import_module` returns it instead of `src/`. + if (path / "__init__.py").is_file() and (path / "src" / path.name).is_dir(): + return pytest.Dir.from_parent(parent, path=path) + return None + + @pytest.fixture(scope="session") def world(request: pytest.FixtureRequest) -> World: """The project's `World`: one import, one object, for the whole session. diff --git a/tests/conftest.py b/tests/conftest.py index b257065..3ce2d54 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,6 +9,8 @@ """ import copy +import os +import subprocess import sys import tempfile import threading @@ -403,9 +405,26 @@ def run_cli(capsys: pytest.CaptureFixture[str], *argv: str) -> CliResult: In process rather than as a subprocess: `main` is the entry point, its return value is the exit code, and a subprocess would test the console script the - installer writes instead of anything here. The one test that does use a - subprocess is the scaffold's own `pytest` run, which has to be one. + installer writes instead of anything here. The tests that do use a subprocess + run a scaffold's own `pytest`, which has to be one: `assert_scaffold_pytest_passes`. """ code = main(list(argv)) captured = capsys.readouterr() return CliResult(code=code, out=captured.out, err=captured.err) + + +def assert_scaffold_pytest_passes(root: Path) -> None: + """A scaffold's own `pytest`, in a subprocess because a second pytest cannot run in this one. + + `src` goes on `PYTHONPATH` in place of the editable install `uv sync` makes. + """ + finished = subprocess.run( + [sys.executable, "-m", "pytest", "-q"], + cwd=root, + env={**os.environ, "PYTHONPATH": str(root / "src")}, + capture_output=True, + text=True, + timeout=300, + check=False, + ) + assert finished.returncode == 0, finished.stdout + finished.stderr diff --git a/tests/test_cli_hub.py b/tests/test_cli_hub.py index 33d41ba..f6b2353 100644 --- a/tests/test_cli_hub.py +++ b/tests/test_cli_hub.py @@ -13,7 +13,7 @@ from seahaven.cli import hub as hub_command from seahaven.cli.hub import HUB_FILES, README, add_hub_files from seahaven.cli.new import render -from tests.conftest import run_cli +from tests.conftest import assert_scaffold_pytest_passes, run_cli pytestmark = pytest.mark.usefixtures("isolated_imports") @@ -217,3 +217,16 @@ def test_a_refusal_through_the_entry_point_is_one_line_and_exit_one( assert result.err.count("\n") == 1 assert "client.py exists" in result.err assert (world / "client.py").read_text(encoding="utf-8") == "mine\n" + + +def test_pytest_passes_on_a_world_named_like_its_directory_after_hub( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + """A one-word name makes the directory and the package the same name, `demo`, + and the root `__init__.py` the hub files add is then a package `demo` too.""" + monkeypatch.chdir(tmp_path) + assert run_cli(capsys, "new", "demo").code == 0 + monkeypatch.chdir(tmp_path / "demo") + assert run_cli(capsys, "hub").code == 0 + assert (tmp_path / "demo" / "__init__.py").is_file() + assert_scaffold_pytest_passes(tmp_path / "demo") diff --git a/tests/test_cli_new.py b/tests/test_cli_new.py index e781b76..abca79e 100644 --- a/tests/test_cli_new.py +++ b/tests/test_cli_new.py @@ -19,7 +19,7 @@ from seahaven.cli import CliError, main from seahaven.cli.new import render, seahaven_requirement from seahaven.fixtures import load -from tests.conftest import CliResult, run_cli +from tests.conftest import CliResult, assert_scaffold_pytest_passes, run_cli pytestmark = pytest.mark.usefixtures("isolated_imports") @@ -131,17 +131,7 @@ def test_check_passes_on_a_fresh_scaffold( def test_pytest_passes_on_a_fresh_scaffold(scaffold: Path) -> None: - """A subprocess, because a second pytest cannot run inside this one.""" - finished = subprocess.run( - [sys.executable, "-m", "pytest", "-q"], - cwd=scaffold, - env={**os.environ, "PYTHONPATH": str(scaffold / "src")}, - capture_output=True, - text=True, - timeout=300, - check=False, - ) - assert finished.returncode == 0, finished.stdout + finished.stderr + assert_scaffold_pytest_passes(scaffold) def test_the_readme_builds_the_first_fixture(scaffold: Path) -> None: diff --git a/tests/test_pytest_plugin.py b/tests/test_pytest_plugin.py index d0c7d6d..3135bee 100644 --- a/tests/test_pytest_plugin.py +++ b/tests/test_pytest_plugin.py @@ -590,6 +590,91 @@ def test_it(world): assert "Traceback" not in result.stdout.str() +def write_test_module(pytester: pytest.Pytester, source: str) -> None: + """A test module in `tests/`, where a scaffold keeps them, and not at the root. + + At the root, a module beside an `__init__.py` is named after the root's + package in any case, which is the scaffold's layout no more than it is a + test of how the root is collected. + """ + tests = pytester.mkdir("tests") + (tests / "test_it.py").write_text(inspect.cleandoc(source) + "\n", encoding="utf-8") + + +def test_a_root_init_named_like_the_package_does_not_stand_in_for_it( + pytester: pytest.Pytester, +) -> None: + """`seahaven hub` adds an `__init__.py` at the root, and a one-word world's + directory has its package's name: the `world` fixture must still get `src/`'s.""" + package = pytester.path.name + root = write_world(pytester, package=package) + (root / "__init__.py").write_text( + "# Present because `openenv push` requires it.\n", encoding="utf-8" + ) + write_test_module( + pytester, + f""" + def test_it(world): + assert world.name == "{package}" + """, + ) + pytester.runpytest().assert_outcomes(passed=1) + + +@pytest.mark.parametrize("module_under_the_root", ["tests/__init__.py", "conftest.py"]) +def test_a_module_named_after_the_root_package_fails_with_the_fix( + pytester: pytest.Pytester, module_under_the_root: str +) -> None: + """pytest names `tests/test_it.py` `.tests.test_it` and a root + `conftest.py` `.conftest`, so either one imports the root's + `__init__.py` as the package whatever the plugin collects.""" + package = pytester.path.name + root = write_world(pytester, package=package) + (root / "__init__.py").write_text("", encoding="utf-8") + write_test_module( + pytester, + """ + def test_it(world): + assert world is not None + """, + ) + (root / module_under_the_root).write_text("", encoding="utf-8") + result = pytester.runpytest() + result.assert_outcomes(errors=1) + result.stdout.fnmatch_lines( + [f"*{package} was imported from {root.resolve() / '__init__.py'} and not from src/*"] + ) + result.stdout.fnmatch_lines( + ["*delete tests/__init__.py, move conftest.py into tests/, or rename the directory*"] + ) + + +@pytest.mark.parametrize( + ("root_init", "package_named_like_root", "collector"), + [ + (False, True, "Dir"), + (True, False, "Package"), + (True, True, "Dir"), + ], + ids=["no-root-init", "root-init-beside-another-package", "hub-world"], +) +def test_only_a_root_init_beside_a_package_of_its_name_changes_how_the_root_is_collected( + pytester: pytest.Pytester, root_init: bool, package_named_like_root: bool, collector: str +) -> None: + root = write_world(pytester, package=pytester.path.name if package_named_like_root else "x") + if root_init: + (root / "__init__.py").write_text("", encoding="utf-8") + write_test_module( + pytester, + """ + def test_it(): + assert True + """, + ) + result = pytester.runpytest("--collect-only") + result.stdout.fnmatch_lines([f"<{collector} {root.name}>"]) + + def test_the_marker_is_registered(pytester: pytest.Pytester) -> None: """`--strict-markers` is what a world's CI runs; an unregistered marker fails it.""" write_world(pytester) From 7c00046adf082457dae492bac202bea87dab9cd9 Mon Sep 17 00:00:00 2001 From: scosman Date: Thu, 8 Oct 2026 20:54:55 +0000 Subject: [PATCH 4/4] Make the hub Dockerfile template build on openenv-base and run as non-root The image `seahaven hub` generates could not build, and could not run as the user a Hugging Face Space runs it as: - openenv-base:latest ships uv 0.5.27, which can download no final CPython 3.14, so `uv sync` stopped with "No interpreter found for Python >=3.14". Copy in uv 0.12.18 at an exact version before the sync. - A Space runs the container as uid 1000. uv installed CPython under /root (mode 700), so the venv's interpreter was unreadable, and `uv run` in the CMD could neither write a cache nor sync the root-owned venv. Install CPython under /opt/uv/python, and start the server with `python -m uvicorn`, the way OpenEnv's Modal and Daytona providers do. - The HEALTHCHECK ran a bare `python`, which fell through to the base image's 3.11 and passed on a broken image. It now runs the venv's interpreter by path. Tests pin each fix: an exact uv of at least 0.9.0 is copied before the sync, the install dir is absolute, outside /root and set before the sync, the CMD does not start with uv, and the HEALTHCHECK uses /.venv/bin/python. The docs say a Space runs the image as user 1000, which cannot write /app. Verified by host emulation, not a docker build: uv 0.5.27 fails the sync; a root build into /app with uv 0.12.24 and the template's env serves as uid 1000 (HOME=/ and unset): /health, /console and /web/ return 200, the HEALTHCHECK passes, and an AutoEnv drive creates and reads an item. The emulation used uv 0.12.24; the template pins 0.12.18, the uv a real Hugging Face build used. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QWJSvm3Hep8BHqyKWBkKvp --- .../cli/templates/hub/Dockerfile.tmpl | 19 +++++- src/seahaven/docs/serving_and_openenv.md | 3 + tests/test_hub_scaffold.py | 62 +++++++++++++++++-- 3 files changed, 77 insertions(+), 7 deletions(-) diff --git a/src/seahaven/cli/templates/hub/Dockerfile.tmpl b/src/seahaven/cli/templates/hub/Dockerfile.tmpl index c461fd0..7a0734b 100644 --- a/src/seahaven/cli/templates/hub/Dockerfile.tmpl +++ b/src/seahaven/cli/templates/hub/Dockerfile.tmpl @@ -2,18 +2,33 @@ # Dockerfile is what lets one be pushed without a server/ directory. FROM ghcr.io/huggingface/openenv-base:latest +# The base image's uv is 0.5.27, which can download no final CPython 3.14, so +# `uv sync` would stop with "No interpreter found for Python >=3.14". uv releases +# often and a release can change what it resolves or the CPython it installs, so +# this is an exact version: a rebuild of the Space gets the same uv. +COPY --from=ghcr.io/astral-sh/uv:0.12.18 /uv /uvx /usr/local/bin/ + WORKDIR /app COPY . /app +# A Hugging Face Space runs the container as uid 1000, and the build runs as root. +# uv's default directory for the CPython it downloads is under /root, which is mode +# 700, so the venv's interpreter would not be readable at run time. +ENV UV_PYTHON_INSTALL_DIR=/opt/uv/python + # The serve extra: this image runs the OpenEnv server, which the in-process world # does not need. RUN uv sync --extra serve # OpenEnv's container providers start the server as `python -m uvicorn `, # with `app` from openenv.yaml, so `python` has to be this world's interpreter. +# The CMD starts it the same way, because `uv run` as uid 1000 can neither write a +# cache nor sync the root-owned venv. ENV PATH="/app/.venv/bin:$$PATH" EXPOSE 8000 +# The venv's interpreter by path: a bare `python` falls through to the base image's +# when the venv's is unreadable, and the check would pass on an image that cannot serve. HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 CMD \ - python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" -CMD ["uv", "run", "uvicorn", "$package.openenv_app:app", "--host", "0.0.0.0", "--port", "8000"] + /app/.venv/bin/python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" +CMD ["python", "-m", "uvicorn", "$package.openenv_app:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/src/seahaven/docs/serving_and_openenv.md b/src/seahaven/docs/serving_and_openenv.md index 8f646ae..7b1d409 100644 --- a/src/seahaven/docs/serving_and_openenv.md +++ b/src/seahaven/docs/serving_and_openenv.md @@ -602,6 +602,9 @@ The image serves `.openenv_app:app` on port 8000, and answers its `HEAL `/health`. The `app` key in `openenv.yaml` names the same app, because OpenEnv's Modal and Daytona providers start the server from that key rather than from the `Dockerfile`. +A Hugging Face Space runs the image as user 1000, not as root. The server can read what the build +installed, but it cannot write to `/app` or to anything else the build made. + **The image is a checkout, and has to stay one.** The generated `Dockerfile` does `COPY . /app` and then `uv sync`, so the container holds the world's whole directory with the framework installed into its environment. Replace those two lines with a plain install of the world and the image builds, the diff --git a/tests/test_hub_scaffold.py b/tests/test_hub_scaffold.py index 6b86a63..167822a 100644 --- a/tests/test_hub_scaffold.py +++ b/tests/test_hub_scaffold.py @@ -121,15 +121,67 @@ def test_the_healthcheck_asks_a_route_the_server_has( get(url + probe[2]) -def test_the_worlds_interpreter_is_first_on_the_images_path(published: Path) -> None: - """The providers run `python -m uvicorn `; `uv sync` puts the venv in the workdir.""" - instructions = [ +def instructions(published: Path) -> list[list[str]]: + """Each top-level Dockerfile instruction as `[keyword, argument]`, in order.""" + return [ line.split(maxsplit=1) for line in (published / "Dockerfile").read_text(encoding="utf-8").splitlines() if line and not line.startswith((" ", "#")) ] - workdir = next(argument for keyword, argument in instructions if keyword == "WORKDIR") - assert ["ENV", f'PATH="{workdir}/.venv/bin:$PATH"'] in instructions + + +def workdir(published: Path) -> str: + return next(argument for keyword, argument in instructions(published) if keyword == "WORKDIR") + + +def position(published: Path, keyword: str, argument_start: str) -> int: + """The index of the one instruction with this keyword whose argument starts so.""" + [index] = [ + index + for index, (instruction, argument) in enumerate(instructions(published)) + if instruction == keyword and argument.startswith(argument_start) + ] + return index + + +def test_the_worlds_interpreter_is_first_on_the_images_path(published: Path) -> None: + """The providers run `python -m uvicorn `; `uv sync` puts the venv in the workdir.""" + assert ["ENV", f'PATH="{workdir(published)}/.venv/bin:$PATH"'] in instructions(published) + + +def test_the_sync_runs_with_a_uv_that_can_install_cpython_3_14(published: Path) -> None: + """The base image's uv 0.5.27 knows no final 3.14; uv 0.9.0 was the first that did.""" + copy = position(published, "COPY", "--from=ghcr.io/astral-sh/uv:") + pinned = re.fullmatch( + r"--from=ghcr\.io/astral-sh/uv:(\d+)\.(\d+)\.(\d+) /uv /uvx /usr/local/bin/", + instructions(published)[copy][1], + ) + assert pinned is not None, "uv is not copied in at an exact version" + assert tuple(int(part) for part in pinned.groups()) >= (0, 9, 0) + assert copy < position(published, "RUN", "uv sync") + + +def test_the_sync_installs_cpython_where_a_non_root_user_can_read_it(published: Path) -> None: + """A Space runs the image as uid 1000, and uv's default directory is under /root.""" + setting = position(published, "ENV", "UV_PYTHON_INSTALL_DIR=") + directory = instructions(published)[setting][1].removeprefix("UV_PYTHON_INSTALL_DIR=") + assert directory.startswith("/") and not Path(directory).is_relative_to("/root") + assert setting < position(published, "RUN", "uv sync") + + +def test_the_server_starts_without_uv(published: Path) -> None: + """`uv run` as uid 1000 cannot write a cache or sync the root-owned venv.""" + command = parse_dockerfile_cmd((published / "Dockerfile").read_text(encoding="utf-8")) + assert command is not None + assert command.split()[:3] == ["python", "-m", "uvicorn"] + + +def test_the_healthcheck_runs_the_worlds_own_interpreter(published: Path) -> None: + """A bare `python` falls through to the base image's, and passes on a broken image.""" + dockerfile = (published / "Dockerfile").read_text(encoding="utf-8") + probe = re.search(r"^HEALTHCHECK .*? CMD[\s\\]+(\S+) -c ", dockerfile, re.M | re.S) + assert probe is not None, "the HEALTHCHECK does not run a Python interpreter" + assert probe[1] == f"{workdir(published)}/.venv/bin/python" def test_the_space_card_points_hugging_face_at_the_server(