diff --git a/src/seahaven/cli/__init__.py b/src/seahaven/cli/__init__.py index 3682de0..66642b6 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" @@ -118,11 +128,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 @@ -176,24 +186,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")) @@ -201,10 +214,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: @@ -229,6 +240,16 @@ 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, + fix=( + "delete tests/__init__.py, move conftest.py into tests/, or rename the " + "project directory" + ), + ) raise CliError( _no_world(module_name, attribute, world), code="SH501", @@ -275,6 +296,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 _import_failure(module_name: str, error: Exception, root: Path) -> CliError: """SH501 for an import that raised, placed at the line of the world's code that raised it.""" summary = _last_traceback_line(error) 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 9dc920d..09cb3f8 100644 --- a/src/seahaven/cli/new.py +++ b/src/seahaven/cli/new.py @@ -1,16 +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 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,29 +16,16 @@ import re import shutil import sys -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" - -# 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. -HUB_FILES = ("Dockerfile", "__init__.py", "client.py", "models.py", "openenv.yaml") # `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 @@ -63,28 +44,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 @@ -123,8 +100,10 @@ def render(name: str, target: Path, *, hub: bool = False) -> Path: "seahaven_requirement": seahaven_requirement(), } 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. @@ -139,31 +118,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 _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/hub/Dockerfile.tmpl b/src/seahaven/cli/templates/hub/Dockerfile.tmpl index 96c5b0e..7a0734b 100644 --- a/src/seahaven/cli/templates/hub/Dockerfile.tmpl +++ b/src/seahaven/cli/templates/hub/Dockerfile.tmpl @@ -2,12 +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 -CMD ["uv", "run", "uvicorn", "$package.openenv_app:app", "--host", "0.0.0.0", "--port", "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 \ + /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/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/index.md b/src/seahaven/docs/index.md index e3b94c2..c0ef980 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 d144b4f..bd3ce51 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` | 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 @@ -75,6 +77,42 @@ directory is written. A name whose package has the same name as a standard libra The command finishes by printing what to do next. +## `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`. + +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 + 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 4c7ae5a..7b1d409 100644 --- a/src/seahaven/docs/serving_and_openenv.md +++ b/src/seahaven/docs/serving_and_openenv.md @@ -31,7 +31,7 @@ A world that copies a product's HTTP API can also serve that API as a test serve | [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 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 | @@ -547,13 +547,63 @@ 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`. +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 +[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 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`. + +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 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 c1fe752..23cef64 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 f9ca7c3..4d43f9c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,7 +9,9 @@ """ import copy +import os import shutil +import subprocess import sys import tempfile import threading @@ -412,14 +414,31 @@ 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 + + # Where `broken_tidy` appends its line, relative to the project it copies. BROKEN_MODULE = Path("src/tidy/tools/notes.py") 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..cfe4895 --- /dev/null +++ b/tests/test_cli_hub.py @@ -0,0 +1,243 @@ +"""`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 assert_scaffold_pytest_passes, 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_hub_container_serves_on_every_interface(world: Path) -> None: + """The server binds loopback by default, so the container has to say otherwise itself.""" + add_hub_files(world) + command = next( + line + for line in (world / "Dockerfile").read_text(encoding="utf-8").splitlines() + if line.startswith("CMD ") + ) + assert '"--host", "0.0.0.0"' in command + + +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" + + +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 e4a3c52..5123ca2 100644 --- a/tests/test_cli_new.py +++ b/tests/test_cli_new.py @@ -18,10 +18,10 @@ import pytest from packaging.requirements import Requirement -from seahaven.cli import CliError -from seahaven.cli.new import HUB_FILES, render, seahaven_requirement +from seahaven.cli import CliError, build_parser +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") @@ -199,17 +199,13 @@ def build_as_the_readme_says(scaffold: Path) -> None: def test_pytest_passes_on_a_fresh_scaffold(scaffold: Path) -> None: - """A subprocess, because a second pytest cannot run inside this one.""" - finished = python_in(scaffold, "-m", "pytest", "-q") - assert finished.returncode == 0, finished.stdout + finished.stderr + assert_scaffold_pytest_passes(scaffold) def test_pytest_passes_after_the_readme_builds_the_first_fixture(scaffold: Path) -> None: """The README's first step is a `build`, and the tests have to survive it.""" build_as_the_readme_says(scaffold) - - finished = python_in(scaffold, "-m", "pytest", "-q") - assert finished.returncode == 0, finished.stdout + finished.stderr + assert_scaffold_pytest_passes(scaffold) def test_the_readme_builds_the_first_fixture(scaffold: Path) -> None: @@ -264,34 +260,17 @@ 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_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_the_hub_container_serves_on_every_interface(tmp_path: Path) -> None: - """The server binds loopback by default, so the container has to say otherwise itself.""" - hubbed = render("hubbed", tmp_path / "hub" / "hubbed", hub=True) - command = next( - line - for line in (hubbed / "Dockerfile").read_text(encoding="utf-8").splitlines() - if line.startswith("CMD ") - ) - assert '"--host", "0.0.0.0"' in command +def test_new_has_no_hub_option() -> None: + """The hub files are `seahaven hub`'s, run in the world afterwards.""" + parsed = build_parser().parse_args(["new", "my-world"]) + assert set(vars(parsed)) == {"command", "name", "dir", "handler"} + + +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( @@ -303,6 +282,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_find_world.py b/tests/test_find_world.py index 714214e..ce8a0db 100644 --- a/tests/test_find_world.py +++ b/tests/test_find_world.py @@ -127,6 +127,23 @@ def test_a_package_with_no_world_attribute_names_the_fix() -> None: assert raised.value.code == "SH501" +def test_a_root_init_imported_as_the_package_names_the_fix( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """What pytest does with a `tests/__init__.py` in a world `seahaven hub` has been run in.""" + root = tmp_path / "rootinit" + root.mkdir() + (root / "pyproject.toml").write_text('[project]\nname = "rootinit"\n', encoding="utf-8") + (root / "__init__.py").write_text("", encoding="utf-8") + monkeypatch.syspath_prepend(str(tmp_path)) + with pytest.raises(CliError) as raised: + discover(None, root) + error = raised.value + assert error.code == "SH501" + assert f"rootinit was imported from {root.resolve() / '__init__.py'}" in error.message + assert error.fix is not None and "delete tests/__init__.py" in error.fix + + def test_an_attribute_that_is_not_a_world_is_refused() -> None: with pytest.raises(CliError) as raised: discover("no_world:NOT_A_WORLD", WORLDS / "no_world") diff --git a/tests/test_hub_scaffold.py b/tests/test_hub_scaffold.py new file mode 100644 index 0000000..167822a --- /dev/null +++ b/tests/test_hub_scaffold.py @@ -0,0 +1,227 @@ +"""A world after `seahaven new` and `seahaven hub`, judged by OpenEnv's own code. + +`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, +`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.hub import add_hub_files +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 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 + + +@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 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((" ", "#")) + ] + + +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( + 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 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] diff --git a/tests/test_pytest_plugin.py b/tests/test_pytest_plugin.py index c3b68ee..2d9a69e 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_a_world_that_does_not_import_fails_with_its_file_and_line( pytester: pytest.Pytester, ) -> None: