Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 54 additions & 15 deletions src/seahaven/cli/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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="<command>")
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

Expand Down Expand Up @@ -176,35 +186,36 @@ 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"))
except (OSError, tomllib.TOMLDecodeError) as error:
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:
Expand All @@ -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",
Expand Down Expand Up @@ -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)
Expand Down
165 changes: 165 additions & 0 deletions src/seahaven/cli/hub.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading