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.
@@ -54,7 +54,12 @@ ehthumbs.db
54
54
  !.env.example
55
55
  *.pem
56
56
  *.key
57
- credentials.json
57
+ credentials*
58
+ *.credentials
59
+ *_rsa
60
+ *.p12
61
+ *.pfx
62
+ *.keystore
58
63
 
59
64
  # Jupyter
60
65
  .ipynb_checkpoints/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: genblaze-cli
3
- Version: 0.3.2
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.4
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
- def extract(file: Path, fmt: str) -> None:
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
- click.echo(_manifest_json_for_display(manifest))
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
@@ -7,7 +7,7 @@ import click
7
7
 
8
8
 
9
9
  @click.command()
10
- @click.argument("manifest_file", type=click.Path(exists=True, path_type=Path))
10
+ @click.argument("manifest_file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
11
11
  @click.option(
12
12
  "--output",
13
13
  "-o",
@@ -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
- def _load_provider(provider_name: str, allowed: tuple[str, ...] | None):
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({step.provider for step in run.steps})
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, object] = {}
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; do not require output sha256 declarations.",
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.2"
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.4,<0.4",
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
- parquet_files = list(out_dir.rglob("*.parquet"))
475
- assert len(parquet_files) > 0
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