genblaze-cli 0.3.2__tar.gz → 0.3.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {genblaze_cli-0.3.2 → genblaze_cli-0.3.4}/.gitignore +6 -1
- {genblaze_cli-0.3.2 → genblaze_cli-0.3.4}/PKG-INFO +2 -2
- {genblaze_cli-0.3.2 → genblaze_cli-0.3.4}/genblaze_cli/commands/extract.py +17 -3
- {genblaze_cli-0.3.2 → genblaze_cli-0.3.4}/genblaze_cli/commands/index.py +1 -1
- {genblaze_cli-0.3.2 → genblaze_cli-0.3.4}/genblaze_cli/commands/replay.py +24 -5
- {genblaze_cli-0.3.2 → genblaze_cli-0.3.4}/genblaze_cli/commands/verify.py +16 -4
- {genblaze_cli-0.3.2 → genblaze_cli-0.3.4}/genblaze_cli/main.py +1 -1
- {genblaze_cli-0.3.2 → genblaze_cli-0.3.4}/pyproject.toml +2 -2
- {genblaze_cli-0.3.2 → genblaze_cli-0.3.4}/tests/test_cli.py +193 -3
- {genblaze_cli-0.3.2 → genblaze_cli-0.3.4}/README.md +0 -0
- {genblaze_cli-0.3.2 → genblaze_cli-0.3.4}/genblaze_cli/__init__.py +0 -0
- {genblaze_cli-0.3.2 → genblaze_cli-0.3.4}/genblaze_cli/commands/__init__.py +0 -0
- {genblaze_cli-0.3.2 → genblaze_cli-0.3.4}/genblaze_cli/manifest_io.py +0 -0
- {genblaze_cli-0.3.2 → genblaze_cli-0.3.4}/genblaze_cli/py.typed +0 -0
- {genblaze_cli-0.3.2 → genblaze_cli-0.3.4}/tests/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: genblaze-cli
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.4
|
|
4
4
|
Summary: CLI for genblaze manifest operations
|
|
5
5
|
Project-URL: Homepage, https://github.com/backblaze-labs/genblaze
|
|
6
6
|
Project-URL: Documentation, https://github.com/backblaze-labs/genblaze#readme
|
|
@@ -20,7 +20,7 @@ Classifier: Topic :: Multimedia
|
|
|
20
20
|
Classifier: Topic :: Software Development :: Libraries
|
|
21
21
|
Requires-Python: >=3.11
|
|
22
22
|
Requires-Dist: click>=8.0
|
|
23
|
-
Requires-Dist: genblaze-core<0.4,>=0.3.
|
|
23
|
+
Requires-Dist: genblaze-core<0.4,>=0.3.6
|
|
24
24
|
Provides-Extra: dev
|
|
25
25
|
Requires-Dist: pillow>=10.0; extra == 'dev'
|
|
26
26
|
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
@@ -20,14 +20,25 @@ def _manifest_json_for_display(manifest: Manifest) -> str:
|
|
|
20
20
|
|
|
21
21
|
|
|
22
22
|
@click.command()
|
|
23
|
-
@click.argument("file", type=click.Path(exists=True, path_type=Path))
|
|
23
|
+
@click.argument("file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
|
24
24
|
@click.option("--format", "fmt", type=click.Choice(["json", "summary"]), default="json")
|
|
25
|
-
|
|
25
|
+
@click.option(
|
|
26
|
+
"-o",
|
|
27
|
+
"--output",
|
|
28
|
+
type=click.Path(path_type=Path),
|
|
29
|
+
default=None,
|
|
30
|
+
help="Write extracted manifest JSON to this file instead of stdout.",
|
|
31
|
+
)
|
|
32
|
+
def extract(file: Path, fmt: str, output: Path | None) -> None:
|
|
26
33
|
"""Extract and display the genblaze manifest from a media file."""
|
|
27
34
|
try:
|
|
28
35
|
manifest = extract_manifest(file)
|
|
29
36
|
if fmt == "json":
|
|
30
|
-
|
|
37
|
+
json_str = _manifest_json_for_display(manifest)
|
|
38
|
+
if output is not None:
|
|
39
|
+
output.write_text(json_str, encoding="utf-8")
|
|
40
|
+
else:
|
|
41
|
+
click.echo(json_str)
|
|
31
42
|
else:
|
|
32
43
|
report = manifest.verification_report()
|
|
33
44
|
click.echo(f"Run ID: {manifest.run.run_id}")
|
|
@@ -35,6 +46,9 @@ def extract(file: Path, fmt: str) -> None:
|
|
|
35
46
|
click.echo(f"Hash: {manifest.canonical_hash}")
|
|
36
47
|
click.echo(f"Hash OK: {report.hash_ok}")
|
|
37
48
|
click.echo(f"Output sha256: {len(report.unverified_sha256_ids)} missing or malformed")
|
|
49
|
+
# Itemize metadata too, so a False `Verified:` line always has a
|
|
50
|
+
# visible reason (report.ok folds in invalid_metadata_ids, #149).
|
|
51
|
+
click.echo(f"Output metadata: {len(report.invalid_metadata_ids)} out of spec")
|
|
38
52
|
click.echo(f"Verified: {report.ok} (asset bytes were not fetched or compared)")
|
|
39
53
|
except Exception as exc:
|
|
40
54
|
raise click.ClickException(f"{type(exc).__name__}: {exc}") from exc
|
|
@@ -2,12 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
4
|
from pathlib import Path
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
5
6
|
|
|
6
7
|
import click
|
|
7
8
|
from genblaze_core.models.enums import PromptVisibility, RunStatus
|
|
8
9
|
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from genblaze_core.providers.base import BaseProvider
|
|
9
12
|
|
|
10
|
-
|
|
13
|
+
|
|
14
|
+
def _load_provider(provider_name: str, allowed: tuple[str, ...] | None) -> "BaseProvider":
|
|
11
15
|
"""Load a provider by name using entry point discovery.
|
|
12
16
|
|
|
13
17
|
If allowed is set, reject providers not in the allowlist.
|
|
@@ -77,7 +81,7 @@ _UNREPLAYABLE_FIELDS = [
|
|
|
77
81
|
|
|
78
82
|
|
|
79
83
|
@click.command()
|
|
80
|
-
@click.argument("manifest_file", type=click.Path(exists=True, path_type=Path))
|
|
84
|
+
@click.argument("manifest_file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
|
81
85
|
@click.option("--dry-run/--no-dry-run", default=True, help="Preview without executing.")
|
|
82
86
|
@click.option(
|
|
83
87
|
"--allow-provider",
|
|
@@ -142,9 +146,13 @@ def replay(
|
|
|
142
146
|
|
|
143
147
|
# Safety: when no allowlist is provided, confirm each unique provider.
|
|
144
148
|
# A hostile manifest could reference any installed provider and execute
|
|
145
|
-
# paid API calls on the user's credentials without consent.
|
|
149
|
+
# paid API calls on the user's credentials without consent. Steps with no
|
|
150
|
+
# provider (INGEST/IMPORT, see the loop below) don't invoke one, so they're
|
|
151
|
+
# excluded here rather than flowing `None` into sorted() (issue #43).
|
|
146
152
|
if allowed is None:
|
|
147
|
-
unique_providers = sorted(
|
|
153
|
+
unique_providers = sorted(
|
|
154
|
+
{step.provider for step in run.steps if step.provider is not None}
|
|
155
|
+
)
|
|
148
156
|
click.echo(
|
|
149
157
|
"No --allow-provider allowlist set. Confirm each provider before execution:",
|
|
150
158
|
err=True,
|
|
@@ -165,7 +173,7 @@ def replay(
|
|
|
165
173
|
# Cache providers by name to avoid re-instantiating
|
|
166
174
|
from genblaze_core.pipeline import Pipeline
|
|
167
175
|
|
|
168
|
-
providers: dict[str,
|
|
176
|
+
providers: dict[str, BaseProvider] = {}
|
|
169
177
|
pipe = Pipeline(
|
|
170
178
|
run.name,
|
|
171
179
|
tenant_id=run.tenant_id,
|
|
@@ -177,6 +185,17 @@ def replay(
|
|
|
177
185
|
_reserved = {"model", "prompt", "modality", "step_type", "seed", "negative_prompt"}
|
|
178
186
|
|
|
179
187
|
for step_idx, step in enumerate(run.steps):
|
|
188
|
+
# A None provider (only valid for INGEST/IMPORT steps, see Step's
|
|
189
|
+
# own validator) has no upstream service to re-invoke here — replay
|
|
190
|
+
# only supports Pipeline.step()-shaped generative steps. Without this
|
|
191
|
+
# guard, None flowed into a dict[str, ...] key and _load_provider's
|
|
192
|
+
# str parameter, raising a confusing TypeError instead (issue #43).
|
|
193
|
+
if step.provider is None:
|
|
194
|
+
raise click.ClickException(
|
|
195
|
+
f"Step {step_idx + 1} (step_type={step.step_type}) has no provider; "
|
|
196
|
+
"replay does not support re-executing provider-less steps "
|
|
197
|
+
"(e.g. INGEST/IMPORT)."
|
|
198
|
+
)
|
|
180
199
|
if step.provider not in providers:
|
|
181
200
|
providers[step.provider] = _load_provider(step.provider, allowed)
|
|
182
201
|
|
|
@@ -8,11 +8,11 @@ from genblaze_cli.manifest_io import extract_manifest
|
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
@click.command()
|
|
11
|
-
@click.argument("file", type=click.Path(exists=True, path_type=Path))
|
|
11
|
+
@click.argument("file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
|
12
12
|
@click.option(
|
|
13
13
|
"--hash-only",
|
|
14
14
|
is_flag=True,
|
|
15
|
-
help="Only verify canonical_hash;
|
|
15
|
+
help="Only verify canonical_hash; skip output sha256 and asset-metadata checks.",
|
|
16
16
|
)
|
|
17
17
|
def verify(file: Path, hash_only: bool) -> None:
|
|
18
18
|
"""Verify an embedded, sidecar, or standalone genblaze manifest."""
|
|
@@ -32,9 +32,21 @@ def verify(file: Path, hash_only: bool) -> None:
|
|
|
32
32
|
err=True,
|
|
33
33
|
)
|
|
34
34
|
raise click.exceptions.Exit(1)
|
|
35
|
+
# Out-of-spec numeric/media_type metadata (e.g. width=0) is tolerated on
|
|
36
|
+
# load by parse_manifest() but fails verify() (#149); surface it here so
|
|
37
|
+
# the CLI verdict matches Manifest.verify()/report.ok instead of a stale
|
|
38
|
+
# "OK" that only checked sha256.
|
|
39
|
+
if report.invalid_metadata_ids:
|
|
40
|
+
click.echo(
|
|
41
|
+
f"FAIL: {len(report.invalid_metadata_ids)} output asset(s) "
|
|
42
|
+
"carry out-of-spec numeric/media_type metadata "
|
|
43
|
+
"(e.g. width=0, or a malformed media_type).",
|
|
44
|
+
err=True,
|
|
45
|
+
)
|
|
46
|
+
raise click.exceptions.Exit(1)
|
|
35
47
|
click.echo(
|
|
36
|
-
"OK: manifest hash verified; all output assets declare sha256
|
|
37
|
-
"Asset bytes were not fetched or compared."
|
|
48
|
+
"OK: manifest hash verified; all output assets declare sha256 and "
|
|
49
|
+
"carry in-spec metadata. Asset bytes were not fetched or compared."
|
|
38
50
|
)
|
|
39
51
|
except click.exceptions.Exit:
|
|
40
52
|
raise
|
|
@@ -9,7 +9,7 @@ from genblaze_cli.commands.verify import verify
|
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
@click.group()
|
|
12
|
-
@click.version_option(package_name="genblaze-cli")
|
|
12
|
+
@click.version_option(package_name="genblaze-cli", prog_name="genblaze-cli")
|
|
13
13
|
def cli() -> None:
|
|
14
14
|
"""genblaze — media generation manifest toolkit."""
|
|
15
15
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "genblaze-cli"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.4"
|
|
8
8
|
description = "CLI for genblaze manifest operations"
|
|
9
9
|
authors = [{name = "Jeronimo De Leon", email = "jdeleon@backblaze.com"}]
|
|
10
10
|
readme = "README.md"
|
|
@@ -23,7 +23,7 @@ classifiers = [
|
|
|
23
23
|
keywords = ["genblaze", "ai", "media", "manifest", "provenance", "c2pa-ready", "genai", "pipeline", "cli", "command-line"]
|
|
24
24
|
|
|
25
25
|
dependencies = [
|
|
26
|
-
"genblaze-core>=0.3.
|
|
26
|
+
"genblaze-core>=0.3.6,<0.4",
|
|
27
27
|
"click>=8.0",
|
|
28
28
|
]
|
|
29
29
|
|
|
@@ -6,6 +6,7 @@ import json
|
|
|
6
6
|
import tomllib
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
|
|
9
|
+
import pytest
|
|
9
10
|
from click.testing import CliRunner
|
|
10
11
|
from genblaze_cli.main import cli
|
|
11
12
|
from genblaze_core._utils import MAX_MANIFEST_BYTES
|
|
@@ -13,8 +14,10 @@ from genblaze_core.builders import RunBuilder, StepBuilder
|
|
|
13
14
|
from genblaze_core.canonical.json import canonical_json
|
|
14
15
|
from genblaze_core.media.png import PngHandler
|
|
15
16
|
from genblaze_core.models.asset import Asset
|
|
16
|
-
from genblaze_core.models.enums import Modality, ProviderErrorCode, StepStatus
|
|
17
|
+
from genblaze_core.models.enums import Modality, ProviderErrorCode, StepStatus, StepType
|
|
17
18
|
from genblaze_core.models.manifest import Manifest
|
|
19
|
+
from genblaze_core.models.run import Run
|
|
20
|
+
from genblaze_core.models.step import Step
|
|
18
21
|
from genblaze_core.testing import MockProvider
|
|
19
22
|
from PIL import Image
|
|
20
23
|
|
|
@@ -55,6 +58,28 @@ def _create_url_only_manifest(*, schema_version: str | None = None) -> Manifest:
|
|
|
55
58
|
return manifest
|
|
56
59
|
|
|
57
60
|
|
|
61
|
+
def _url_only_manifest_with_bad_metadata() -> Manifest:
|
|
62
|
+
"""A hash-valid, sha256-valid manifest whose sole output asset carries an
|
|
63
|
+
out-of-spec dimension (width=0) — the shape parse_manifest() tolerates on
|
|
64
|
+
load but verify() must reject (#149). Injected via object.__setattr__ to
|
|
65
|
+
bypass construction validation, mirroring a foreign-authored manifest."""
|
|
66
|
+
manifest = _create_url_only_manifest()
|
|
67
|
+
asset = manifest.run.steps[0].assets[0]
|
|
68
|
+
object.__setattr__(asset, "sha256", "a" * 64) # valid, so only metadata fails
|
|
69
|
+
object.__setattr__(asset, "width", 0)
|
|
70
|
+
manifest.compute_hash()
|
|
71
|
+
return manifest
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _combined_output(result) -> str:
|
|
75
|
+
"""CLI stdout+stderr, robust across Click versions. Click >=8.2 captures
|
|
76
|
+
stderr separately; 8.1.x mixes it into output and raises on ``.stderr``."""
|
|
77
|
+
try:
|
|
78
|
+
return result.output + result.stderr
|
|
79
|
+
except ValueError:
|
|
80
|
+
return result.output
|
|
81
|
+
|
|
82
|
+
|
|
58
83
|
def test_extract_json(tmp_path: Path) -> None:
|
|
59
84
|
png = _create_embedded_png(tmp_path)
|
|
60
85
|
runner = CliRunner()
|
|
@@ -91,6 +116,7 @@ def test_extract_summary(tmp_path: Path) -> None:
|
|
|
91
116
|
assert "Run ID:" in result.output
|
|
92
117
|
assert "Hash OK:" in result.output
|
|
93
118
|
assert "Output sha256:" in result.output
|
|
119
|
+
assert "Output metadata:" in result.output
|
|
94
120
|
assert "Verified:" in result.output
|
|
95
121
|
|
|
96
122
|
|
|
@@ -241,6 +267,75 @@ def test_verify_rejects_malformed_output_sha256(tmp_path: Path) -> None:
|
|
|
241
267
|
assert "1 output asset(s) missing or malformed sha256" in combined
|
|
242
268
|
|
|
243
269
|
|
|
270
|
+
def test_verify_rejects_out_of_spec_asset_metadata(tmp_path: Path) -> None:
|
|
271
|
+
"""#149/#155: a manifest with a valid hash and valid sha256 but out-of-spec
|
|
272
|
+
asset metadata (e.g. width=0, which parse_manifest() tolerates on load) must
|
|
273
|
+
fail `verify`, so the CLI verdict agrees with Manifest.verify()/report.ok
|
|
274
|
+
rather than reporting OK because only sha256 was checked."""
|
|
275
|
+
manifest = _url_only_manifest_with_bad_metadata()
|
|
276
|
+
# Assert the model layer first so this pins the boundary even in a Click
|
|
277
|
+
# build where CLI output parsing behaves differently.
|
|
278
|
+
assert not manifest.verify()
|
|
279
|
+
|
|
280
|
+
png_path = tmp_path / "bad-metadata.png"
|
|
281
|
+
Image.new("RGBA", (1, 1), (255, 0, 0, 255)).save(png_path)
|
|
282
|
+
png_path.with_suffix(png_path.suffix + ".genblaze.json").write_text(
|
|
283
|
+
manifest.to_canonical_json(),
|
|
284
|
+
encoding="utf-8",
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
runner = CliRunner()
|
|
288
|
+
result = runner.invoke(cli, ["verify", str(png_path)])
|
|
289
|
+
combined = _combined_output(result)
|
|
290
|
+
|
|
291
|
+
assert result.exit_code != 0
|
|
292
|
+
assert "out-of-spec" in combined
|
|
293
|
+
# Prove it's the metadata boundary, not a sha256 or hash failure.
|
|
294
|
+
assert "missing or malformed sha256" not in combined
|
|
295
|
+
assert "hash mismatch" not in combined
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def test_verify_hash_only_skips_metadata_check(tmp_path: Path) -> None:
|
|
299
|
+
"""--hash-only must short-circuit before the metadata check: an out-of-spec
|
|
300
|
+
manifest whose hash still matches exits 0 (metadata lives inside the hash
|
|
301
|
+
payload, so hash-only stays an honest integrity-only claim). Pins the check
|
|
302
|
+
order so a refactor can't move the metadata guard ahead of the early return."""
|
|
303
|
+
manifest = _url_only_manifest_with_bad_metadata()
|
|
304
|
+
png_path = tmp_path / "bad-metadata-hash-only.png"
|
|
305
|
+
Image.new("RGBA", (1, 1), (255, 0, 0, 255)).save(png_path)
|
|
306
|
+
png_path.with_suffix(png_path.suffix + ".genblaze.json").write_text(
|
|
307
|
+
manifest.to_canonical_json(),
|
|
308
|
+
encoding="utf-8",
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
runner = CliRunner()
|
|
312
|
+
result = runner.invoke(cli, ["verify", "--hash-only", str(png_path)])
|
|
313
|
+
|
|
314
|
+
assert result.exit_code == 0
|
|
315
|
+
assert "manifest hash verified" in result.output
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def test_extract_summary_surfaces_out_of_spec_metadata(tmp_path: Path) -> None:
|
|
319
|
+
"""#149/#155: `extract --format summary` itemizes invalid metadata so a
|
|
320
|
+
`Verified: False` verdict always has a visible reason and agrees with
|
|
321
|
+
`verify` (mirrors the sha256 parity test)."""
|
|
322
|
+
manifest = _url_only_manifest_with_bad_metadata()
|
|
323
|
+
png_path = tmp_path / "bad-metadata-extract.png"
|
|
324
|
+
Image.new("RGBA", (1, 1), (255, 0, 0, 255)).save(png_path)
|
|
325
|
+
png_path.with_suffix(png_path.suffix + ".genblaze.json").write_text(
|
|
326
|
+
manifest.to_canonical_json(),
|
|
327
|
+
encoding="utf-8",
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
runner = CliRunner()
|
|
331
|
+
result = runner.invoke(cli, ["extract", "--format", "summary", str(png_path)])
|
|
332
|
+
|
|
333
|
+
assert result.exit_code == 0
|
|
334
|
+
assert "Output sha256: 0 missing or malformed" in result.output
|
|
335
|
+
assert "Output metadata: 1 out of spec" in result.output
|
|
336
|
+
assert "Verified: False" in result.output
|
|
337
|
+
|
|
338
|
+
|
|
244
339
|
def test_verify_reports_legacy_unverified_assets(tmp_path: Path) -> None:
|
|
245
340
|
manifest = _create_url_only_manifest(schema_version="1.5")
|
|
246
341
|
png_path = tmp_path / "legacy-url-only.png"
|
|
@@ -431,6 +526,30 @@ def test_replay_aborts_when_no_allowlist_and_declined(tmp_path: Path) -> None:
|
|
|
431
526
|
assert "Aborted" in result.output or "Execute with provider" in result.output
|
|
432
527
|
|
|
433
528
|
|
|
529
|
+
def test_replay_reports_clear_error_for_providerless_step(tmp_path: Path) -> None:
|
|
530
|
+
"""A provider-less step (INGEST/IMPORT, valid per Step's own model) must
|
|
531
|
+
produce a clear ClickException on replay --no-dry-run, not a TypeError
|
|
532
|
+
from None flowing into sorted()/dict keys/_load_provider (issue #43)."""
|
|
533
|
+
step = Step(
|
|
534
|
+
provider=None,
|
|
535
|
+
model="rss",
|
|
536
|
+
step_type=StepType.INGEST,
|
|
537
|
+
status=StepStatus.SUCCEEDED,
|
|
538
|
+
assets=[Asset(url="https://example.com/a.png", media_type="image/png")],
|
|
539
|
+
)
|
|
540
|
+
run = Run(name="ingest-test", steps=[step])
|
|
541
|
+
manifest = Manifest.from_run(run)
|
|
542
|
+
manifest_path = tmp_path / "ingest.json"
|
|
543
|
+
manifest_path.write_text(manifest.to_canonical_json(), encoding="utf-8")
|
|
544
|
+
|
|
545
|
+
runner = CliRunner()
|
|
546
|
+
result = runner.invoke(cli, ["replay", str(manifest_path), "--no-dry-run"])
|
|
547
|
+
|
|
548
|
+
assert result.exit_code != 0
|
|
549
|
+
assert "TypeError" not in result.output
|
|
550
|
+
assert "no provider" in result.output.lower()
|
|
551
|
+
|
|
552
|
+
|
|
434
553
|
def test_replay_no_dry_run_exits_nonzero_when_run_fails(tmp_path: Path, monkeypatch) -> None:
|
|
435
554
|
"""A replayed failed run must not look successful to automation."""
|
|
436
555
|
|
|
@@ -471,5 +590,76 @@ def test_index(tmp_path: Path) -> None:
|
|
|
471
590
|
result = runner.invoke(cli, ["index", str(manifest_path), "-o", str(out_dir)])
|
|
472
591
|
assert result.exit_code == 0
|
|
473
592
|
assert "Indexed" in result.output
|
|
474
|
-
|
|
475
|
-
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
# --- Issue #64: directories rejected with a clear error; non-object JSON is
|
|
596
|
+
# handled consistently across index/replay ---
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
@pytest.mark.parametrize("command", ["extract", "verify", "index", "replay"])
|
|
600
|
+
def test_commands_reject_directory_with_clear_error(tmp_path: Path, command: str) -> None:
|
|
601
|
+
"""A directory argument must fail with click's own actionable error
|
|
602
|
+
instead of leaking a confusing downstream error (e.g. EmbeddingError or
|
|
603
|
+
[Errno 21] Is a directory)."""
|
|
604
|
+
runner = CliRunner()
|
|
605
|
+
result = runner.invoke(cli, [command, str(tmp_path)])
|
|
606
|
+
assert result.exit_code != 0
|
|
607
|
+
assert "is a directory" in result.output.lower()
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
@pytest.mark.parametrize("command", ["index", "replay"])
|
|
611
|
+
def test_non_object_manifest_json_gives_consistent_clean_error(
|
|
612
|
+
tmp_path: Path, command: str
|
|
613
|
+
) -> None:
|
|
614
|
+
"""A top-level JSON array (valid JSON, invalid manifest shape) must fail
|
|
615
|
+
with the same clean error for both index and replay, not an internal
|
|
616
|
+
AttributeError leaked from parse_manifest's dict-only .get() call."""
|
|
617
|
+
array_path = tmp_path / "not-a-manifest.json"
|
|
618
|
+
array_path.write_text("[1, 2, 3]", encoding="utf-8")
|
|
619
|
+
|
|
620
|
+
runner = CliRunner()
|
|
621
|
+
result = runner.invoke(cli, [command, str(array_path)])
|
|
622
|
+
|
|
623
|
+
assert result.exit_code != 0
|
|
624
|
+
assert "must be a JSON object" in result.output
|
|
625
|
+
assert "AttributeError" not in result.output
|
|
626
|
+
assert "'list' object has no attribute 'get'" not in result.output
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
# --- Fix B: extract -o / --output ---
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def test_extract_output_flag_writes_file(tmp_path: Path) -> None:
|
|
633
|
+
"""-o writes manifest JSON to file; stdout is empty."""
|
|
634
|
+
png = _create_embedded_png(tmp_path)
|
|
635
|
+
out_file = tmp_path / "manifest.json"
|
|
636
|
+
runner = CliRunner()
|
|
637
|
+
result = runner.invoke(cli, ["extract", str(png), "-o", str(out_file)])
|
|
638
|
+
assert result.exit_code == 0, result.output
|
|
639
|
+
# Output goes to file, not stdout
|
|
640
|
+
assert result.output.strip() == ""
|
|
641
|
+
assert out_file.exists()
|
|
642
|
+
data = json.loads(out_file.read_text(encoding="utf-8"))
|
|
643
|
+
assert "canonical_hash" in data
|
|
644
|
+
assert "run" in data
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def test_extract_output_flag_stdout_default(tmp_path: Path) -> None:
|
|
648
|
+
"""Without -o, JSON goes to stdout (backward compat)."""
|
|
649
|
+
png = _create_embedded_png(tmp_path)
|
|
650
|
+
runner = CliRunner()
|
|
651
|
+
result = runner.invoke(cli, ["extract", str(png)])
|
|
652
|
+
assert result.exit_code == 0
|
|
653
|
+
data = json.loads(result.output)
|
|
654
|
+
assert "canonical_hash" in data
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
# --- Fix C: --version label ---
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def test_version_label_shows_cli_package() -> None:
|
|
661
|
+
"""--version must say 'genblaze-cli' not the umbrella package name."""
|
|
662
|
+
runner = CliRunner()
|
|
663
|
+
result = runner.invoke(cli, ["--version"])
|
|
664
|
+
assert result.exit_code == 0
|
|
665
|
+
assert "genblaze-cli" in result.output
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|