genblaze-cli 0.3.0__tar.gz → 0.3.2__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: genblaze-cli
3
- Version: 0.3.0
3
+ Version: 0.3.2
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.2.0
23
+ Requires-Dist: genblaze-core<0.4,>=0.3.4
24
24
  Provides-Extra: dev
25
25
  Requires-Dist: pillow>=10.0; extra == 'dev'
26
26
  Requires-Dist: pytest>=7.0; extra == 'dev'
@@ -31,11 +31,11 @@ Description-Content-Type: text/markdown
31
31
 
32
32
  **Command-line toolkit for inspecting, verifying, and indexing genblaze AI-generated-media provenance manifests.**
33
33
 
34
- `genblaze-cli` is the companion CLI for [genblaze](https://github.com/backblaze-labs/genblaze) — the Python SDK for generative AI pipelines across video, image, and audio. It lets anyone (not just Python developers) audit AI-generated media: extract the embedded provenance manifest from an MP4 / PNG / MP3, verify its SHA-256 hash, replay a run, or index manifests into Parquet for downstream analytics.
34
+ `genblaze-cli` is the companion CLI for [genblaze](https://github.com/backblaze-labs/genblaze) — the Python SDK for generative AI pipelines across video, image, and audio. It lets anyone (not just Python developers) audit AI-generated media: extract the embedded provenance manifest from an MP4 / PNG / MP3, verify its manifest hash and output sha256 coverage, replay a run, or index manifests into Parquet for downstream analytics.
35
35
 
36
36
  ## Why genblaze-cli
37
37
 
38
- - **Audit AI-generated media in one command** — `genblaze verify video.mp4` confirms a file's manifest hash hasn't been tampered with.
38
+ - **Audit AI-generated media in one command** — `genblaze verify video.mp4` confirms a file's manifest hash hasn't been tampered with and all output assets declare sha256.
39
39
  - **Works on any genblaze output** — PNG, JPEG, WebP, MP4, MP3, WAV with embedded manifests.
40
40
  - **Analytics-ready** — `genblaze index` emits partitioned Parquet tables (runs, steps, assets) for BI tools and data warehouses.
41
41
  - **Zero provider dependencies** — reads manifests; doesn't call any AI API.
@@ -57,7 +57,7 @@ genblaze --help
57
57
  genblaze extract video.mp4 # Extract embedded manifest → stdout (JSON)
58
58
  genblaze extract video.mp4 -o m.json # …or to a file
59
59
 
60
- genblaze verify video.mp4 # Verify the embedded manifest's SHA-256
60
+ genblaze verify video.mp4 # Verify manifest hash + output sha256 declarations
61
61
  genblaze verify manifest.json # Or verify a standalone manifest file
62
62
 
63
63
  genblaze replay manifest.json # Show what a replay would do (dry run)
@@ -65,7 +65,7 @@ genblaze replay manifest.json # Show what a replay would do (dry run
65
65
  genblaze index manifest.json -o data/ # Index into partitioned Parquet tables
66
66
  ```
67
67
 
68
- Exit codes are non-zero on verification failure — safe to drop into CI pipelines, release checks, or content-moderation workflows.
68
+ Exit codes are non-zero on verification failure — safe to drop into CI pipelines, release checks, or content-moderation workflows. The command does not fetch remote asset URLs; consumers that dereference `asset.url` must hash those bytes separately and compare them with the manifest's `asset.sha256`.
69
69
 
70
70
  ## Typical flow
71
71
 
@@ -3,11 +3,11 @@
3
3
 
4
4
  **Command-line toolkit for inspecting, verifying, and indexing genblaze AI-generated-media provenance manifests.**
5
5
 
6
- `genblaze-cli` is the companion CLI for [genblaze](https://github.com/backblaze-labs/genblaze) — the Python SDK for generative AI pipelines across video, image, and audio. It lets anyone (not just Python developers) audit AI-generated media: extract the embedded provenance manifest from an MP4 / PNG / MP3, verify its SHA-256 hash, replay a run, or index manifests into Parquet for downstream analytics.
6
+ `genblaze-cli` is the companion CLI for [genblaze](https://github.com/backblaze-labs/genblaze) — the Python SDK for generative AI pipelines across video, image, and audio. It lets anyone (not just Python developers) audit AI-generated media: extract the embedded provenance manifest from an MP4 / PNG / MP3, verify its manifest hash and output sha256 coverage, replay a run, or index manifests into Parquet for downstream analytics.
7
7
 
8
8
  ## Why genblaze-cli
9
9
 
10
- - **Audit AI-generated media in one command** — `genblaze verify video.mp4` confirms a file's manifest hash hasn't been tampered with.
10
+ - **Audit AI-generated media in one command** — `genblaze verify video.mp4` confirms a file's manifest hash hasn't been tampered with and all output assets declare sha256.
11
11
  - **Works on any genblaze output** — PNG, JPEG, WebP, MP4, MP3, WAV with embedded manifests.
12
12
  - **Analytics-ready** — `genblaze index` emits partitioned Parquet tables (runs, steps, assets) for BI tools and data warehouses.
13
13
  - **Zero provider dependencies** — reads manifests; doesn't call any AI API.
@@ -29,7 +29,7 @@ genblaze --help
29
29
  genblaze extract video.mp4 # Extract embedded manifest → stdout (JSON)
30
30
  genblaze extract video.mp4 -o m.json # …or to a file
31
31
 
32
- genblaze verify video.mp4 # Verify the embedded manifest's SHA-256
32
+ genblaze verify video.mp4 # Verify manifest hash + output sha256 declarations
33
33
  genblaze verify manifest.json # Or verify a standalone manifest file
34
34
 
35
35
  genblaze replay manifest.json # Show what a replay would do (dry run)
@@ -37,7 +37,7 @@ genblaze replay manifest.json # Show what a replay would do (dry run
37
37
  genblaze index manifest.json -o data/ # Index into partitioned Parquet tables
38
38
  ```
39
39
 
40
- Exit codes are non-zero on verification failure — safe to drop into CI pipelines, release checks, or content-moderation workflows.
40
+ Exit codes are non-zero on verification failure — safe to drop into CI pipelines, release checks, or content-moderation workflows. The command does not fetch remote asset URLs; consumers that dereference `asset.url` must hash those bytes separately and compare them with the manifest's `asset.sha256`.
41
41
 
42
42
  ## Typical flow
43
43
 
@@ -0,0 +1,40 @@
1
+ """Extract command — print embedded manifest from a media file."""
2
+
3
+ from pathlib import Path
4
+
5
+ import click
6
+ from genblaze_core.canonical.json import canonical_json
7
+ from genblaze_core.exceptions import ManifestError
8
+ from genblaze_core.models.manifest import Manifest
9
+
10
+ from genblaze_cli.manifest_io import extract_manifest
11
+
12
+
13
+ def _manifest_json_for_display(manifest: Manifest) -> str:
14
+ """Serialize for inspection without bypassing write-schema guards."""
15
+ try:
16
+ manifest.assert_writable_schema()
17
+ except ManifestError:
18
+ return canonical_json(manifest.model_dump(mode="python"))
19
+ return manifest.to_canonical_json()
20
+
21
+
22
+ @click.command()
23
+ @click.argument("file", type=click.Path(exists=True, path_type=Path))
24
+ @click.option("--format", "fmt", type=click.Choice(["json", "summary"]), default="json")
25
+ def extract(file: Path, fmt: str) -> None:
26
+ """Extract and display the genblaze manifest from a media file."""
27
+ try:
28
+ manifest = extract_manifest(file)
29
+ if fmt == "json":
30
+ click.echo(_manifest_json_for_display(manifest))
31
+ else:
32
+ report = manifest.verification_report()
33
+ click.echo(f"Run ID: {manifest.run.run_id}")
34
+ click.echo(f"Steps: {len(manifest.run.steps)}")
35
+ click.echo(f"Hash: {manifest.canonical_hash}")
36
+ click.echo(f"Hash OK: {report.hash_ok}")
37
+ click.echo(f"Output sha256: {len(report.unverified_sha256_ids)} missing or malformed")
38
+ click.echo(f"Verified: {report.ok} (asset bytes were not fetched or compared)")
39
+ except Exception as exc:
40
+ raise click.ClickException(f"{type(exc).__name__}: {exc}") from exc
@@ -17,12 +17,12 @@ import click
17
17
  )
18
18
  def index(manifest_file: Path, output: Path) -> None:
19
19
  """Write manifest data to a Parquet sink for querying."""
20
- from genblaze_core.models.manifest import Manifest
20
+ from genblaze_core.models.manifest import parse_manifest
21
21
  from genblaze_core.sinks.parquet import ParquetSink
22
22
 
23
23
  try:
24
24
  data = json.loads(manifest_file.read_text(encoding="utf-8"))
25
- manifest = Manifest.model_validate(data)
25
+ manifest = parse_manifest(data)
26
26
  except Exception as exc:
27
27
  raise click.ClickException(f"Failed to load manifest: {exc}") from exc
28
28
 
@@ -4,7 +4,7 @@ import json
4
4
  from pathlib import Path
5
5
 
6
6
  import click
7
- from genblaze_core.models.enums import PromptVisibility
7
+ from genblaze_core.models.enums import PromptVisibility, RunStatus
8
8
 
9
9
 
10
10
  def _load_provider(provider_name: str, allowed: tuple[str, ...] | None):
@@ -99,7 +99,6 @@ def replay(
99
99
  ) -> None:
100
100
  """Re-execute a pipeline from a manifest JSON file."""
101
101
  from genblaze_core.models.manifest import parse_manifest
102
- from genblaze_core.pipeline import Pipeline
103
102
 
104
103
  try:
105
104
  data = json.loads(manifest_file.read_text(encoding="utf-8"))
@@ -107,14 +106,25 @@ def replay(
107
106
  except Exception as exc:
108
107
  raise click.ClickException(f"Failed to load manifest: {exc}") from exc
109
108
 
110
- # Verify manifest hash integrity
111
- if not force and not manifest.verify():
109
+ # Verify manifest hash integrity before replay. Output sha256 coverage is
110
+ # reported separately because URL-only outputs can be metadata-valid but
111
+ # not sha256-covered.
112
+ report = manifest.verification_report()
113
+ if not force and not report.hash_ok:
112
114
  click.echo(
113
115
  "WARNING: Manifest hash does not match content. The file may have been modified.",
114
116
  err=True,
115
117
  )
116
118
  if not click.confirm("Continue anyway?"):
117
119
  raise click.Abort()
120
+ elif not force:
121
+ if report.unverified_sha256_ids:
122
+ click.echo(
123
+ "WARNING: Manifest hash matches, but output asset bytes are not "
124
+ f"bound for {len(report.unverified_sha256_ids)} asset(s) "
125
+ "missing or malformed sha256.",
126
+ err=True,
127
+ )
118
128
 
119
129
  run = manifest.run
120
130
  _print_summary(run, show_prompts=show_prompts)
@@ -153,6 +163,8 @@ def replay(
153
163
  click.echo(" Detected chain mode from manifest step inputs.", err=True)
154
164
 
155
165
  # Cache providers by name to avoid re-instantiating
166
+ from genblaze_core.pipeline import Pipeline
167
+
156
168
  providers: dict[str, object] = {}
157
169
  pipe = Pipeline(
158
170
  run.name,
@@ -202,10 +214,17 @@ def replay(
202
214
  )
203
215
 
204
216
  try:
205
- result = pipe.run()
217
+ result = pipe.run(raise_on_failure=False)
206
218
  click.echo()
219
+ if result.run.status in {RunStatus.FAILED, RunStatus.CANCELLED}:
220
+ click.echo(f"Replay failed. New run ID: {result.run.run_id}", err=True)
221
+ click.echo(f"Hash: {result.manifest.canonical_hash}", err=True)
222
+ click.echo(f"Status: {result.run.status}", err=True)
223
+ raise click.exceptions.Exit(1)
207
224
  click.echo(f"Replay complete. New run ID: {result.run.run_id}")
208
225
  click.echo(f"Hash: {result.manifest.canonical_hash}")
209
226
  click.echo(f"Status: {result.run.status}")
227
+ except click.exceptions.Exit:
228
+ raise
210
229
  except Exception as exc:
211
230
  raise click.ClickException(f"Replay failed: {exc}") from exc
@@ -0,0 +1,44 @@
1
+ """Verify command — check manifest hash and output sha256 coverage."""
2
+
3
+ from pathlib import Path
4
+
5
+ import click
6
+
7
+ from genblaze_cli.manifest_io import extract_manifest
8
+
9
+
10
+ @click.command()
11
+ @click.argument("file", type=click.Path(exists=True, path_type=Path))
12
+ @click.option(
13
+ "--hash-only",
14
+ is_flag=True,
15
+ help="Only verify canonical_hash; do not require output sha256 declarations.",
16
+ )
17
+ def verify(file: Path, hash_only: bool) -> None:
18
+ """Verify an embedded, sidecar, or standalone genblaze manifest."""
19
+ try:
20
+ manifest = extract_manifest(file)
21
+ report = manifest.verification_report()
22
+ if not report.hash_ok:
23
+ click.echo("FAIL: manifest hash mismatch.", err=True)
24
+ raise click.exceptions.Exit(1)
25
+ if hash_only:
26
+ click.echo("OK: manifest hash verified. Asset bytes were not fetched or compared.")
27
+ return
28
+ if report.unverified_sha256_ids:
29
+ click.echo(
30
+ f"FAIL: {len(report.unverified_sha256_ids)} output asset(s) "
31
+ "missing or malformed sha256.",
32
+ err=True,
33
+ )
34
+ raise click.exceptions.Exit(1)
35
+ click.echo(
36
+ "OK: manifest hash verified; all output assets declare sha256. "
37
+ "Asset bytes were not fetched or compared."
38
+ )
39
+ except click.exceptions.Exit:
40
+ raise
41
+ except Exception as exc:
42
+ # Prefix with exception type so "PermissionError: ..." and
43
+ # "EmbeddingError: ..." are distinguishable at a glance.
44
+ raise click.ClickException(f"{type(exc).__name__}: {exc}") from exc
@@ -0,0 +1,42 @@
1
+ """Shared CLI manifest loading helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ from genblaze_core._utils import MAX_MANIFEST_BYTES
9
+ from genblaze_core.exceptions import EmbeddingError
10
+ from genblaze_core.media import get_handler, guess_mime
11
+ from genblaze_core.media.sidecar import PointerSidecarError, SidecarHandler
12
+ from genblaze_core.models.manifest import Manifest, parse_manifest
13
+
14
+
15
+ def load_standalone_json_manifest(file: Path) -> Manifest:
16
+ size = file.stat().st_size
17
+ if size > MAX_MANIFEST_BYTES:
18
+ raise EmbeddingError(
19
+ f"Manifest JSON exceeds size limit: {size} > {MAX_MANIFEST_BYTES} bytes"
20
+ )
21
+ data = json.loads(file.read_text(encoding="utf-8"))
22
+ if "run" not in data and "manifest_uri" in data:
23
+ raise PointerSidecarError(
24
+ manifest_uri=data["manifest_uri"],
25
+ canonical_hash=data.get("canonical_hash", ""),
26
+ )
27
+ return parse_manifest(data)
28
+
29
+
30
+ def extract_manifest(file: Path) -> Manifest:
31
+ """Extract a manifest from standalone JSON, media, or sidecar files."""
32
+ if file.suffix.lower() == ".json":
33
+ return load_standalone_json_manifest(file)
34
+
35
+ mime = guess_mime(file)
36
+ handler = get_handler(mime)
37
+ if handler is not None:
38
+ try:
39
+ return handler.extract(file)
40
+ except EmbeddingError:
41
+ pass
42
+ return SidecarHandler().extract(file)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "genblaze-cli"
7
- version = "0.3.0"
7
+ version = "0.3.2"
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.2.0,<0.4",
26
+ "genblaze-core>=0.3.4,<0.4",
27
27
  "click>=8.0",
28
28
  ]
29
29
 
@@ -48,3 +48,8 @@ packages = ["genblaze_cli"]
48
48
 
49
49
  [tool.pytest.ini_options]
50
50
  testpaths = ["tests"]
51
+
52
+ [tool.deptry]
53
+ # Treat the `dev` extra as dev-only tooling so deptry does not flag pytest
54
+ # (and other test-only deps) as unused declared deps (DEP002).
55
+ optional_dependencies_dev_groups = ["dev"]
@@ -0,0 +1,475 @@
1
+ """Tests for CLI commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import tomllib
7
+ from pathlib import Path
8
+
9
+ from click.testing import CliRunner
10
+ from genblaze_cli.main import cli
11
+ from genblaze_core._utils import MAX_MANIFEST_BYTES
12
+ from genblaze_core.builders import RunBuilder, StepBuilder
13
+ from genblaze_core.canonical.json import canonical_json
14
+ from genblaze_core.media.png import PngHandler
15
+ from genblaze_core.models.asset import Asset
16
+ from genblaze_core.models.enums import Modality, ProviderErrorCode, StepStatus
17
+ from genblaze_core.models.manifest import Manifest
18
+ from genblaze_core.testing import MockProvider
19
+ from PIL import Image
20
+
21
+
22
+ def _create_manifest_json(tmp_path: Path) -> Path:
23
+ """Create a manifest JSON file for testing."""
24
+ step = StepBuilder("test", "test-model").prompt("hello world").modality(Modality.IMAGE).build()
25
+ run = RunBuilder("cli-test").add_step(step).build()
26
+ manifest = Manifest.from_run(run)
27
+
28
+ manifest_path = tmp_path / "manifest.json"
29
+ manifest_path.write_text(manifest.to_canonical_json(), encoding="utf-8")
30
+ return manifest_path
31
+
32
+
33
+ def _create_embedded_png(tmp_path: Path) -> Path:
34
+ """Create a PNG with an embedded manifest."""
35
+ step = StepBuilder("test", "test-model").prompt("hello").build()
36
+ run = RunBuilder("png-test").add_step(step).build()
37
+ manifest = Manifest.from_run(run)
38
+
39
+ png_path = tmp_path / "test.png"
40
+ Image.new("RGBA", (1, 1), (255, 0, 0, 255)).save(png_path)
41
+ PngHandler().embed(png_path, manifest)
42
+ return png_path
43
+
44
+
45
+ def _create_url_only_manifest(*, schema_version: str | None = None) -> Manifest:
46
+ """Create a hash-valid manifest whose output assets are not byte-bound."""
47
+ step = StepBuilder("test", "test-model").prompt("hello").build()
48
+ step.status = StepStatus.SUCCEEDED
49
+ step.assets = [Asset(url="https://cdn.example.com/output.png", media_type="image/png")]
50
+ run = RunBuilder("url-only").add_step(step).build()
51
+ if schema_version is None:
52
+ return Manifest.from_run(run)
53
+ manifest = Manifest(run=run, schema_version=schema_version)
54
+ manifest.compute_hash()
55
+ return manifest
56
+
57
+
58
+ def test_extract_json(tmp_path: Path) -> None:
59
+ png = _create_embedded_png(tmp_path)
60
+ runner = CliRunner()
61
+ result = runner.invoke(cli, ["extract", str(png)])
62
+ assert result.exit_code == 0
63
+ data = json.loads(result.output)
64
+ assert "canonical_hash" in data
65
+ assert "run" in data
66
+
67
+
68
+ def test_extract_json_read_only_schema_is_inspectable(tmp_path: Path) -> None:
69
+ manifest = _create_url_only_manifest(schema_version="1.6")
70
+ png_path = tmp_path / "read-only-schema.png"
71
+ Image.new("RGBA", (1, 1), (255, 0, 0, 255)).save(png_path)
72
+ png_path.with_suffix(png_path.suffix + ".genblaze.json").write_text(
73
+ canonical_json(manifest.model_dump(mode="python")),
74
+ encoding="utf-8",
75
+ )
76
+
77
+ runner = CliRunner()
78
+ result = runner.invoke(cli, ["extract", str(png_path)])
79
+
80
+ assert result.exit_code == 0
81
+ data = json.loads(result.output)
82
+ assert data["schema_version"] == "1.6"
83
+ assert data["canonical_hash"] == manifest.canonical_hash
84
+
85
+
86
+ def test_extract_summary(tmp_path: Path) -> None:
87
+ png = _create_embedded_png(tmp_path)
88
+ runner = CliRunner()
89
+ result = runner.invoke(cli, ["extract", "--format", "summary", str(png)])
90
+ assert result.exit_code == 0
91
+ assert "Run ID:" in result.output
92
+ assert "Hash OK:" in result.output
93
+ assert "Output sha256:" in result.output
94
+ assert "Verified:" in result.output
95
+
96
+
97
+ def test_verify_ok(tmp_path: Path) -> None:
98
+ png = _create_embedded_png(tmp_path)
99
+ runner = CliRunner()
100
+ result = runner.invoke(cli, ["verify", str(png)])
101
+ assert result.exit_code == 0
102
+ assert "OK" in result.output
103
+ assert "asset integrity" not in result.output
104
+
105
+
106
+ def test_verify_standalone_manifest_json(tmp_path: Path) -> None:
107
+ manifest_path = _create_manifest_json(tmp_path)
108
+ runner = CliRunner()
109
+ result = runner.invoke(cli, ["verify", str(manifest_path)])
110
+
111
+ assert result.exit_code == 0
112
+ assert "OK" in result.output
113
+
114
+
115
+ def test_extract_standalone_manifest_json(tmp_path: Path) -> None:
116
+ manifest_path = _create_manifest_json(tmp_path)
117
+ runner = CliRunner()
118
+ result = runner.invoke(cli, ["extract", str(manifest_path)])
119
+
120
+ assert result.exit_code == 0
121
+ data = json.loads(result.output)
122
+ assert data["canonical_hash"]
123
+
124
+
125
+ def test_verify_standalone_manifest_json_case_insensitive_suffix(tmp_path: Path) -> None:
126
+ manifest_path = _create_manifest_json(tmp_path)
127
+ upper_path = tmp_path / "MANIFEST.JSON"
128
+ upper_path.write_text(manifest_path.read_text(encoding="utf-8"), encoding="utf-8")
129
+ runner = CliRunner()
130
+ result = runner.invoke(cli, ["verify", str(upper_path)])
131
+
132
+ assert result.exit_code == 0
133
+ assert "OK" in result.output
134
+
135
+
136
+ def test_verify_direct_sidecar_json(tmp_path: Path) -> None:
137
+ manifest_path = _create_manifest_json(tmp_path)
138
+ sidecar_path = tmp_path / "image.png.genblaze.json"
139
+ sidecar_path.write_text(manifest_path.read_text(encoding="utf-8"), encoding="utf-8")
140
+ runner = CliRunner()
141
+ result = runner.invoke(cli, ["verify", str(sidecar_path)])
142
+
143
+ assert result.exit_code == 0
144
+ assert "OK" in result.output
145
+
146
+
147
+ def test_verify_direct_pointer_sidecar_json_has_actionable_error(tmp_path: Path) -> None:
148
+ sidecar_path = tmp_path / "image.png.genblaze.json"
149
+ sidecar_path.write_text(
150
+ json.dumps(
151
+ {
152
+ "schema_version": "1.5",
153
+ "canonical_hash": "a" * 64,
154
+ "manifest_uri": "https://cdn.example.com/manifest.json",
155
+ }
156
+ ),
157
+ encoding="utf-8",
158
+ )
159
+ runner = CliRunner()
160
+ result = runner.invoke(cli, ["verify", str(sidecar_path)])
161
+ combined = result.output + getattr(result, "stderr", "")
162
+
163
+ assert result.exit_code != 0
164
+ assert "Sidecar is a pointer" in combined
165
+ assert "Fetch the full manifest" in combined
166
+
167
+
168
+ def test_verify_standalone_manifest_json_size_cap(tmp_path: Path) -> None:
169
+ manifest_path = tmp_path / "manifest.json"
170
+ with manifest_path.open("wb") as fh:
171
+ fh.truncate(MAX_MANIFEST_BYTES + 1)
172
+ runner = CliRunner()
173
+ result = runner.invoke(cli, ["verify", str(manifest_path)])
174
+ combined = result.output + getattr(result, "stderr", "")
175
+
176
+ assert result.exit_code != 0
177
+ assert "exceeds size limit" in combined
178
+
179
+
180
+ def test_verify_ok_does_not_claim_remote_bytes_were_hashed(tmp_path: Path) -> None:
181
+ manifest = _create_url_only_manifest()
182
+ manifest.run.steps[0].assets[0].sha256 = "f" * 64
183
+ manifest.compute_hash()
184
+ png_path = tmp_path / "declared-sha.png"
185
+ Image.new("RGBA", (1, 1), (255, 0, 0, 255)).save(png_path)
186
+ PngHandler().embed(png_path, manifest)
187
+
188
+ runner = CliRunner()
189
+ result = runner.invoke(cli, ["verify", str(png_path)])
190
+
191
+ assert result.exit_code == 0
192
+ assert "all output assets declare sha256" in result.output
193
+ assert "Asset bytes were not fetched or compared" in result.output
194
+ assert "asset integrity" not in result.output
195
+
196
+
197
+ def test_verify_distinguishes_unverified_assets(tmp_path: Path) -> None:
198
+ manifest = _create_url_only_manifest()
199
+ png_path = tmp_path / "url-only.png"
200
+ Image.new("RGBA", (1, 1), (255, 0, 0, 255)).save(png_path)
201
+ PngHandler().embed(png_path, manifest)
202
+
203
+ runner = CliRunner()
204
+ result = runner.invoke(cli, ["verify", str(png_path)])
205
+ combined = result.output + getattr(result, "stderr", "")
206
+ assert result.exit_code != 0
207
+ assert "1 output asset(s) missing or malformed sha256" in combined
208
+ assert "hash mismatch" not in combined
209
+
210
+
211
+ def test_verify_hash_only_preserves_legacy_url_only_exit_code(tmp_path: Path) -> None:
212
+ manifest = _create_url_only_manifest()
213
+ png_path = tmp_path / "url-only.png"
214
+ Image.new("RGBA", (1, 1), (255, 0, 0, 255)).save(png_path)
215
+ PngHandler().embed(png_path, manifest)
216
+
217
+ runner = CliRunner()
218
+ result = runner.invoke(cli, ["verify", "--hash-only", str(png_path)])
219
+
220
+ assert result.exit_code == 0
221
+ assert "manifest hash verified" in result.output
222
+ assert "Asset bytes were not fetched or compared" in result.output
223
+
224
+
225
+ def test_verify_rejects_malformed_output_sha256(tmp_path: Path) -> None:
226
+ manifest = _create_url_only_manifest()
227
+ object.__setattr__(manifest.run.steps[0].assets[0], "sha256", "not-a-sha")
228
+ manifest.compute_hash()
229
+ png_path = tmp_path / "bad-sha.png"
230
+ Image.new("RGBA", (1, 1), (255, 0, 0, 255)).save(png_path)
231
+ png_path.with_suffix(png_path.suffix + ".genblaze.json").write_text(
232
+ manifest.to_canonical_json(),
233
+ encoding="utf-8",
234
+ )
235
+
236
+ runner = CliRunner()
237
+ result = runner.invoke(cli, ["verify", str(png_path)])
238
+ combined = result.output + getattr(result, "stderr", "")
239
+
240
+ assert result.exit_code != 0
241
+ assert "1 output asset(s) missing or malformed sha256" in combined
242
+
243
+
244
+ def test_verify_reports_legacy_unverified_assets(tmp_path: Path) -> None:
245
+ manifest = _create_url_only_manifest(schema_version="1.5")
246
+ png_path = tmp_path / "legacy-url-only.png"
247
+ Image.new("RGBA", (1, 1), (255, 0, 0, 255)).save(png_path)
248
+ PngHandler().embed(png_path, manifest)
249
+
250
+ runner = CliRunner()
251
+ result = runner.invoke(cli, ["verify", str(png_path)])
252
+ combined = result.output + getattr(result, "stderr", "")
253
+ assert result.exit_code != 0
254
+ assert "1 output asset(s) missing or malformed sha256" in combined
255
+ assert "hash mismatch" not in combined
256
+
257
+
258
+ def test_extract_summary_and_verify_agree_on_legacy_unverified_assets(tmp_path: Path) -> None:
259
+ manifest = _create_url_only_manifest(schema_version="1.5")
260
+ png_path = tmp_path / "legacy-url-only.png"
261
+ Image.new("RGBA", (1, 1), (255, 0, 0, 255)).save(png_path)
262
+ PngHandler().embed(png_path, manifest)
263
+
264
+ runner = CliRunner()
265
+ extract_result = runner.invoke(cli, ["extract", "--format", "summary", str(png_path)])
266
+ verify_result = runner.invoke(cli, ["verify", str(png_path)])
267
+
268
+ assert extract_result.exit_code == 0
269
+ assert "Hash OK: True" in extract_result.output
270
+ assert "Output sha256: 1 missing or malformed" in extract_result.output
271
+ assert "Verified: False" in extract_result.output
272
+ assert verify_result.exit_code != 0
273
+ assert not manifest.verify()
274
+
275
+
276
+ def test_verify_no_manifest(tmp_path: Path) -> None:
277
+ png = tmp_path / "bare.png"
278
+ Image.new("RGBA", (1, 1)).save(png)
279
+ runner = CliRunner()
280
+ result = runner.invoke(cli, ["verify", str(png)])
281
+ assert result.exit_code != 0
282
+
283
+
284
+ def test_replay_dry_run(tmp_path: Path) -> None:
285
+ manifest_path = _create_manifest_json(tmp_path)
286
+ runner = CliRunner()
287
+ result = runner.invoke(cli, ["replay", str(manifest_path)])
288
+ assert result.exit_code == 0
289
+ assert "Dry run" in result.output
290
+ assert "Step 1:" in result.output
291
+
292
+
293
+ def test_replay_warns_on_unverified_assets_without_abort(tmp_path: Path) -> None:
294
+ manifest = _create_url_only_manifest()
295
+ manifest_path = tmp_path / "url-only.json"
296
+ manifest_path.write_text(manifest.to_canonical_json(), encoding="utf-8")
297
+
298
+ runner = CliRunner()
299
+ result = runner.invoke(cli, ["replay", str(manifest_path)])
300
+ combined = result.output + getattr(result, "stderr", "")
301
+ assert result.exit_code == 0
302
+ assert "output asset bytes are not bound" in combined
303
+ assert "Dry run" in result.output
304
+
305
+
306
+ def test_replay_warns_on_legacy_unverified_assets(tmp_path: Path) -> None:
307
+ manifest = _create_url_only_manifest(schema_version="1.5")
308
+ manifest_path = tmp_path / "legacy-url-only.json"
309
+ manifest_path.write_text(manifest.to_canonical_json(), encoding="utf-8")
310
+
311
+ runner = CliRunner()
312
+ result = runner.invoke(cli, ["replay", str(manifest_path)])
313
+ combined = result.output + getattr(result, "stderr", "")
314
+ assert result.exit_code == 0
315
+ assert "output asset bytes are not bound" in combined
316
+ assert "Dry run" in result.output
317
+
318
+
319
+ def test_cli_core_dependency_floor_matches_local_core() -> None:
320
+ repo_root = Path(__file__).resolve().parents[2]
321
+ cli_project = tomllib.loads((repo_root / "cli/pyproject.toml").read_text())
322
+ core_project = tomllib.loads((repo_root / "libs/core/pyproject.toml").read_text())
323
+
324
+ expected = f"genblaze-core>={core_project['project']['version']},<0.4"
325
+ assert expected in cli_project["project"]["dependencies"]
326
+
327
+
328
+ def test_umbrella_core_dependency_floor_matches_local_core() -> None:
329
+ repo_root = Path(__file__).resolve().parents[2]
330
+ meta_project = tomllib.loads((repo_root / "libs/meta/pyproject.toml").read_text())
331
+ core_project = tomllib.loads((repo_root / "libs/core/pyproject.toml").read_text())
332
+
333
+ expected = f"genblaze-core>={core_project['project']['version']},<0.4"
334
+ assert expected in meta_project["project"]["dependencies"]
335
+
336
+
337
+ def test_umbrella_s3_dependency_floor_matches_local_s3() -> None:
338
+ repo_root = Path(__file__).resolve().parents[2]
339
+ meta_project = tomllib.loads((repo_root / "libs/meta/pyproject.toml").read_text())
340
+ s3_project = tomllib.loads((repo_root / "libs/connectors/s3/pyproject.toml").read_text())
341
+
342
+ expected = f"genblaze-s3>={s3_project['project']['version']},<0.4"
343
+ assert expected in meta_project["project"]["dependencies"]
344
+
345
+
346
+ def test_umbrella_connector_extra_floors_match_local_versions() -> None:
347
+ repo_root = Path(__file__).resolve().parents[2]
348
+ meta_project = tomllib.loads((repo_root / "libs/meta/pyproject.toml").read_text())
349
+ connector_dirs = [
350
+ "decart",
351
+ "elevenlabs",
352
+ "gmicloud",
353
+ "google",
354
+ "hume",
355
+ "langsmith",
356
+ "lmnt",
357
+ "luma",
358
+ "nvidia",
359
+ "openai",
360
+ "replicate",
361
+ "runway",
362
+ "stability-audio",
363
+ ]
364
+ requirements = [
365
+ requirement
366
+ for values in meta_project["project"]["optional-dependencies"].values()
367
+ for requirement in values
368
+ ]
369
+
370
+ for connector_dir in connector_dirs:
371
+ project = tomllib.loads(
372
+ (repo_root / f"libs/connectors/{connector_dir}/pyproject.toml").read_text()
373
+ )
374
+ package = project["project"]["name"]
375
+ expected = f"{package}>={project['project']['version']},<0.4"
376
+ matching = [requirement for requirement in requirements if requirement.startswith(package)]
377
+ assert matching
378
+ assert set(matching) == {expected}
379
+
380
+
381
+ def test_replay_redacts_prompts_by_default(tmp_path: Path) -> None:
382
+ """Dry-run summary must redact prompts unless --show-prompts is passed."""
383
+ manifest_path = _create_manifest_json(tmp_path)
384
+ runner = CliRunner()
385
+ result = runner.invoke(cli, ["replay", str(manifest_path)])
386
+ assert result.exit_code == 0
387
+ assert "hello world" not in result.output
388
+ assert "[redacted" in result.output
389
+
390
+
391
+ def test_replay_shows_public_prompts_with_flag(tmp_path: Path) -> None:
392
+ """--show-prompts reveals public-visibility prompts."""
393
+ manifest_path = _create_manifest_json(tmp_path)
394
+ runner = CliRunner()
395
+ result = runner.invoke(cli, ["replay", str(manifest_path), "--show-prompts"])
396
+ assert result.exit_code == 0
397
+ assert "hello world" in result.output
398
+
399
+
400
+ def test_replay_redacts_private_prompts_even_with_flag(tmp_path: Path) -> None:
401
+ """--show-prompts must NOT reveal non-public prompts."""
402
+ from genblaze_core.models.enums import PromptVisibility
403
+
404
+ step = (
405
+ StepBuilder("test", "test-model")
406
+ .prompt("secret prompt content")
407
+ .modality(Modality.IMAGE)
408
+ .build()
409
+ )
410
+ step.prompt_visibility = PromptVisibility.PRIVATE
411
+ run = RunBuilder("cli-test").add_step(step).build()
412
+ manifest = Manifest.from_run(run)
413
+ manifest_path = tmp_path / "private.json"
414
+ manifest_path.write_text(manifest.to_canonical_json(), encoding="utf-8")
415
+
416
+ runner = CliRunner()
417
+ result = runner.invoke(cli, ["replay", str(manifest_path), "--show-prompts"])
418
+ assert result.exit_code == 0
419
+ assert "secret prompt content" not in result.output
420
+ assert "[redacted" in result.output
421
+
422
+
423
+ def test_replay_aborts_when_no_allowlist_and_declined(tmp_path: Path) -> None:
424
+ """Non-dry-run replay without --allow-provider must prompt for confirmation
425
+ and abort on 'no'."""
426
+ manifest_path = _create_manifest_json(tmp_path)
427
+ runner = CliRunner()
428
+ # Respond "n" to the per-provider confirmation prompt
429
+ result = runner.invoke(cli, ["replay", str(manifest_path), "--no-dry-run"], input="n\n")
430
+ assert result.exit_code != 0
431
+ assert "Aborted" in result.output or "Execute with provider" in result.output
432
+
433
+
434
+ def test_replay_no_dry_run_exits_nonzero_when_run_fails(tmp_path: Path, monkeypatch) -> None:
435
+ """A replayed failed run must not look successful to automation."""
436
+
437
+ class FailingReplayProvider(MockProvider):
438
+ def __init__(self) -> None:
439
+ super().__init__(
440
+ name="test",
441
+ should_fail=True,
442
+ error_code=ProviderErrorCode.AUTH_FAILURE,
443
+ error_message="missing credentials",
444
+ )
445
+
446
+ import genblaze_core.providers.registry as provider_registry
447
+
448
+ monkeypatch.setattr(
449
+ provider_registry,
450
+ "discover_providers",
451
+ lambda: {"test": FailingReplayProvider},
452
+ )
453
+ manifest_path = _create_manifest_json(tmp_path)
454
+ runner = CliRunner()
455
+
456
+ result = runner.invoke(
457
+ cli,
458
+ ["replay", str(manifest_path), "--no-dry-run", "--allow-provider", "test"],
459
+ )
460
+
461
+ assert result.exit_code != 0
462
+ assert "Replay failed" in result.stderr
463
+ assert "Replay failed" not in result.stdout
464
+ assert "Replay complete" not in result.stdout
465
+
466
+
467
+ def test_index(tmp_path: Path) -> None:
468
+ manifest_path = _create_manifest_json(tmp_path)
469
+ out_dir = tmp_path / "index_out"
470
+ runner = CliRunner()
471
+ result = runner.invoke(cli, ["index", str(manifest_path), "-o", str(out_dir)])
472
+ assert result.exit_code == 0
473
+ assert "Indexed" in result.output
474
+ parquet_files = list(out_dir.rglob("*.parquet"))
475
+ assert len(parquet_files) > 0
@@ -1,40 +0,0 @@
1
- """Extract command — print embedded manifest from a media file."""
2
-
3
- from pathlib import Path
4
-
5
- import click
6
- from genblaze_core.exceptions import EmbeddingError
7
- from genblaze_core.media import get_handler, guess_mime
8
- from genblaze_core.media.sidecar import SidecarHandler
9
-
10
-
11
- def _extract_manifest(file: Path):
12
- """Try format-specific handler first, then sidecar fallback."""
13
- mime = guess_mime(file)
14
- handler = get_handler(mime)
15
- if handler is not None:
16
- try:
17
- return handler.extract(file)
18
- except EmbeddingError:
19
- pass # Expected: no manifest in this format, try sidecar
20
- # Try sidecar fallback
21
- sidecar = SidecarHandler()
22
- return sidecar.extract(file)
23
-
24
-
25
- @click.command()
26
- @click.argument("file", type=click.Path(exists=True, path_type=Path))
27
- @click.option("--format", "fmt", type=click.Choice(["json", "summary"]), default="json")
28
- def extract(file: Path, fmt: str) -> None:
29
- """Extract and display the genblaze manifest from a media file."""
30
- try:
31
- manifest = _extract_manifest(file)
32
- if fmt == "json":
33
- click.echo(manifest.to_canonical_json())
34
- else:
35
- click.echo(f"Run ID: {manifest.run.run_id}")
36
- click.echo(f"Steps: {len(manifest.run.steps)}")
37
- click.echo(f"Hash: {manifest.canonical_hash}")
38
- click.echo(f"Verified: {manifest.verify()}")
39
- except Exception as exc:
40
- raise click.ClickException(f"{type(exc).__name__}: {exc}") from exc
@@ -1,41 +0,0 @@
1
- """Verify command — check manifest hash integrity."""
2
-
3
- from pathlib import Path
4
-
5
- import click
6
- from genblaze_core.exceptions import EmbeddingError
7
- from genblaze_core.media import get_handler, guess_mime
8
- from genblaze_core.media.sidecar import SidecarHandler
9
-
10
-
11
- def _verify_manifest(file: Path) -> bool:
12
- """Extract and verify manifest, trying format-specific handler then sidecar."""
13
- mime = guess_mime(file)
14
- handler = get_handler(mime)
15
- if handler is not None:
16
- try:
17
- return handler.verify(file)
18
- except EmbeddingError:
19
- pass # Expected: no manifest in this format, try sidecar
20
- # Try sidecar fallback
21
- sidecar = SidecarHandler()
22
- return sidecar.verify(file)
23
-
24
-
25
- @click.command()
26
- @click.argument("file", type=click.Path(exists=True, path_type=Path))
27
- def verify(file: Path) -> None:
28
- """Verify the integrity of a genblaze manifest in a media file."""
29
- try:
30
- valid = _verify_manifest(file)
31
- if valid:
32
- click.echo("OK — manifest hash verified.")
33
- else:
34
- click.echo("FAIL — manifest hash mismatch.", err=True)
35
- raise click.exceptions.Exit(1)
36
- except click.exceptions.Exit:
37
- raise
38
- except Exception as exc:
39
- # Prefix with exception type so "PermissionError: ..." and
40
- # "EmbeddingError: ..." are distinguishable at a glance.
41
- raise click.ClickException(f"{type(exc).__name__}: {exc}") from exc
@@ -1,145 +0,0 @@
1
- """Tests for CLI commands."""
2
-
3
- from __future__ import annotations
4
-
5
- import json
6
- from pathlib import Path
7
-
8
- from click.testing import CliRunner
9
- from genblaze_cli.main import cli
10
- from genblaze_core.builders import RunBuilder, StepBuilder
11
- from genblaze_core.media.png import PngHandler
12
- from genblaze_core.models.enums import Modality
13
- from genblaze_core.models.manifest import Manifest
14
- from PIL import Image
15
-
16
-
17
- def _create_manifest_json(tmp_path: Path) -> Path:
18
- """Create a manifest JSON file for testing."""
19
- step = StepBuilder("test", "test-model").prompt("hello world").modality(Modality.IMAGE).build()
20
- run = RunBuilder("cli-test").add_step(step).build()
21
- manifest = Manifest.from_run(run)
22
-
23
- manifest_path = tmp_path / "manifest.json"
24
- manifest_path.write_text(manifest.to_canonical_json(), encoding="utf-8")
25
- return manifest_path
26
-
27
-
28
- def _create_embedded_png(tmp_path: Path) -> Path:
29
- """Create a PNG with an embedded manifest."""
30
- step = StepBuilder("test", "test-model").prompt("hello").build()
31
- run = RunBuilder("png-test").add_step(step).build()
32
- manifest = Manifest.from_run(run)
33
-
34
- png_path = tmp_path / "test.png"
35
- Image.new("RGBA", (1, 1), (255, 0, 0, 255)).save(png_path)
36
- PngHandler().embed(png_path, manifest)
37
- return png_path
38
-
39
-
40
- def test_extract_json(tmp_path: Path) -> None:
41
- png = _create_embedded_png(tmp_path)
42
- runner = CliRunner()
43
- result = runner.invoke(cli, ["extract", str(png)])
44
- assert result.exit_code == 0
45
- data = json.loads(result.output)
46
- assert "canonical_hash" in data
47
- assert "run" in data
48
-
49
-
50
- def test_extract_summary(tmp_path: Path) -> None:
51
- png = _create_embedded_png(tmp_path)
52
- runner = CliRunner()
53
- result = runner.invoke(cli, ["extract", "--format", "summary", str(png)])
54
- assert result.exit_code == 0
55
- assert "Run ID:" in result.output
56
- assert "Verified:" in result.output
57
-
58
-
59
- def test_verify_ok(tmp_path: Path) -> None:
60
- png = _create_embedded_png(tmp_path)
61
- runner = CliRunner()
62
- result = runner.invoke(cli, ["verify", str(png)])
63
- assert result.exit_code == 0
64
- assert "OK" in result.output
65
-
66
-
67
- def test_verify_no_manifest(tmp_path: Path) -> None:
68
- png = tmp_path / "bare.png"
69
- Image.new("RGBA", (1, 1)).save(png)
70
- runner = CliRunner()
71
- result = runner.invoke(cli, ["verify", str(png)])
72
- assert result.exit_code != 0
73
-
74
-
75
- def test_replay_dry_run(tmp_path: Path) -> None:
76
- manifest_path = _create_manifest_json(tmp_path)
77
- runner = CliRunner()
78
- result = runner.invoke(cli, ["replay", str(manifest_path)])
79
- assert result.exit_code == 0
80
- assert "Dry run" in result.output
81
- assert "Step 1:" in result.output
82
-
83
-
84
- def test_replay_redacts_prompts_by_default(tmp_path: Path) -> None:
85
- """Dry-run summary must redact prompts unless --show-prompts is passed."""
86
- manifest_path = _create_manifest_json(tmp_path)
87
- runner = CliRunner()
88
- result = runner.invoke(cli, ["replay", str(manifest_path)])
89
- assert result.exit_code == 0
90
- assert "hello world" not in result.output
91
- assert "[redacted" in result.output
92
-
93
-
94
- def test_replay_shows_public_prompts_with_flag(tmp_path: Path) -> None:
95
- """--show-prompts reveals public-visibility prompts."""
96
- manifest_path = _create_manifest_json(tmp_path)
97
- runner = CliRunner()
98
- result = runner.invoke(cli, ["replay", str(manifest_path), "--show-prompts"])
99
- assert result.exit_code == 0
100
- assert "hello world" in result.output
101
-
102
-
103
- def test_replay_redacts_private_prompts_even_with_flag(tmp_path: Path) -> None:
104
- """--show-prompts must NOT reveal non-public prompts."""
105
- from genblaze_core.models.enums import PromptVisibility
106
-
107
- step = (
108
- StepBuilder("test", "test-model")
109
- .prompt("secret prompt content")
110
- .modality(Modality.IMAGE)
111
- .build()
112
- )
113
- step.prompt_visibility = PromptVisibility.PRIVATE
114
- run = RunBuilder("cli-test").add_step(step).build()
115
- manifest = Manifest.from_run(run)
116
- manifest_path = tmp_path / "private.json"
117
- manifest_path.write_text(manifest.to_canonical_json(), encoding="utf-8")
118
-
119
- runner = CliRunner()
120
- result = runner.invoke(cli, ["replay", str(manifest_path), "--show-prompts"])
121
- assert result.exit_code == 0
122
- assert "secret prompt content" not in result.output
123
- assert "[redacted" in result.output
124
-
125
-
126
- def test_replay_aborts_when_no_allowlist_and_declined(tmp_path: Path) -> None:
127
- """Non-dry-run replay without --allow-provider must prompt for confirmation
128
- and abort on 'no'."""
129
- manifest_path = _create_manifest_json(tmp_path)
130
- runner = CliRunner()
131
- # Respond "n" to the per-provider confirmation prompt
132
- result = runner.invoke(cli, ["replay", str(manifest_path), "--no-dry-run"], input="n\n")
133
- assert result.exit_code != 0
134
- assert "Aborted" in result.output or "Execute with provider" in result.output
135
-
136
-
137
- def test_index(tmp_path: Path) -> None:
138
- manifest_path = _create_manifest_json(tmp_path)
139
- out_dir = tmp_path / "index_out"
140
- runner = CliRunner()
141
- result = runner.invoke(cli, ["index", str(manifest_path), "-o", str(out_dir)])
142
- assert result.exit_code == 0
143
- assert "Indexed" in result.output
144
- parquet_files = list(out_dir.rglob("*.parquet"))
145
- assert len(parquet_files) > 0
File without changes