2 Commits

Author SHA1 Message Date
CIAgent a6c5a1bb4f docs(P01): complete cli-skeleton phase
---ci---
phase: 1
milestone: v0.1
status: complete
requirements:
  covered: [REQ-05]
  partial: [REQ-01]
---/ci---
2026-10-14 22:38:14 +00:00
CIAgent 3e86118383 chore(P00): checkpoint — phase 0 complete, shipped v0.1.0
---ci---
phase: 0
milestone: v0.1
status: complete
---/ci---
2026-10-14 22:13:36 +00:00
11 changed files with 1231 additions and 3 deletions
+3 -3
View File
@@ -1,8 +1,8 @@
{
"phase": 0,
"phase": 1,
"stage": "ship_pending",
"milestone": "v0.1",
"phase_role": "pre_execution",
"phase_role": "execution",
"attempts": 0,
"updated_at": "2026-10-14T23:00:00Z"
"updated_at": "2026-10-15T00:10:00Z"
}
+25
View File
@@ -0,0 +1,25 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "nova-cli"
version = "0.1.0"
requires-python = ">=3.10"
dependencies = [
"click>=8.1",
"PyYAML>=6.0",
]
[project.scripts]
nova = "nova.cli:main"
[tool.setuptools.packages.find]
where = ["src"]
[tool.pytest.ini_options]
testpaths = ["tests"]
[tool.mypy]
python_version = "3.10"
ignore_missing_imports = true
+3
View File
@@ -0,0 +1,3 @@
"""nova - Docker-style ergonomics for infrastructure deployment."""
__version__ = "0.1.0"
+5
View File
@@ -0,0 +1,5 @@
"""nova.cli - click command surface and console entry point."""
from nova.cli.main import main
__all__ = ["main"]
+151
View File
@@ -0,0 +1,151 @@
"""Compose-style config file discovery and env-file loading (task P01-01-03).
Discovery rules mirror docker compose (verified against the compose-spec
research in .ciagent/RESEARCH.md, area 3):
1. explicit ``-f`` files — the first is the primary file, but every listed
path must exist;
2. the ``NOVA_COMPOSE_FILE`` environment variable — ``:``-separated paths,
validated like ``-f`` (the ``COMPOSE_FILE`` analog);
3. an upward directory search — walk from the working directory to the
filesystem root, checking ``compose.yaml`` before ``compose.yml`` in
each directory.
``find_novafile`` mirrors ``docker build`` instead: the Novafile is only
looked up in the working directory or at an explicitly given path, never
found by an upward search (Dockerfile parity).
All functions are pure: they take ``pathlib.Path`` arguments, read the
``NOVA_COMPOSE_FILE`` environment variable only as the documented
discovery input, and keep no global state.
"""
import os
from collections.abc import Sequence
from pathlib import Path
#: Environment variable holding ``:``-separated compose file paths.
NOVA_COMPOSE_FILE_ENV = "NOVA_COMPOSE_FILE"
#: Candidate compose file names, in per-directory preference order.
COMPOSE_FILE_NAMES: tuple[str, ...] = ("compose.yaml", "compose.yml")
#: Default build recipe file name (Dockerfile parity).
NOVAFILE_NAME = "Novafile"
class NovaFileError(RuntimeError):
"""A configuration file is missing or could not be located."""
def _resolve(workdir: Path, path: Path) -> Path:
"""Anchor a relative path to ``workdir``; absolute paths pass through."""
return path if path.is_absolute() else workdir / path
def _require_file(target: Path) -> None:
"""Raise the compose-style error when ``target`` is not a file."""
if not target.is_file():
raise NovaFileError(f"no such file: {target}")
def _primary_or_raise(resolved: Sequence[Path]) -> Path:
"""Return the first path, after validating that every path exists."""
for target in resolved:
_require_file(target)
return resolved[0]
def _from_env(workdir: Path) -> list[Path] | None:
"""Interpret ``NOVA_COMPOSE_FILE`` as a ``:``-separated path list.
Relative entries are anchored to ``workdir``. An unset, empty, or
all-separator value means "no override" and returns None so the
upward search can take over.
"""
raw = os.environ.get(NOVA_COMPOSE_FILE_ENV, "")
entries = [part for part in raw.split(":") if part]
if not entries:
return None
return [_resolve(workdir, Path(part)) for part in entries]
def _search_upward(workdir: Path) -> Path | None:
"""Walk from ``workdir`` to the filesystem root looking for compose files.
In each directory ``compose.yaml`` is preferred over ``compose.yml``;
only when neither exists does the search continue upward.
"""
current = workdir.resolve()
while True:
for name in COMPOSE_FILE_NAMES:
candidate = current / name
if candidate.is_file():
return candidate
if current.parent == current: # reached the filesystem root
return None
current = current.parent
def find_compose_file(workdir: Path, files: Sequence[Path] | None = None) -> Path:
"""Locate the primary compose file, docker-compose style.
Precedence: explicit ``files`` (first is primary, all must exist) >
``NOVA_COMPOSE_FILE`` (same validation) > upward search for
``compose.yaml``/``compose.yml``. Raises :class:`NovaFileError` when a
listed file is missing or no configuration file exists at all.
"""
if files:
return _primary_or_raise([_resolve(workdir, path) for path in files])
env_files = _from_env(workdir)
if env_files is not None:
return _primary_or_raise(env_files)
found = _search_upward(workdir)
if found is not None:
return found
raise NovaFileError("no configuration file found: specify a file with -f")
def find_novafile(workdir: Path, file: Path | None = None) -> Path:
"""Locate the Novafile with docker-build parity: no upward search.
``file`` (from ``build -f``) wins when given; otherwise only
``<workdir>/Novafile`` is considered — exactly like ``docker build``
looking at ``./Dockerfile`` and never in parent directories.
"""
target = _resolve(workdir, file) if file is not None else workdir / NOVAFILE_NAME
_require_file(target)
return target
def load_env_files(paths: Sequence[Path]) -> dict[str, str]:
"""Load ``KEY=VALUE`` env files in order, later files overriding earlier.
Blank lines and ``#`` comments are skipped; one pair of surrounding
single or double quotes is stripped from each value. A missing file
raises :class:`NovaFileError`.
"""
env: dict[str, str] = {}
for path in paths:
_require_file(path)
env.update(_parse_env_file(path))
return env
def _parse_env_file(path: Path) -> dict[str, str]:
"""Parse one .env-format file into a key/value mapping."""
parsed: dict[str, str] = {}
for line in path.read_text(encoding="utf-8").splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#") or "=" not in stripped:
continue
key, _, value = stripped.partition("=")
parsed[key.strip()] = _unquote(value.strip())
return parsed
def _unquote(value: str) -> str:
"""Strip one pair of surrounding single or double quotes from a value."""
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
return value[1:-1]
return value
+362
View File
@@ -0,0 +1,362 @@
"""nova CLI entry point: root command group, version command, console script target.
This phase wires the docker compose-ergonomic command *surface*: every
lifecycle command parses its full flag set, resolves its configuration
file compose-style (``-f`` override, ``NOVA_COMPOSE_FILE``, upward
``compose.yaml``/``compose.yml`` search; ``build`` instead resolves its
Novafile, docker build parity), then reports the later phase that will
implement the behavior. Novafile/compose parsers land in phase 2,
environment discovery in phase 3, the IaC engine in phase 4, and policy
integration in phase 5.
"""
import importlib.metadata
from pathlib import Path
from typing import Callable, NoReturn, TypeVar
import click
import nova
from nova.cli.config_files import NovaFileError, find_compose_file, find_novafile
def _resolve_version() -> str:
"""Resolve the installed nova-cli version, falling back to the package constant."""
try:
return importlib.metadata.version("nova-cli")
except importlib.metadata.PackageNotFoundError:
return nova.__version__
# --- Phase-landing stub handlers -------------------------------------------------
# Exact stderr contract for surface-only commands, keyed by the phase in
# which the implementation lands. ``{command_name}`` is substituted per
# command: build/config -> phase 2 parsers, env group -> phase 3 discovery,
# lifecycle engines -> phase 4, policy group -> phase 5.
_PHASE_LANDING: dict[int, str] = {
2: "the '{command_name}' parser lands in phase 2 (Novafile + compose parsers)",
3: "nova {command_name} lands in phase 3 (environment discovery)",
4: "the '{command_name}' engine lands in phase 4 (IaC adapter)",
5: "nova {command_name} lands in phase 5 (vendored c7n + c7n-left)",
}
def _pending(command_name: str, landing: int) -> NoReturn:
"""Emit the phase-landing error for a surface-only command and exit 1.
click has already parsed and validated the full flag set by the time a
handler runs; handlers first run config discovery (P01-01-03), then
report where the behavior implementation lands.
"""
message = _PHASE_LANDING[landing].format(command_name=command_name)
click.echo(f"Error: {message}", err=True)
raise SystemExit(1)
def _require_compose_file(file: tuple[str, ...]) -> None:
"""Run compose-file discovery for a lifecycle command, hard-failing on error.
docker-compose parity: a missing configuration file is reported as
``Error: <message>`` on stderr with exit code 1, before the command
reports its phase landing. On success the resolved file is simply
validated — the engines that consume it land in later phases.
"""
try:
find_compose_file(Path.cwd(), tuple(Path(f) for f in file))
except NovaFileError as exc:
click.echo(f"Error: {exc}", err=True)
raise SystemExit(1)
def _require_novafile(file: tuple[str, ...]) -> None:
"""Run Novafile discovery for ``build``, hard-failing on error.
``build`` is the lifecycle exception: its ``-f`` names the Novafile,
exactly as ``docker build -f Dockerfile`` names the build recipe,
while every other lifecycle command follows
``docker compose -f compose.yaml``. Only the first ``-f`` is the
Novafile; discovery never searches parent directories.
"""
try:
find_novafile(Path.cwd(), Path(file[0]) if file else None)
except NovaFileError as exc:
click.echo(f"Error: {exc}", err=True)
raise SystemExit(1)
# --- Root command group -----------------------------------------------------------
@click.group(
invoke_without_command=False,
no_args_is_help=True,
help="nova - Docker-style ergonomics for infrastructure deployment",
)
def cli() -> None:
"""nova - Docker-style ergonomics for infrastructure deployment."""
@cli.command()
def version() -> None:
"""Print the nova-cli version."""
click.echo(f"nova-cli v{_resolve_version()}")
# --- Shared lifecycle flags (docker compose parity) -------------------------------
_FC = TypeVar("_FC", bound=Callable[..., object])
def _lifecycle_options(
file_short: bool = True,
file_help: str = "Specify an alternate Novafile/compose file (repeatable)",
) -> Callable[[_FC], _FC]:
"""Build the decorator stack of compose-parity options for a lifecycle command.
``file_short=False`` omits the ``-f`` short flag. It is used by ``rm``,
where ``-f`` is ``--force`` -- the same resolution docker compose ships
for the collision, since ``docker compose rm -f`` means forced removal.
``file_help`` overrides the ``--file`` help text; ``build`` uses it to
document that its ``-f`` is the Novafile path (docker build parity),
unlike the compose-file meaning everywhere else.
"""
# click lists options in reverse application order in `--help`, so apply
# them back-to-front to display the spec order (file, project-name,
# detach, env-file, profile) above each command's extras.
def decorate(command: _FC) -> _FC:
file_decl: tuple[str, ...] = ("-f", "--file") if file_short else ("--file",)
command = click.option(
"--profile",
"profile",
multiple=True,
help="Specify a deployment profile to enable (repeatable)",
)(command)
command = click.option(
"--env-file",
"env_file",
multiple=True,
help="Specify an alternate environment file (repeatable)",
)(command)
command = click.option(
"-d",
"--detach",
"detach",
is_flag=True,
help="Detached mode: do not stream engine output",
)(command)
command = click.option(
"-p",
"--project-name",
"project_name",
help="Specify an alternate project name",
)(command)
command = click.option(
*file_decl,
"file",
multiple=True,
type=click.Path(),
help=file_help,
)(command)
return command
return decorate
# --- Lifecycle commands -----------------------------------------------------------
@cli.command()
@_lifecycle_options(file_help="Specify the Novafile to build from (accepts the Novafile path, Dockerfile -f parity)")
def build(
file: tuple[str, ...],
project_name: str | None,
detach: bool,
env_file: tuple[str, ...],
profile: tuple[str, ...],
) -> None:
"""Create a module build from a Novafile."""
_require_novafile(file)
_pending("build", 2)
@cli.command()
@_lifecycle_options()
def up(
file: tuple[str, ...],
project_name: str | None,
detach: bool,
env_file: tuple[str, ...],
profile: tuple[str, ...],
) -> None:
"""Deploy infrastructure modules."""
_require_compose_file(file)
_pending("up", 4)
@cli.command()
@_lifecycle_options()
@click.option(
"-v",
"--volumes",
"volumes",
is_flag=True,
help="Remove named volumes declared in the Novafile",
)
def down(
file: tuple[str, ...],
project_name: str | None,
detach: bool,
env_file: tuple[str, ...],
profile: tuple[str, ...],
volumes: bool,
) -> None:
"""Remove deployed infrastructure modules."""
_require_compose_file(file)
_pending("down", 4)
@cli.command()
@_lifecycle_options()
@click.option(
"-a",
"--all",
"all",
is_flag=True,
help="Show all services, including stopped ones",
)
def ps(
file: tuple[str, ...],
project_name: str | None,
detach: bool,
env_file: tuple[str, ...],
profile: tuple[str, ...],
all: bool,
) -> None:
"""List deployed services."""
_require_compose_file(file)
_pending("ps", 4)
@cli.command()
@_lifecycle_options()
def stop(
file: tuple[str, ...],
project_name: str | None,
detach: bool,
env_file: tuple[str, ...],
profile: tuple[str, ...],
) -> None:
"""Stop services."""
_require_compose_file(file)
_pending("stop", 4)
@cli.command()
@_lifecycle_options()
def start(
file: tuple[str, ...],
project_name: str | None,
detach: bool,
env_file: tuple[str, ...],
profile: tuple[str, ...],
) -> None:
"""Start services."""
_require_compose_file(file)
_pending("start", 4)
@cli.command()
@_lifecycle_options()
def restart(
file: tuple[str, ...],
project_name: str | None,
detach: bool,
env_file: tuple[str, ...],
profile: tuple[str, ...],
) -> None:
"""Restart services."""
_require_compose_file(file)
_pending("restart", 4)
@cli.command()
@_lifecycle_options(file_short=False)
@click.option(
"-f",
"--force",
"force",
is_flag=True,
help="Force removal of service states",
)
def rm(
file: tuple[str, ...],
project_name: str | None,
detach: bool,
env_file: tuple[str, ...],
profile: tuple[str, ...],
force: bool,
) -> None:
"""Remove stopped service states."""
_require_compose_file(file)
_pending("rm", 4)
@cli.command()
@_lifecycle_options()
@click.argument("service", nargs=-1)
def logs(
file: tuple[str, ...],
project_name: str | None,
detach: bool,
env_file: tuple[str, ...],
profile: tuple[str, ...],
service: tuple[str, ...],
) -> None:
"""View output from services."""
_require_compose_file(file)
_pending("logs", 4)
@cli.command()
@_lifecycle_options()
@click.option(
"--no-interpolate",
"no_interpolate",
is_flag=True,
help="Print the configuration without interpolating variables",
)
def config(
file: tuple[str, ...],
project_name: str | None,
detach: bool,
env_file: tuple[str, ...],
profile: tuple[str, ...],
no_interpolate: bool,
) -> None:
"""Validate and view the compose file."""
_require_compose_file(file)
_pending("config", 2)
# --- Placeholder command groups ---------------------------------------------------
@cli.group(invoke_without_command=True)
def policy() -> None:
"""Manage deployment policies."""
_pending("policy", 5)
@cli.group(invoke_without_command=True)
def env() -> None:
"""Inspect deployment environments."""
_pending("env", 3)
# Console entry point (`nova = "nova.cli:main"`): the click group object is
# itself callable, so main() dispatches exactly as cli() would. This keeps
# `nova.cli.main` a click.BaseCommand instance, which is also what the test
# suite asserts.
main: click.Group = cli
View File
+273
View File
@@ -0,0 +1,273 @@
"""Validated command behaviors with precise phase-landing errors (task P01-01-04).
The phase-1 "must-have" behavior table, exercised end to end through the
CliRunner: every lifecycle command parses its flags, discovers its
configuration file compose-style through the CLI itself, and fails with
the exact error naming the phase where its implementation lands — exit
code 1 throughout, never a crash.
Table rows, in test order:
1. no config anywhere -> discovery error for every lifecycle command
2. compose.yml in cwd -> each command lands in its documented phase
3. compose.yml + Novafile -> build reaches the phase-2 parser
4. explicit -f missing -> -f wins over discovery and hard-fails
5. upward search -> compose.yaml in a parent dir is found via the CLI
6. compose.yaml preference over compose.yml
7. version -> the one exit-0 path, printing the version
8. policy/env groups -> phase 5 / phase 3 landings
9. help surface -> Usage + docker-parity options; 13 commands listed
10. exit codes -> every surface error is exactly exit 1
"""
import re
from collections.abc import Mapping
from pathlib import Path
from typing import Protocol
import click
import click.testing
import pytest
from nova.cli import main
from nova.cli.config_files import find_compose_file
runner = click.testing.CliRunner()
#: The 10 lifecycle commands that carry the compose-parity flag set.
LIFECYCLE_COMMANDS: tuple[str, ...] = (
"build",
"up",
"down",
"ps",
"stop",
"start",
"restart",
"rm",
"logs",
"config",
)
#: Lifecycle commands resolving a compose file (build resolves a Novafile).
COMPOSE_COMMANDS: tuple[str, ...] = LIFECYCLE_COMMANDS[1:]
#: The complete command surface: 10 lifecycle + policy + env + version.
ALL_COMMANDS: tuple[str, ...] = (*LIFECYCLE_COMMANDS, "policy", "env", "version")
MINIMAL_COMPOSE = "# minimal nova config\n"
MINIMAL_NOVAFILE = "FROM demo\n"
@pytest.fixture(autouse=True)
def _no_compose_env(monkeypatch: pytest.MonkeyPatch) -> None:
"""Keep NOVA_COMPOSE_FILE out of every behavior test (discovery isolation)."""
monkeypatch.delenv("NOVA_COMPOSE_FILE", raising=False)
class ProjectFactory(Protocol):
"""Callable creating an isolated project dir and making it the cwd."""
def __call__(
self,
files: Mapping[str, str] | None = None,
subdir: str | None = None,
) -> Path:
...
@pytest.fixture
def project(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> ProjectFactory:
"""Create a project dir under tmp_path and chdir into it (monkeypatch.chdir).
``files`` maps relative paths (nested paths create their parents) to
content, so a project can ship a compose file, a Novafile, or both.
``subdir`` makes that subdirectory of the project the cwd, for the
upward-discovery row. Returns the project root.
"""
def _project(
files: Mapping[str, str] | None = None,
subdir: str | None = None,
) -> Path:
root = tmp_path / "proj"
root.mkdir(exist_ok=True)
for rel, content in (files or {}).items():
target = root / rel
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content, encoding="utf-8")
cwd = root / subdir if subdir is not None else root
cwd.mkdir(parents=True, exist_ok=True)
monkeypatch.chdir(cwd)
return root
return _project
def run(*args: str) -> click.testing.Result:
"""Invoke `nova <args...>` through the shared CliRunner."""
return runner.invoke(main, list(args))
def output(result: click.testing.Result) -> str:
"""Everything the command printed, on stdout and stderr alike.
click >= 8.2 always separates the streams, so both are concatenated;
on older click versions (merged streams) ``output`` already carries
stderr and the ``stderr`` access raises — same fallback as the
P01-01-02 helper in test_main.py.
"""
try:
return result.output + getattr(result, "stderr", "")
except ValueError:
return result.output
# --- Row 1: no configuration file anywhere -------------------------------------------
@pytest.mark.parametrize("command", COMPOSE_COMMANDS)
def test_no_config_compose_commands_report_missing_configuration(
project: ProjectFactory, command: str
) -> None:
"""With no config in cwd (or any parent), every compose command fails on discovery."""
project()
result = run(command)
assert result.exit_code == 1
assert "no configuration file found" in output(result)
def test_no_config_build_reports_missing_novafile(project: ProjectFactory) -> None:
"""build needs a Novafile and reports it missing, docker-build style."""
project()
result = run("build")
assert result.exit_code == 1
printed = output(result)
assert "no such file" in printed
assert "Novafile" in printed
# --- Rows 2-3: a config file lets discovery pass; each command lands in its phase ----
@pytest.mark.parametrize(
("command", "phase"),
[
("up", 4),
("down", 4),
("ps", 4),
("stop", 4),
("start", 4),
("restart", 4),
("rm", 4),
("logs", 4),
("config", 2),
],
)
def test_with_compose_file_each_command_lands_in_its_phase(
project: ProjectFactory, command: str, phase: int
) -> None:
"""A minimal compose.yml in cwd: engines land in phase 4, config in phase 2."""
project(files={"compose.yml": MINIMAL_COMPOSE})
result = run(command)
assert result.exit_code == 1
assert f"phase {phase}" in output(result)
def test_build_with_novafile_lands_in_phase_2(project: ProjectFactory) -> None:
"""With compose.yml and a Novafile, build reaches the phase-2 parser landing."""
project(files={"compose.yml": MINIMAL_COMPOSE, "Novafile": MINIMAL_NOVAFILE})
result = run("build")
assert result.exit_code == 1
assert "phase 2" in output(result)
# --- Row 4: explicit -f with a missing file ------------------------------------------
@pytest.mark.parametrize("command", ("up", "ps"))
def test_explicit_f_with_missing_file_beats_discovery(
project: ProjectFactory, command: str
) -> None:
"""An explicit -f wins over discovery and hard-fails naming the missing file."""
project(files={"compose.yml": MINIMAL_COMPOSE})
result = run(command, "-f", "nope.yml")
assert result.exit_code == 1
printed = output(result)
assert "no such file" in printed
assert "nope.yml" in printed
# --- Rows 5-6: discovery reaches the right file through the CLI ----------------------
def test_upward_search_finds_parent_compose_via_cli(project: ProjectFactory) -> None:
"""From a subdirectory, `logs` finds compose.yaml in the project root."""
project(files={"compose.yaml": MINIMAL_COMPOSE}, subdir="sub")
result = run("logs")
assert result.exit_code == 1
assert "phase 4" in output(result)
def test_compose_yaml_preferred_over_compose_yml(project: ProjectFactory) -> None:
"""With both files present, the CLI discovers compose.yaml, not compose.yml."""
root = project(files={"compose.yaml": MINIMAL_COMPOSE, "compose.yml": MINIMAL_COMPOSE})
result = run("up")
assert result.exit_code == 1
assert "phase 4" in output(result)
assert find_compose_file(root) == root / "compose.yaml"
# --- Rows 7-8: version and the placeholder groups ------------------------------------
def test_version_exits_zero() -> None:
"""`nova version` is the one success path in phase 1."""
result = run("version")
assert result.exit_code == 0
assert "0.1.0" in output(result)
@pytest.mark.parametrize(
("group", "phase"),
[("policy", "phase 5"), ("env", "phase 3")],
)
def test_placeholder_groups_land_in_their_phases(group: str, phase: str) -> None:
"""`policy` alone lands in phase 5; `env` alone lands in phase 3."""
result = run(group)
assert result.exit_code == 1
assert phase in output(result)
# --- Row 9: help surface --------------------------------------------------------------
@pytest.mark.parametrize("command", LIFECYCLE_COMMANDS)
def test_lifecycle_help_shows_usage_and_parity_options(command: str) -> None:
"""Every lifecycle `--help` shows Usage plus the docker-parity option set."""
result = run(command, "--help")
assert result.exit_code == 0
printed = output(result)
assert "Usage:" in printed
assert "--project-name" in printed
def test_root_help_lists_all_thirteen_commands() -> None:
"""`nova --help` lists the full 13-command surface."""
assert len(ALL_COMMANDS) == 13
result = run("--help")
assert result.exit_code == 0
printed = output(result)
for command in ALL_COMMANDS:
# word-boundary match: "start" must not be satisfied by "restart"
assert re.search(rf"\b{re.escape(command)}\b", printed), command
# --- Row 10: exit codes are exactly 1 -------------------------------------------------
@pytest.mark.parametrize("command", ("up", "build", "config"))
def test_phase_landings_exit_exactly_one(project: ProjectFactory, command: str) -> None:
"""Landing errors are exit 1 precisely (rows 1-2 assert the same for no-config)."""
project(files={"compose.yml": MINIMAL_COMPOSE, "Novafile": MINIMAL_NOVAFILE})
result = run(command)
assert result.exit_code == 1
assert "lands in phase" in output(result)
+188
View File
@@ -0,0 +1,188 @@
"""Config file discovery tests (task P01-01-03).
Pure unit tests — no CliRunner, no CLI process: every discovery rule is
exercised directly against tmp_path directory trees, mirroring the
compose-spec rules verified in .ciagent/RESEARCH.md area 3.
"""
from pathlib import Path
import pytest
from nova.cli.config_files import (
NovaFileError,
find_compose_file,
find_novafile,
load_env_files,
)
NO_CONFIG = "no configuration file found: specify a file with -f"
@pytest.fixture(autouse=True)
def _no_compose_env(monkeypatch: pytest.MonkeyPatch) -> None:
"""Keep NOVA_COMPOSE_FILE out of every discovery test unless set explicitly."""
monkeypatch.delenv("NOVA_COMPOSE_FILE", raising=False)
def _write(path: Path, content: str = "# nova config\n") -> Path:
"""Create a config file (discovery only checks existence, parsers land in P2)."""
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
return path
# --- find_compose_file: explicit -f files ------------------------------------------
def test_explicit_files_first_is_primary(tmp_path: Path) -> None:
"""Explicit files: the first is primary once every listed file exists."""
first = _write(tmp_path / "one.yml")
second = _write(tmp_path / "two.yml")
assert find_compose_file(tmp_path, [first, second]) == first
def test_explicit_files_missing_raises(tmp_path: Path) -> None:
"""Every explicit file must exist; a missing one is a hard error."""
present = _write(tmp_path / "one.yml")
missing = tmp_path / "two.yml"
with pytest.raises(NovaFileError) as excinfo:
find_compose_file(tmp_path, [present, missing])
assert str(excinfo.value) == f"no such file: {missing}"
def test_explicit_file_relative_resolves_against_workdir(tmp_path: Path) -> None:
"""Relative -f paths anchor to the workdir (click hands us raw strings)."""
_write(tmp_path / "compose.yaml")
assert find_compose_file(tmp_path, [Path("compose.yaml")]) == tmp_path / "compose.yaml"
# --- find_compose_file: NOVA_COMPOSE_FILE env var ----------------------------------
def test_env_var_single_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""NOVA_COMPOSE_FILE supplies the compose file without any directory search."""
custom = _write(tmp_path / "custom.yml")
monkeypatch.setenv("NOVA_COMPOSE_FILE", str(custom))
assert find_compose_file(tmp_path) == custom
def test_env_var_multiple_paths(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""A `:`-separated NOVA_COMPOSE_FILE resolves with the first as primary."""
one = _write(tmp_path / "one.yml")
two = _write(tmp_path / "two.yml")
monkeypatch.setenv("NOVA_COMPOSE_FILE", f"{one}:{two}")
assert find_compose_file(tmp_path) == one
def test_env_var_missing_path_raises(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Env-var paths get the same existence validation as -f files."""
missing = tmp_path / "gone.yml"
monkeypatch.setenv("NOVA_COMPOSE_FILE", str(missing))
with pytest.raises(NovaFileError) as excinfo:
find_compose_file(tmp_path)
assert str(excinfo.value) == f"no such file: {missing}"
def test_explicit_files_beat_env_var(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Precedence: -f files win over NOVA_COMPOSE_FILE."""
explicit = _write(tmp_path / "explicit.yml")
from_env = _write(tmp_path / "from-env.yml")
monkeypatch.setenv("NOVA_COMPOSE_FILE", str(from_env))
assert find_compose_file(tmp_path, [explicit]) == explicit
# --- find_compose_file: upward directory search ------------------------------------
def test_upward_search_finds_parent_compose_yaml(tmp_path: Path) -> None:
"""Without -f/env, discovery walks up parent directories to a compose file."""
compose = _write(tmp_path / "a" / "compose.yaml")
nested = tmp_path / "a" / "b" / "c"
nested.mkdir(parents=True)
assert find_compose_file(nested) == compose
def test_upward_search_prefers_compose_yaml_in_same_dir(tmp_path: Path) -> None:
"""compose.yaml beats compose.yml within the same directory."""
yaml = _write(tmp_path / "compose.yaml")
_write(tmp_path / "compose.yml")
assert find_compose_file(tmp_path) == yaml
def test_upward_search_compose_yml_fallback(tmp_path: Path) -> None:
"""compose.yml alone is a valid hit when compose.yaml is absent."""
yml = _write(tmp_path / "compose.yml")
assert find_compose_file(tmp_path) == yml
def test_no_config_found_raises(tmp_path: Path) -> None:
"""Nothing anywhere up to the root: the exact compose-style error."""
empty = tmp_path / "empty"
empty.mkdir()
with pytest.raises(NovaFileError) as excinfo:
find_compose_file(empty)
assert str(excinfo.value) == NO_CONFIG
# --- find_novafile (docker build parity) ---------------------------------------------
def test_novafile_default_in_workdir(tmp_path: Path) -> None:
"""Default lookup is <workdir>/Novafile, nothing else."""
novafile = _write(tmp_path / "Novafile", "FROM demo\n")
assert find_novafile(tmp_path) == novafile
def test_novafile_no_upward_search(tmp_path: Path) -> None:
"""Dockerfile parity: a Novafile in a parent directory is never found."""
_write(tmp_path / "Novafile", "FROM demo\n")
nested = tmp_path / "nested"
nested.mkdir()
with pytest.raises(NovaFileError) as excinfo:
find_novafile(nested)
assert str(excinfo.value) == f"no such file: {nested / 'Novafile'}"
def test_novafile_override(tmp_path: Path) -> None:
"""An explicit build -f path replaces the default Novafile location."""
_write(tmp_path / "Novafile", "FROM demo\n")
custom = _write(tmp_path / "Novafile.prod", "FROM demo\n")
assert find_novafile(tmp_path, Path("Novafile.prod")) == custom
# --- load_env_files ------------------------------------------------------------------
def test_env_file_parsing_comments_blanks_and_quotes(tmp_path: Path) -> None:
"""KEY=VALUE lines parse; comments/blanks skip; surrounding quotes strip."""
env_file = _write(
tmp_path / "test.env",
"# comment line\n"
"\n"
"PLAIN=value\n"
'DOUBLE="quoted value"\n'
"SINGLE='single value'\n"
"EMPTY=\n",
)
assert load_env_files([env_file]) == {
"PLAIN": "value",
"DOUBLE": "quoted value",
"SINGLE": "single value",
"EMPTY": "",
}
def test_env_files_later_overrides_earlier(tmp_path: Path) -> None:
"""Later files override earlier keys; untouched keys survive."""
first = _write(tmp_path / "first.env", "A=1\nB=keep\n")
second = _write(tmp_path / "second.env", "A=2\n")
assert load_env_files([first, second]) == {"A": "2", "B": "keep"}
def test_env_files_missing_raises(tmp_path: Path) -> None:
"""A missing env file is a hard error naming the path."""
missing = tmp_path / "nope.env"
with pytest.raises(NovaFileError) as excinfo:
load_env_files([missing])
assert str(excinfo.value) == f"no such file: {missing}"
+198
View File
@@ -0,0 +1,198 @@
"""CLI surface tests: docker-ergonomic command surface (task P01-01-02).
Every lifecycle command parses its full flag set (click rejects unknown
options with exit code 2), resolves its configuration file compose-style
(P01-01-03), then reports the later phase in which its implementation
lands with exit code 1.
"""
import os
import tempfile
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
import click
import click.testing
import pytest
from nova.cli import main
runner = click.testing.CliRunner()
@contextmanager
def _project_dir(tmp_path: Path, files: tuple[str, ...] = ("compose.yml",)) -> Iterator[Path]:
"""Run with cwd inside a fresh project dir containing the named files.
Since P01-01-03, lifecycle handlers discover a compose file (build: a
Novafile) against ``Path.cwd()`` before reporting their phase landing,
so the landing tests need a minimal project directory to land in.
``files=()`` yields an empty project — the no-config case.
"""
root = Path(tempfile.mkdtemp(dir=tmp_path))
for name in files:
(root / name).write_text("# minimal nova config\n", encoding="utf-8")
previous = Path.cwd()
os.chdir(root)
try:
yield root
finally:
os.chdir(previous)
def _visible_output(result: click.testing.Result) -> str:
"""Everything the command printed, on stdout and stderr alike.
click >= 8.2 always separates the streams and ``Result.output`` already
mixes them in terminal order; concatenating ``stderr`` as well keeps
older click versions (where ``output`` may be stdout-only and stderr is
accessed separately) working. See the P01-01-02 task note.
"""
try:
return result.output + getattr(result, "stderr", "")
except ValueError:
# click < 8.2 with merged streams: output already contains stderr.
return result.output
def test_help_lists_all_commands() -> None:
"""`nova --help` shows the full command surface."""
result = runner.invoke(main, ["--help"])
assert result.exit_code == 0
output = _visible_output(result)
assert "Usage:" in output
for command in (
"build",
"up",
"down",
"ps",
"stop",
"start",
"restart",
"rm",
"logs",
"config",
"policy",
"env",
"version",
):
assert command in output, f"`nova --help` should list `{command}`"
def test_flag_parsing_up(tmp_path: Path) -> None:
"""`up` accepts every compose-parity flag, then reports the phase-4 engine."""
with _project_dir(tmp_path, files=("a.yml", "b.yml")):
result = runner.invoke(
main,
[
"up",
"-d",
"-f",
"a.yml",
"-f",
"b.yml",
"-p",
"proj",
"--env-file",
"e1",
"--profile",
"p1",
],
)
assert result.exit_code == 1
output = _visible_output(result)
assert "phase 4" in output
assert "Error: the 'up' engine lands in phase 4 (IaC adapter)" in output
def test_down_volumes_flag(tmp_path: Path) -> None:
"""`down -v` parses the volumes flag, then reports the phase-4 engine."""
with _project_dir(tmp_path):
result = runner.invoke(main, ["down", "-v"])
assert result.exit_code == 1
assert "phase 4" in _visible_output(result)
def test_build_config_phase2(tmp_path: Path) -> None:
"""`build` and `config` report the phase-2 Novafile/compose parsers."""
with _project_dir(tmp_path, files=("Novafile", "compose.yml")):
for command in ("build", "config"):
result = runner.invoke(main, [command])
assert result.exit_code == 1
output = _visible_output(result)
assert "phase 2" in output
assert (
f"Error: the '{command}' parser lands in phase 2 (Novafile + compose parsers)"
in output
)
def test_policy_env_groups() -> None:
"""`policy`/`env` groups report their phase-5/phase-3 landings."""
result = runner.invoke(main, ["policy"])
assert result.exit_code == 1
assert (
"Error: nova policy lands in phase 5 (vendored c7n + c7n-left)"
in _visible_output(result)
)
result = runner.invoke(main, ["env"])
assert result.exit_code == 1
assert (
"Error: nova env lands in phase 3 (environment discovery)"
in _visible_output(result)
)
def test_version_still_works() -> None:
"""`nova version` still prints the installed version and exits 0."""
result = runner.invoke(main, ["version"])
assert result.exit_code == 0
assert "0.1.0" in _visible_output(result)
def test_lifecycle_extras_parse(tmp_path: Path) -> None:
"""Per-command extras parse cleanly (an unknown option would exit 2)."""
cases = [
["down", "-v", "--profile", "prod"],
["ps", "-a"],
["stop"],
["start"],
["restart"],
["rm", "-f"],
["rm", "--file", "nova.yml", "--force"],
["logs", "web", "db", "-d"],
["config", "--no-interpolate", "-p", "proj"],
]
for args in cases:
with _project_dir(tmp_path, files=("compose.yml", "nova.yml")):
result = runner.invoke(main, args)
assert result.exit_code == 1, args
output = _visible_output(result)
assert "Error:" in output, args
assert "lands in phase" in output, args
def test_lifecycle_without_config_reports_missing_file(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Without any config file, discovery fails before the phase landing.
`up` finds no compose file anywhere up to the root (the exact
compose-style message); `build` finds no Novafile in the working
directory (docker build parity: no upward search).
"""
monkeypatch.delenv("NOVA_COMPOSE_FILE", raising=False)
with _project_dir(tmp_path, files=()) as project:
result = runner.invoke(main, ["up"])
assert result.exit_code == 1
output = _visible_output(result)
assert (
"Error: no configuration file found: specify a file with -f" in output
)
assert "phase 4" not in output
result = runner.invoke(main, ["build"])
assert result.exit_code == 1
assert f"no such file: {project / 'Novafile'}" in _visible_output(result)
+23
View File
@@ -0,0 +1,23 @@
"""Package acceptance tests: version metadata and CLI entry point."""
import importlib.metadata
import click
import nova
import nova.cli
def test_package_exposes_version_constant() -> None:
"""The package exposes its version as the __version__ constant."""
assert nova.__version__ == "0.1.0"
def test_installed_distribution_version_matches() -> None:
"""Installed distribution metadata reports the same version."""
assert importlib.metadata.version("nova-cli") == "0.1.0"
def test_cli_main_is_click_base_command() -> None:
"""The nova entry point (nova.cli:main) is a click command object."""
assert isinstance(nova.cli.main, click.BaseCommand)