Skip to content

Commit 4d1f3d9

Browse files
committed
feat(cli): add validation profiles and shared reports
1 parent 1e84a15 commit 4d1f3d9

3 files changed

Lines changed: 163 additions & 5 deletions

File tree

docs/source/reference/cli.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,23 @@ openenv import path/to/source --name my_env --output-dir ./envs --env-class MyEn
4040

4141
## `openenv validate`
4242

43+
Use an explicit profile to produce the RFC 008 validation plan and shared
44+
report schema:
45+
46+
```bash
47+
openenv validate path/to/env --profile static
48+
openenv validate path/to/env --profile runtime --json
49+
openenv validate --url http://127.0.0.1:8000 --profile runtime --json
50+
openenv validate path/to/env --profile full --output validation.json
51+
```
52+
53+
`static` checks source and packaging, `runtime` adds a launched or connected
54+
server, and `full` records every policy criterion while marking unavailable
55+
remote capabilities as skipped. Local reports never claim official
56+
certification. Automatic local launch executes the environment checkout as the
57+
current user and is intended only for trusted development source; use `--url`
58+
for a server you already isolated.
59+
4360
[[autodoc]] openenv.cli.commands.validate.validate
4461

4562
## `openenv push`

src/openenv/cli/commands/validate.py

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@
1919
validate_multi_mode_deployment,
2020
validate_running_environment,
2121
)
22+
from openenv.validation import (
23+
format_shared_validation_report,
24+
run_local_validation,
25+
ValidationProfile,
26+
)
2227

2328

2429
def _looks_like_url(value: str) -> bool:
@@ -48,9 +53,23 @@ def validate(
4853
bool,
4954
typer.Option(
5055
"--json",
51-
help="Output local validation report as JSON (runtime validation is JSON by default)",
56+
help="Output the RFC 008 shared validation report as JSON",
5257
),
5358
] = False,
59+
profile: Annotated[
60+
str | None,
61+
typer.Option(
62+
"--profile",
63+
help="Validation profile: static, runtime, or full",
64+
),
65+
] = None,
66+
output: Annotated[
67+
Path | None,
68+
typer.Option(
69+
"--output",
70+
help="Write the shared JSON validation report to this path",
71+
),
72+
] = None,
5473
timeout: Annotated[
5574
float,
5675
typer.Option(
@@ -74,6 +93,9 @@ def validate(
7493
7594
Runtime validation checks if a live OpenEnv server conforms to the
7695
versioned runtime API contract and returns a criteria-based JSON report.
96+
Explicit static, runtime, and full profiles emit the RFC 008 shared report.
97+
Automatic runtime launch is intended for trusted local source; connect to
98+
an already isolated server with `--url` for untrusted environments.
7799
78100
Examples:
79101
@@ -91,6 +113,9 @@ def validate(
91113
92114
# Validate specific environment
93115
$ openenv validate envs/echo_env
116+
117+
# Run every locally available check and record remote-only skips
118+
$ openenv validate envs/echo_env --profile full --output report.json
94119
```
95120
"""
96121
runtime_target = url
@@ -114,6 +139,80 @@ def validate(
114139
raise typer.Exit(1)
115140
runtime_target = target
116141

142+
# Machine-readable and explicit-profile invocations use the RFC 008 shared
143+
# report. Only the unqualified human rendering stays on the legacy path.
144+
if profile is not None or output is not None or json_output:
145+
if profile is None:
146+
selected_profile = (
147+
ValidationProfile.RUNTIME
148+
if runtime_target is not None
149+
else ValidationProfile.STATIC
150+
)
151+
else:
152+
try:
153+
selected_profile = ValidationProfile(profile.lower())
154+
except ValueError as exc:
155+
typer.echo(
156+
"Error: --profile must be one of: static, runtime, full",
157+
err=True,
158+
)
159+
raise typer.Exit(1) from exc
160+
161+
if runtime_target is not None and selected_profile is ValidationProfile.STATIC:
162+
typer.echo(
163+
"Error: The static profile requires a local source directory",
164+
err=True,
165+
)
166+
raise typer.Exit(1)
167+
168+
if runtime_target is not None:
169+
shared_target: str | Path = runtime_target
170+
shared_runtime_url = runtime_target
171+
else:
172+
shared_target = Path.cwd() if target is None else Path(target)
173+
shared_runtime_url = None
174+
if not shared_target.exists():
175+
typer.echo(f"Error: Path does not exist: {shared_target}", err=True)
176+
raise typer.Exit(1)
177+
if not shared_target.is_dir():
178+
typer.echo(f"Error: Path is not a directory: {shared_target}", err=True)
179+
raise typer.Exit(1)
180+
181+
try:
182+
report = run_local_validation(
183+
shared_target,
184+
profile=selected_profile,
185+
runtime_url=shared_runtime_url,
186+
timeout_s=timeout,
187+
)
188+
except ValueError as exc:
189+
typer.echo(f"Error: {exc}", err=True)
190+
raise typer.Exit(1) from exc
191+
payload = report.to_dict()
192+
serialized = json.dumps(payload, indent=2)
193+
194+
if output is not None:
195+
try:
196+
output.parent.mkdir(parents=True, exist_ok=True)
197+
output.write_text(f"{serialized}\n", encoding="utf-8")
198+
except OSError as exc:
199+
typer.echo(
200+
f"Error: Unable to write validation report: {type(exc).__name__}",
201+
err=True,
202+
)
203+
raise typer.Exit(1) from exc
204+
205+
if json_output:
206+
typer.echo(serialized)
207+
else:
208+
typer.echo(format_shared_validation_report(report))
209+
if output is not None:
210+
typer.echo(f"Report written to {output}")
211+
212+
if not report.passed:
213+
raise typer.Exit(1)
214+
return
215+
117216
if runtime_target is not None:
118217
try:
119218
report = validate_running_environment(runtime_target, timeout_s=timeout)

tests/test_cli/test_validate.py

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,9 @@ def _write_minimal_valid_env(
4141
(env_dir / "openenv.yaml").write_text(
4242
"spec_version: 1\nname: test_env\ntype: space\nruntime: fastapi\napp: server.app:app\nport: 8000\n"
4343
)
44-
(env_dir / "uv.lock").write_text("")
44+
(env_dir / "uv.lock").write_text(
45+
'version = 1\nrevision = 1\nrequires-python = ">=3.10"\n'
46+
)
4547
(env_dir / "pyproject.toml").write_text(
4648
"[project]\n"
4749
'name = "test-env"\n'
@@ -54,6 +56,7 @@ def _write_minimal_valid_env(
5456
(env_dir / "server" / "app.py").write_text(
5557
f"{main_signature}\n return None\n\nif __name__ == '__main__':\n {main_invocation}\n"
5658
)
59+
(env_dir / "server" / "Dockerfile").write_text("FROM python:3.12-slim\n")
5760

5861

5962
def test_validate_running_environment_success() -> None:
@@ -194,6 +197,16 @@ def test_validate_command_runtime_target_outputs_json() -> None:
194197
mock_validate.assert_called_once_with("https://example.com", timeout_s=5.0)
195198

196199

200+
def test_validate_shared_profile_reports_invalid_runtime_url() -> None:
201+
result = runner.invoke(
202+
app,
203+
["validate", "--url", "http://[::1", "--profile", "runtime", "--json"],
204+
)
205+
206+
assert result.exit_code == 1
207+
assert "Error: Invalid runtime URL" in result.output
208+
209+
197210
def test_validate_command_local_path_still_works(tmp_path: Path) -> None:
198211
"""CLI local validation remains backward compatible."""
199212
env_dir = tmp_path / "test_env"
@@ -214,13 +227,41 @@ def test_validate_command_local_json_output(tmp_path: Path) -> None:
214227

215228
assert result.exit_code == 0
216229
payload = json.loads(result.output)
217-
assert payload["validation_type"] == "local_environment"
230+
assert payload["validation_type"] == "openenv_validation"
231+
assert payload["report_schema_version"] == "1.0"
232+
assert payload["profile"] == "static"
218233
assert payload["passed"] is True
219-
assert payload["summary"]["passed_count"] == 1
220-
assert payload["summary"]["total_count"] == 1
234+
assert payload["summary"]["passed_count"] >= 1
235+
assert payload["summary"]["total_count"] >= 1
221236
assert payload["summary"]["failed_criteria"] == []
222237

223238

239+
def test_profile_json_can_be_written_to_output_file(tmp_path: Path) -> None:
240+
env_dir = tmp_path / "test_env"
241+
report_path = tmp_path / "reports" / "validation.json"
242+
_write_minimal_valid_env(env_dir)
243+
244+
result = runner.invoke(
245+
app,
246+
[
247+
"validate",
248+
str(env_dir),
249+
"--profile",
250+
"static",
251+
"--json",
252+
"--output",
253+
str(report_path),
254+
],
255+
)
256+
257+
assert result.exit_code == 0, result.output
258+
stdout_payload = json.loads(result.output)
259+
file_payload = json.loads(report_path.read_text())
260+
assert file_payload == stdout_payload
261+
assert file_payload["profile"] == "static"
262+
assert file_payload["report_schema_version"] == "1.0"
263+
264+
224265
def test_validate_command_rejects_environment_package_as_runtime_dependency(
225266
tmp_path: Path,
226267
) -> None:
@@ -259,6 +300,7 @@ def test_validate_command_accepts_dockerfile_managed_openenv_runtime(
259300
'server = "server.app:main"\n'
260301
)
261302
(env_dir / "server" / "Dockerfile").write_text(
303+
"FROM python:3.12-slim\n"
262304
'RUN pip install --no-cache-dir --no-deps "openenv[core]>=0.2.2"\n'
263305
)
264306

0 commit comments

Comments
 (0)