genblaze-cli 0.2.0__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.
@@ -0,0 +1,76 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+
7
+ # Distribution / packaging
8
+ dist/
9
+ build/
10
+ *.egg-info/
11
+ *.egg
12
+ wheels/
13
+ /MANIFEST
14
+
15
+ # Virtual environments
16
+ .venv/
17
+ venv/
18
+ env/
19
+ ENV/
20
+
21
+ # IDE / editors
22
+ .vscode/
23
+ .idea/
24
+ *.swp
25
+ *.swo
26
+ *~
27
+ .project
28
+ .settings/
29
+
30
+ # Testing / coverage
31
+ .pytest_cache/
32
+ .coverage
33
+ .coverage.*
34
+ htmlcov/
35
+ coverage.xml
36
+ *.cover
37
+
38
+ # Type checking
39
+ .mypy_cache/
40
+ .pytype/
41
+ .pyre/
42
+
43
+ # Linting
44
+ .ruff_cache/
45
+
46
+ # OS files
47
+ .DS_Store
48
+ Thumbs.db
49
+ ehthumbs.db
50
+
51
+ # Environment / secrets
52
+ .env
53
+ .env.*
54
+ !.env.example
55
+ *.pem
56
+ *.key
57
+ credentials.json
58
+
59
+ # Jupyter
60
+ .ipynb_checkpoints/
61
+
62
+ # MkDocs build output
63
+ site/
64
+
65
+ # Claude Code — local/ephemeral
66
+ .claude/settings.local.json
67
+ .claude/worktrees/
68
+ CLAUDE.local.md
69
+
70
+ # Hypothesis test cache
71
+ .hypothesis/
72
+
73
+ # Temp files
74
+ *.tmp
75
+ *.bak
76
+ *.log
@@ -0,0 +1,88 @@
1
+ Metadata-Version: 2.4
2
+ Name: genblaze-cli
3
+ Version: 0.2.0
4
+ Summary: CLI for genblaze manifest operations
5
+ Project-URL: Homepage, https://github.com/backblaze-labs/genblaze
6
+ Project-URL: Repository, https://github.com/backblaze-labs/genblaze
7
+ Project-URL: Changelog, https://github.com/backblaze-labs/genblaze/blob/main/CHANGELOG.md
8
+ Project-URL: Issues, https://github.com/backblaze-labs/genblaze/issues
9
+ Author-email: Jeronimo De Leon <jdeleon@backblaze.com>
10
+ License-Expression: MIT
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Requires-Python: >=3.11
15
+ Requires-Dist: click>=8.0
16
+ Requires-Dist: genblaze-core<0.3,>=0.2.0
17
+ Provides-Extra: dev
18
+ Requires-Dist: pillow>=10.0; extra == 'dev'
19
+ Requires-Dist: pytest>=7.0; extra == 'dev'
20
+ Description-Content-Type: text/markdown
21
+
22
+ <!-- last_verified: 2026-04-22 -->
23
+ # genblaze-cli
24
+
25
+ **Command-line toolkit for inspecting, verifying, and indexing genblaze AI-generated-media provenance manifests.**
26
+
27
+ `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.
28
+
29
+ ## Why genblaze-cli
30
+
31
+ - **Audit AI-generated media in one command** — `genblaze verify video.mp4` confirms a file's manifest hash hasn't been tampered with.
32
+ - **Works on any genblaze output** — PNG, JPEG, WebP, MP4, MP3, WAV with embedded manifests.
33
+ - **Analytics-ready** — `genblaze index` emits partitioned Parquet tables (runs, steps, assets) for BI tools and data warehouses.
34
+ - **Zero provider dependencies** — reads manifests; doesn't call any AI API.
35
+ - **Shell-friendly** — non-zero exit codes on verification failure, pipeable JSON output.
36
+
37
+ ## Install
38
+
39
+ ```bash
40
+ pip install genblaze-cli
41
+ ```
42
+
43
+ Installs the `genblaze` console script.
44
+
45
+ ## Usage
46
+
47
+ ```bash
48
+ genblaze --help
49
+
50
+ genblaze extract video.mp4 # Extract embedded manifest → stdout (JSON)
51
+ genblaze extract video.mp4 -o m.json # …or to a file
52
+
53
+ genblaze verify video.mp4 # Verify the embedded manifest's SHA-256
54
+ genblaze verify manifest.json # Or verify a standalone manifest file
55
+
56
+ genblaze replay manifest.json # Show what a replay would do (dry run)
57
+
58
+ genblaze index manifest.json -o data/ # Index into partitioned Parquet tables
59
+ ```
60
+
61
+ Exit codes are non-zero on verification failure — safe to drop into CI pipelines, release checks, or content-moderation workflows.
62
+
63
+ ## Typical flow
64
+
65
+ ```bash
66
+ # 1. Someone ships you an AI-generated video
67
+ genblaze extract delivered-ad.mp4 -o manifest.json
68
+
69
+ # 2. Confirm it hasn't been tampered with
70
+ genblaze verify delivered-ad.mp4
71
+
72
+ # 3. Index into your analytics warehouse
73
+ genblaze index manifest.json -o s3://analytics/genblaze/
74
+ ```
75
+
76
+ ## Documentation
77
+
78
+ - **Main repo**: https://github.com/backblaze-labs/genblaze
79
+ - **CLI reference**: https://github.com/backblaze-labs/genblaze/tree/main/cli
80
+
81
+ ## Related packages
82
+
83
+ - [`genblaze-core`](https://pypi.org/project/genblaze-core/) — the pipeline SDK that produces these manifests
84
+ - [`genblaze-s3`](https://pypi.org/project/genblaze-s3/) — durable storage on [Backblaze B2](https://www.backblaze.com/cloud-storage?utm_source=github&utm_medium=referral&utm_campaign=ai_artifacts&utm_content=genblaze), AWS S3, Cloudflare R2, MinIO
85
+
86
+ ## License
87
+
88
+ MIT
@@ -0,0 +1,67 @@
1
+ <!-- last_verified: 2026-04-22 -->
2
+ # genblaze-cli
3
+
4
+ **Command-line toolkit for inspecting, verifying, and indexing genblaze AI-generated-media provenance manifests.**
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.
7
+
8
+ ## Why genblaze-cli
9
+
10
+ - **Audit AI-generated media in one command** — `genblaze verify video.mp4` confirms a file's manifest hash hasn't been tampered with.
11
+ - **Works on any genblaze output** — PNG, JPEG, WebP, MP4, MP3, WAV with embedded manifests.
12
+ - **Analytics-ready** — `genblaze index` emits partitioned Parquet tables (runs, steps, assets) for BI tools and data warehouses.
13
+ - **Zero provider dependencies** — reads manifests; doesn't call any AI API.
14
+ - **Shell-friendly** — non-zero exit codes on verification failure, pipeable JSON output.
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ pip install genblaze-cli
20
+ ```
21
+
22
+ Installs the `genblaze` console script.
23
+
24
+ ## Usage
25
+
26
+ ```bash
27
+ genblaze --help
28
+
29
+ genblaze extract video.mp4 # Extract embedded manifest → stdout (JSON)
30
+ genblaze extract video.mp4 -o m.json # …or to a file
31
+
32
+ genblaze verify video.mp4 # Verify the embedded manifest's SHA-256
33
+ genblaze verify manifest.json # Or verify a standalone manifest file
34
+
35
+ genblaze replay manifest.json # Show what a replay would do (dry run)
36
+
37
+ genblaze index manifest.json -o data/ # Index into partitioned Parquet tables
38
+ ```
39
+
40
+ Exit codes are non-zero on verification failure — safe to drop into CI pipelines, release checks, or content-moderation workflows.
41
+
42
+ ## Typical flow
43
+
44
+ ```bash
45
+ # 1. Someone ships you an AI-generated video
46
+ genblaze extract delivered-ad.mp4 -o manifest.json
47
+
48
+ # 2. Confirm it hasn't been tampered with
49
+ genblaze verify delivered-ad.mp4
50
+
51
+ # 3. Index into your analytics warehouse
52
+ genblaze index manifest.json -o s3://analytics/genblaze/
53
+ ```
54
+
55
+ ## Documentation
56
+
57
+ - **Main repo**: https://github.com/backblaze-labs/genblaze
58
+ - **CLI reference**: https://github.com/backblaze-labs/genblaze/tree/main/cli
59
+
60
+ ## Related packages
61
+
62
+ - [`genblaze-core`](https://pypi.org/project/genblaze-core/) — the pipeline SDK that produces these manifests
63
+ - [`genblaze-s3`](https://pypi.org/project/genblaze-s3/) — durable storage on [Backblaze B2](https://www.backblaze.com/cloud-storage?utm_source=github&utm_medium=referral&utm_campaign=ai_artifacts&utm_content=genblaze), AWS S3, Cloudflare R2, MinIO
64
+
65
+ ## License
66
+
67
+ MIT
@@ -0,0 +1 @@
1
+ """genblaze CLI."""
File without changes
@@ -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.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
@@ -0,0 +1,34 @@
1
+ """Index command — write manifest data to a Parquet sink."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import click
7
+
8
+
9
+ @click.command()
10
+ @click.argument("manifest_file", type=click.Path(exists=True, path_type=Path))
11
+ @click.option(
12
+ "--output",
13
+ "-o",
14
+ type=click.Path(path_type=Path),
15
+ default="./genblaze_index",
16
+ help="Base directory for Parquet output.",
17
+ )
18
+ def index(manifest_file: Path, output: Path) -> None:
19
+ """Write manifest data to a Parquet sink for querying."""
20
+ from genblaze_core.models.manifest import Manifest
21
+ from genblaze_core.sinks.parquet import ParquetSink
22
+
23
+ try:
24
+ data = json.loads(manifest_file.read_text(encoding="utf-8"))
25
+ manifest = Manifest.model_validate(data)
26
+ except Exception as exc:
27
+ raise click.ClickException(f"Failed to load manifest: {exc}") from exc
28
+
29
+ try:
30
+ sink = ParquetSink(output)
31
+ sink.write_run(manifest.run, manifest)
32
+ click.echo(f"Indexed run {manifest.run.run_id} to {output}")
33
+ except Exception as exc:
34
+ raise click.ClickException(f"Failed to write Parquet: {exc}") from exc
@@ -0,0 +1,211 @@
1
+ """Replay command — re-execute a pipeline from a manifest file."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import click
7
+ from genblaze_core.models.enums import PromptVisibility
8
+
9
+
10
+ def _load_provider(provider_name: str, allowed: tuple[str, ...] | None):
11
+ """Load a provider by name using entry point discovery.
12
+
13
+ If allowed is set, reject providers not in the allowlist.
14
+ """
15
+ if allowed and provider_name not in allowed:
16
+ raise click.ClickException(
17
+ f"Provider '{provider_name}' not in allowlist: {', '.join(allowed)}"
18
+ )
19
+ from genblaze_core.providers.registry import discover_providers
20
+
21
+ registry = discover_providers()
22
+ cls = registry.get(provider_name)
23
+ if cls is None:
24
+ available = ", ".join(sorted(registry.keys())) or "(none installed)"
25
+ raise click.ClickException(
26
+ f"Unknown provider '{provider_name}'. Installed providers: {available}"
27
+ )
28
+ click.echo(f" Loading provider: {provider_name}", err=True)
29
+ return cls()
30
+
31
+
32
+ def _detect_chain_mode(run) -> bool:
33
+ """Detect if the original pipeline used chain mode by checking step inputs.
34
+
35
+ If any step has _input_from metadata, this is a fan-in pipeline (not chain).
36
+ Otherwise, if every non-first step has inputs, it was likely chain mode.
37
+ """
38
+ has_input_from = any(step.metadata.get("_input_from") is not None for step in run.steps)
39
+ if has_input_from:
40
+ return False
41
+ non_first = run.steps[1:]
42
+ return bool(non_first) and all(len(step.inputs) > 0 for step in non_first)
43
+
44
+
45
+ def _print_summary(run, *, show_prompts: bool) -> None:
46
+ """Print manifest run summary.
47
+
48
+ Prompts are only shown when ``show_prompts`` is True AND the step's
49
+ prompt_visibility is PUBLIC. Non-public prompts are redacted to avoid
50
+ leaking private content to terminals/CI logs when replaying manifests
51
+ pulled from shared storage.
52
+ """
53
+ click.echo(f"Run: {run.run_id}")
54
+ click.echo(f"Name: {run.name or '(unnamed)'}")
55
+ click.echo(f"Steps: {len(run.steps)}")
56
+ click.echo()
57
+
58
+ for i, step in enumerate(run.steps, 1):
59
+ click.echo(f" Step {i}: {step.provider}/{step.model}")
60
+ click.echo(f" Type: {step.step_type}")
61
+ if show_prompts and step.prompt_visibility == PromptVisibility.PUBLIC:
62
+ click.echo(f" Prompt: {step.prompt or '(none)'}")
63
+ else:
64
+ click.echo(
65
+ f" Prompt: [redacted — visibility={step.prompt_visibility};"
66
+ " pass --show-prompts to reveal public prompts]"
67
+ )
68
+ click.echo(f" Modality: {step.modality}")
69
+ if step.params:
70
+ click.echo(f" Params: {step.params}")
71
+
72
+
73
+ # Fields that cannot be faithfully replayed (logged as warnings)
74
+ _UNREPLAYABLE_FIELDS = [
75
+ ("prompt_visibility", lambda s: s.prompt_visibility != "public"),
76
+ ]
77
+
78
+
79
+ @click.command()
80
+ @click.argument("manifest_file", type=click.Path(exists=True, path_type=Path))
81
+ @click.option("--dry-run/--no-dry-run", default=True, help="Preview without executing.")
82
+ @click.option(
83
+ "--allow-provider",
84
+ multiple=True,
85
+ help="Only allow these providers (can be specified multiple times).",
86
+ )
87
+ @click.option(
88
+ "--show-prompts",
89
+ is_flag=True,
90
+ help="Show public-visibility prompts in the summary. Non-public prompts are always redacted.",
91
+ )
92
+ @click.option("--force", is_flag=True, help="Skip manifest hash verification.")
93
+ def replay(
94
+ manifest_file: Path,
95
+ dry_run: bool,
96
+ allow_provider: tuple[str, ...],
97
+ show_prompts: bool,
98
+ force: bool,
99
+ ) -> None:
100
+ """Re-execute a pipeline from a manifest JSON file."""
101
+ from genblaze_core.models.manifest import parse_manifest
102
+ from genblaze_core.pipeline import Pipeline
103
+
104
+ try:
105
+ data = json.loads(manifest_file.read_text(encoding="utf-8"))
106
+ manifest = parse_manifest(data)
107
+ except Exception as exc:
108
+ raise click.ClickException(f"Failed to load manifest: {exc}") from exc
109
+
110
+ # Verify manifest hash integrity
111
+ if not force and not manifest.verify():
112
+ click.echo(
113
+ "WARNING: Manifest hash does not match content. The file may have been modified.",
114
+ err=True,
115
+ )
116
+ if not click.confirm("Continue anyway?"):
117
+ raise click.Abort()
118
+
119
+ run = manifest.run
120
+ _print_summary(run, show_prompts=show_prompts)
121
+
122
+ if dry_run:
123
+ click.echo()
124
+ click.echo("Dry run — no steps executed. Use --no-dry-run to execute.")
125
+ return
126
+
127
+ # Build and execute pipeline from manifest steps
128
+ click.echo()
129
+ click.echo("Replaying pipeline...")
130
+
131
+ allowed: tuple[str, ...] | None = allow_provider if allow_provider else None
132
+
133
+ # Safety: when no allowlist is provided, confirm each unique provider.
134
+ # A hostile manifest could reference any installed provider and execute
135
+ # paid API calls on the user's credentials without consent.
136
+ if allowed is None:
137
+ unique_providers = sorted({step.provider for step in run.steps})
138
+ click.echo(
139
+ "No --allow-provider allowlist set. Confirm each provider before execution:",
140
+ err=True,
141
+ )
142
+ confirmed: list[str] = []
143
+ for name in unique_providers:
144
+ if click.confirm(f" Execute with provider '{name}'?", default=False):
145
+ confirmed.append(name)
146
+ else:
147
+ raise click.Abort()
148
+ allowed = tuple(confirmed)
149
+
150
+ # Detect chain mode from step inputs in the manifest
151
+ chain = _detect_chain_mode(run)
152
+ if chain:
153
+ click.echo(" Detected chain mode from manifest step inputs.", err=True)
154
+
155
+ # Cache providers by name to avoid re-instantiating
156
+ providers: dict[str, object] = {}
157
+ pipe = Pipeline(
158
+ run.name,
159
+ tenant_id=run.tenant_id,
160
+ project_id=run.project_id,
161
+ chain=chain,
162
+ )
163
+
164
+ # Reserved kwargs that Pipeline.step() accepts explicitly
165
+ _reserved = {"model", "prompt", "modality", "step_type", "seed", "negative_prompt"}
166
+
167
+ for step_idx, step in enumerate(run.steps):
168
+ if step.provider not in providers:
169
+ providers[step.provider] = _load_provider(step.provider, allowed)
170
+
171
+ # Warn about fields that can't be faithfully replayed
172
+ for field_name, has_value in _UNREPLAYABLE_FIELDS:
173
+ if has_value(step):
174
+ click.echo(
175
+ f" WARNING: Step {step_idx + 1} has {field_name}={getattr(step, field_name)}"
176
+ f" which may not replay identically.",
177
+ err=True,
178
+ )
179
+
180
+ # Strip reserved keys from params to avoid TypeError on collision
181
+ extra_params = {k: v for k, v in step.params.items() if k not in _reserved}
182
+
183
+ # Restore seed and negative_prompt if present
184
+ if step.seed is not None:
185
+ extra_params["seed"] = step.seed
186
+ if step.negative_prompt is not None:
187
+ extra_params["negative_prompt"] = step.negative_prompt
188
+
189
+ # Restore fallback_models and input_from from step metadata
190
+ fallback_models = step.metadata.get("_fallback_models")
191
+ input_from = step.metadata.get("_input_from")
192
+
193
+ pipe.step(
194
+ providers[step.provider],
195
+ model=step.model,
196
+ prompt=step.prompt,
197
+ modality=step.modality,
198
+ step_type=step.step_type,
199
+ fallback_models=fallback_models,
200
+ input_from=input_from,
201
+ **extra_params,
202
+ )
203
+
204
+ try:
205
+ result = pipe.run()
206
+ click.echo()
207
+ click.echo(f"Replay complete. New run ID: {result.run.run_id}")
208
+ click.echo(f"Hash: {result.manifest.canonical_hash}")
209
+ click.echo(f"Status: {result.run.status}")
210
+ except Exception as exc:
211
+ raise click.ClickException(f"Replay failed: {exc}") from exc
@@ -0,0 +1,41 @@
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
@@ -0,0 +1,20 @@
1
+ """CLI entry point."""
2
+
3
+ import click
4
+
5
+ from genblaze_cli.commands.extract import extract
6
+ from genblaze_cli.commands.index import index
7
+ from genblaze_cli.commands.replay import replay
8
+ from genblaze_cli.commands.verify import verify
9
+
10
+
11
+ @click.group()
12
+ @click.version_option(package_name="genblaze-cli")
13
+ def cli() -> None:
14
+ """genblaze — media generation manifest toolkit."""
15
+
16
+
17
+ cli.add_command(extract)
18
+ cli.add_command(index)
19
+ cli.add_command(replay)
20
+ cli.add_command(verify)
File without changes
@@ -0,0 +1,42 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "genblaze-cli"
7
+ version = "0.2.0"
8
+ description = "CLI for genblaze manifest operations"
9
+ authors = [{name = "Jeronimo De Leon", email = "jdeleon@backblaze.com"}]
10
+ readme = "README.md"
11
+ requires-python = ">=3.11"
12
+ license = "MIT"
13
+ classifiers = [
14
+ "Development Status :: 3 - Alpha",
15
+ "License :: OSI Approved :: MIT License",
16
+ "Environment :: Console",
17
+ ]
18
+ dependencies = [
19
+ "genblaze-core>=0.2.0,<0.3",
20
+ "click>=8.0",
21
+ ]
22
+
23
+ [project.urls]
24
+ Homepage = "https://github.com/backblaze-labs/genblaze"
25
+ Repository = "https://github.com/backblaze-labs/genblaze"
26
+ Changelog = "https://github.com/backblaze-labs/genblaze/blob/main/CHANGELOG.md"
27
+ Issues = "https://github.com/backblaze-labs/genblaze/issues"
28
+
29
+ [project.optional-dependencies]
30
+ dev = [
31
+ "pytest>=7.0",
32
+ "pillow>=10.0",
33
+ ]
34
+
35
+ [project.scripts]
36
+ genblaze = "genblaze_cli.main:cli"
37
+
38
+ [tool.hatch.build.targets.wheel]
39
+ packages = ["genblaze_cli"]
40
+
41
+ [tool.pytest.ini_options]
42
+ testpaths = ["tests"]
File without changes
@@ -0,0 +1,145 @@
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