Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a6c5a1bb4f | |||
| 3e86118383 |
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
"""nova - Docker-style ergonomics for infrastructure deployment."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,5 @@
|
||||
"""nova.cli - click command surface and console entry point."""
|
||||
|
||||
from nova.cli.main import main
|
||||
|
||||
__all__ = ["main"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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}"
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user