chore: rename bulkgen to hokusai
All checks were successful
Continuous Integration / Build Package (push) Successful in 35s
Continuous Integration / Lint, Check & Test (push) Successful in 57s

This commit is contained in:
Konstantin Fickel 2026-02-20 17:08:12 +01:00
parent a28cc97aed
commit 4def49350e
Signed by: kfickel
GPG key ID: A793722F9933C1A5
32 changed files with 215 additions and 213 deletions

4
.gitignore vendored
View file

@ -14,8 +14,8 @@ wheels/
.devenv.flake.nix .devenv.flake.nix
.pre-commit-config.yaml .pre-commit-config.yaml
# bulkgen state # hokusai state
.*.bulkgen-state.yaml .*.hokusai-state.yaml
# Nix # Nix
result result

View file

@ -1 +1 @@
3.14 3.13

View file

@ -1,17 +1,17 @@
# CLAUDE.md - bulkgen development guide # CLAUDE.md - hokusai development guide
## Project overview ## Project overview
bulkgen is a `make`-like build tool for AI-generated artifacts (images and text). A YAML config file defines targets with dependencies; bulkgen builds a DAG with networkx and executes generation in parallel topological order using Mistral (text) and BlackForestLabs (images) as providers. hokusai is a `make`-like build tool for AI-generated artifacts (images and text). A YAML config file defines targets with dependencies; hokusai builds a DAG with networkx and executes generation in parallel topological order using Mistral (text) and BlackForestLabs (images) as providers.
## Commands ## Commands
```bash ```bash
uv sync # install dependencies uv sync # install dependencies
uv run bulkgen build # build all targets uv run hokusai build # build all targets
uv run bulkgen build X # build target X and its transitive deps uv run hokusai build X # build target X and its transitive deps
uv run bulkgen clean # remove generated artifacts + state file uv run hokusai clean # remove generated artifacts + state file
uv run bulkgen graph # print dependency graph with stages uv run hokusai graph # print dependency graph with stages
uv run pytest # run tests uv run pytest # run tests
``` ```
@ -45,14 +45,14 @@ ruff format --check
### Module structure ### Module structure
``` ```
main.py # Entry point: imports and runs bulkgen.cli.app main.py # Entry point: imports and runs hokusai.cli.app
bulkgen/ hokusai/
__init__.py __init__.py
cli.py # Typer CLI: build, clean, graph commands cli.py # Typer CLI: build, clean, graph commands
config.py # Pydantic models for YAML config config.py # Pydantic models for YAML config
graph.py # networkx DAG construction and traversal graph.py # networkx DAG construction and traversal
builder.py # Build orchestrator: incremental + parallel builder.py # Build orchestrator: incremental + parallel
state.py # .bulkgen.state.yaml hash tracking state.py # .hokusai.state.yaml hash tracking
providers/ providers/
__init__.py # Abstract Provider base class (ABC) __init__.py # Abstract Provider base class (ABC)
image.py # BlackForestLabs image generation image.py # BlackForestLabs image generation
@ -61,7 +61,7 @@ bulkgen/
### Data flow ### Data flow
1. **cli.py** finds the `*.bulkgen.yaml` in cwd, calls `load_config()` from `config.py` 1. **cli.py** finds the `*.hokusai.yaml` in cwd, calls `load_config()` from `config.py`
2. **config.py** parses YAML into `ProjectConfig` (pydantic), which contains `Defaults` and `dict[str, TargetConfig]` 2. **config.py** parses YAML into `ProjectConfig` (pydantic), which contains `Defaults` and `dict[str, TargetConfig]`
3. **graph.py** builds an `nx.DiGraph` from target dependencies. `get_build_order()` uses `nx.topological_generations()` to return parallel batches 3. **graph.py** builds an `nx.DiGraph` from target dependencies. `get_build_order()` uses `nx.topological_generations()` to return parallel batches
4. **builder.py** `run_build()` iterates generations. Per generation: 4. **builder.py** `run_build()` iterates generations. Per generation:
@ -77,13 +77,13 @@ bulkgen/
- **Prompt resolution**: if the `prompt` string is a path to an existing file, its contents are read; otherwise it's used as-is. Done in `builder.py:_resolve_prompt()`. - **Prompt resolution**: if the `prompt` string is a path to an existing file, its contents are read; otherwise it's used as-is. Done in `builder.py:_resolve_prompt()`.
- **BFL client is synchronous**: wrapped in `asyncio.to_thread()` in `providers/image.py`. Uses `ClientConfig(sync=True, timeout=300)` for internal polling. - **BFL client is synchronous**: wrapped in `asyncio.to_thread()` in `providers/image.py`. Uses `ClientConfig(sync=True, timeout=300)` for internal polling.
- **Mistral client is natively async**: uses `complete_async()` directly in `providers/text.py`. - **Mistral client is natively async**: uses `complete_async()` directly in `providers/text.py`.
- **Incremental builds**: `.bulkgen.state.yaml` tracks per-target: input file hashes, prompt hash, model name, and extra params hash. Any change marks the target dirty. - **Incremental builds**: `.hokusai.state.yaml` tracks per-target: input file hashes, prompt hash, model name, and extra params hash. Any change marks the target dirty.
- **Error isolation**: if a target fails, its dependents are marked "Dependency failed" but independent targets continue building. - **Error isolation**: if a target fails, its dependents are marked "Dependency failed" but independent targets continue building.
- **State saved per-generation**: partial progress survives crashes. At most one generation of work is lost. - **State saved per-generation**: partial progress survives crashes. At most one generation of work is lost.
### Provider interface ### Provider interface
All providers implement `bulkgen.providers.Provider`: All providers implement `hokusai.providers.Provider`:
```python ```python
async def generate(self, target_name, target_config, resolved_prompt, resolved_model, project_dir) -> None async def generate(self, target_name, target_config, resolved_prompt, resolved_model, project_dir) -> None
``` ```

View file

@ -1,9 +1,11 @@
# bulkgen # hokusai
A build tool for AI-generated artifacts. Define image and text targets in a YAML config, and bulkgen handles dependency resolution, incremental builds, and parallel execution. A build tool for AI-generated artifacts. Define image and text targets in a YAML config, and hokusai handles dependency resolution, incremental builds, and parallel execution.
Uses [Mistral](https://mistral.ai) and [OpenAI](https://openai.com) for text generation, and [BlackForestLabs](https://blackforestlabs.ai) (FLUX) and [OpenAI](https://openai.com) for image generation. Uses [Mistral](https://mistral.ai) and [OpenAI](https://openai.com) for text generation, and [BlackForestLabs](https://blackforestlabs.ai) (FLUX) and [OpenAI](https://openai.com) for image generation.
The name Hokusai was chosen in honor of [Katsushika Hokusai](https://en.wikipedia.org/wiki/Hokusai), who produced over 30,000 paintings, sketches, woodblock prints, and images for picture books, many in larger series.
## Installation ## Installation
Requires Python 3.13+. Requires Python 3.13+.
@ -28,7 +30,7 @@ export BFL_API_KEY="your-key"
export OPENAI_API_KEY="your-key" export OPENAI_API_KEY="your-key"
``` ```
2. Create a config file (e.g. `my-project.bulkgen.yaml`): 2. Create a config file (e.g. `my-project.hokusai.yaml`):
```yaml ```yaml
defaults: defaults:
@ -51,12 +53,12 @@ targets:
3. Build: 3. Build:
```bash ```bash
bulkgen build hokusai build
``` ```
## Config format ## Config format
The config file must be named `<anything>.bulkgen.yaml` and placed in your project directory. One config file per directory. The config file must be named `<anything>.hokusai.yaml` and placed in your project directory. One config file per directory.
### Top-level fields ### Top-level fields
@ -127,7 +129,7 @@ targets:
- research-notes.md # depends on an existing file - research-notes.md # depends on an existing file
``` ```
bulkgen resolves dependencies automatically. If you build a single target, its transitive dependencies are included. hokusai resolves dependencies automatically. If you build a single target, its transitive dependencies are included.
### Archiving previous outputs ### Archiving previous outputs
@ -145,7 +147,7 @@ On each rebuild of `hero.png`, the previous file is archived as `archive/hero.01
## CLI ## CLI
### `bulkgen build [target]` ### `hokusai build [target]`
Build all targets, or a specific target and its dependencies. Build all targets, or a specific target and its dependencies.
@ -153,11 +155,11 @@ Build all targets, or a specific target and its dependencies.
- Runs independent targets in parallel - Runs independent targets in parallel
- Continues building if a target fails (dependents of the failed target are skipped) - Continues building if a target fails (dependents of the failed target are skipped)
### `bulkgen clean` ### `hokusai clean`
Remove all generated target files and the build state file (`.bulkgen.state.yaml`). Input files are preserved. Remove all generated target files and the build state file (`.hokusai.state.yaml`). Input files are preserved.
### `bulkgen graph` ### `hokusai graph`
Print the dependency graph showing build stages: Print the dependency graph showing build stages:
@ -171,7 +173,7 @@ Stage 1 (targets): variant.png, summary.md
## Incremental builds ## Incremental builds
bulkgen tracks the state of each build in `.bulkgen.state.yaml` (auto-generated, add to `.gitignore`). A target is rebuilt when any of these change: hokusai tracks the state of each build in `.hokusai.state.yaml` (auto-generated, add to `.gitignore`). A target is rebuilt when any of these change:
- Input file contents (SHA-256 hash) - Input file contents (SHA-256 hash)
- Prompt text - Prompt text
@ -180,7 +182,7 @@ bulkgen tracks the state of each build in `.bulkgen.state.yaml` (auto-generated,
## Installation with Nix / home-manager ## Installation with Nix / home-manager
bulkgen provides a Nix flake with a home-manager module. Add the flake as an input and enable the module: hokusai provides a Nix flake with a home-manager module. Add the flake as an input and enable the module:
```nix ```nix
# flake.nix # flake.nix
@ -188,17 +190,17 @@ bulkgen provides a Nix flake with a home-manager module. Add the flake as an inp
inputs = { inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
home-manager.url = "github:nix-community/home-manager"; home-manager.url = "github:nix-community/home-manager";
bulkgen.url = "github:kfickel/bulkgen"; # adjust to your actual repo URL hokusai.url = "github:kfickel/hokusai"; # adjust to your actual repo URL
}; };
outputs = { nixpkgs, home-manager, bulkgen, ... }: { outputs = { nixpkgs, home-manager, hokusai, ... }: {
# ... your existing config, then in homeConfigurations: # ... your existing config, then in homeConfigurations:
homeConfigurations."user" = home-manager.lib.homeManagerConfiguration { homeConfigurations."user" = home-manager.lib.homeManagerConfiguration {
# ... # ...
modules = [ modules = [
bulkgen.homeManagerModules.bulkgen hokusai.homeManagerModules.hokusai
{ {
programs.bulkgen.enable = true; programs.hokusai.enable = true;
} }
]; ];
}; };
@ -206,11 +208,11 @@ bulkgen provides a Nix flake with a home-manager module. Add the flake as an inp
} }
``` ```
This places the `bulkgen` binary on your `$PATH`. To use a different package build (e.g. from a different system or overlay), set `programs.bulkgen.package`. This places the `hokusai` binary on your `$PATH`. To use a different package build (e.g. from a different system or overlay), set `programs.hokusai.package`.
The flake also exposes: The flake also exposes:
- `packages.<system>.bulkgen` — the standalone package, usable without home-manager (e.g. `nix run github:kfickel/bulkgen`) - `packages.<system>.hokusai` — the standalone package, usable without home-manager (e.g. `nix run github:kfickel/hokusai`)
- `devShells.<system>.default` — development shell with all dependencies - `devShells.<system>.default` — development shell with all dependencies
## Environment variables ## Environment variables

View file

@ -1,5 +1,5 @@
{ {
description = "bulkgen - Bulk-Generate Images with Generative AI"; description = "hokusai - Bulk-Generate Images with Generative AI";
inputs = { inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
@ -63,15 +63,15 @@
}; };
pyprojectOverrides = _final: prev: { pyprojectOverrides = _final: prev: {
bulkgen = prev.bulkgen.overrideAttrs (old: { hokusai = prev.hokusai.overrideAttrs (old: {
passthru = old.passthru // { passthru = old.passthru // {
tests = (old.passthru.tests or { }) // { tests = (old.passthru.tests or { }) // {
pytest = stdenv.mkDerivation { pytest = stdenv.mkDerivation {
name = "${_final.bulkgen.name}-pytest"; name = "${_final.hokusai.name}-pytest";
inherit (_final.bulkgen) src; inherit (_final.hokusai) src;
nativeBuildInputs = [ nativeBuildInputs = [
(_final.mkVirtualEnv "bulkgen-pytest-env" { (_final.mkVirtualEnv "hokusai-pytest-env" {
bulkgen = [ "dev" ]; hokusai = [ "dev" ];
}) })
]; ];
dontConfigure = true; dontConfigure = true;
@ -108,7 +108,7 @@
let let
pkgs = nixpkgs.legacyPackages.${system}; pkgs = nixpkgs.legacyPackages.${system};
pythonSet = pythonSets.${system}; pythonSet = pythonSets.${system};
venv = pythonSet.mkVirtualEnv "bulkgen-check-env" workspace.deps.default; venv = pythonSet.mkVirtualEnv "hokusai-check-env" workspace.deps.default;
in in
git-hooks.lib.${system}.run { git-hooks.lib.${system}.run {
src = ./.; src = ./.;
@ -142,17 +142,17 @@
inherit (pkgs.callPackages pyproject-nix.build.util { }) mkApplication; inherit (pkgs.callPackages pyproject-nix.build.util { }) mkApplication;
in in
rec { rec {
bulkgen = mkApplication { hokusai = mkApplication {
venv = pythonSet.mkVirtualEnv "bulkgen-env" workspace.deps.default; venv = pythonSet.mkVirtualEnv "hokusai-env" workspace.deps.default;
package = pythonSet.bulkgen; package = pythonSet.hokusai;
}; };
default = bulkgen; default = hokusai;
} }
); );
homeManagerModules = rec { homeManagerModules = rec {
bulkgen = import ./nix/hm-module.nix self; hokusai = import ./nix/hm-module.nix self;
default = bulkgen; default = hokusai;
}; };
checks = forAllSystems ( checks = forAllSystems (
@ -161,7 +161,7 @@
pythonSet = pythonSets.${system}; pythonSet = pythonSets.${system};
in in
{ {
inherit (pythonSet.bulkgen.passthru.tests) pytest; inherit (pythonSet.hokusai.passthru.tests) pytest;
pre-commit = mkGitHooksCheck system; pre-commit = mkGitHooksCheck system;
} }
); );
@ -171,7 +171,7 @@
let let
pkgs = nixpkgs.legacyPackages.${system}; pkgs = nixpkgs.legacyPackages.${system};
pythonSet = pythonSets.${system}.overrideScope editableOverlay; pythonSet = pythonSets.${system}.overrideScope editableOverlay;
virtualenv = pythonSet.mkVirtualEnv "bulkgen-dev-env" workspace.deps.all; virtualenv = pythonSet.mkVirtualEnv "hokusai-dev-env" workspace.deps.all;
in in
{ {
default = pkgs.mkShell { default = pkgs.mkShell {

View file

@ -9,15 +9,15 @@ from collections.abc import Callable
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from bulkgen.config import ProjectConfig from hokusai.config import ProjectConfig
from bulkgen.graph import build_graph, get_build_order, get_subgraph_for_target from hokusai.graph import build_graph, get_build_order, get_subgraph_for_target
from bulkgen.providers import Provider from hokusai.providers import Provider
from bulkgen.providers.blackforest import BlackForestProvider from hokusai.providers.blackforest import BlackForestProvider
from bulkgen.providers.mistral import MistralProvider from hokusai.providers.mistral import MistralProvider
from bulkgen.providers.openai_image import OpenAIImageProvider from hokusai.providers.openai_image import OpenAIImageProvider
from bulkgen.providers.openai_text import OpenAITextProvider from hokusai.providers.openai_text import OpenAITextProvider
from bulkgen.resolve import resolve_model from hokusai.resolve import resolve_model
from bulkgen.state import ( from hokusai.state import (
BuildState, BuildState,
is_target_dirty, is_target_dirty,
load_state, load_state,

View file

@ -1,4 +1,4 @@
"""Typer CLI for bulkgen: build, clean, graph commands.""" """Typer CLI for hokusai: build, clean, graph commands."""
from __future__ import annotations from __future__ import annotations
@ -10,33 +10,33 @@ from typing import Annotated
import click import click
import typer import typer
from bulkgen.builder import BuildEvent, BuildResult, run_build from hokusai.builder import BuildEvent, BuildResult, run_build
from bulkgen.config import ProjectConfig, load_config from hokusai.config import ProjectConfig, load_config
from bulkgen.graph import build_graph, get_build_order from hokusai.graph import build_graph, get_build_order
from bulkgen.providers.registry import get_all_models from hokusai.providers.registry import get_all_models
from bulkgen.state import state_filename from hokusai.state import state_filename
app = typer.Typer(name="bulkgen", help="AI artifact build tool.") app = typer.Typer(name="hokusai", help="AI artifact build tool.")
_CONFIG_SUFFIX = ".bulkgen.yaml" _CONFIG_SUFFIX = ".hokusai.yaml"
def _project_name(config_path: Path) -> str: def _project_name(config_path: Path) -> str:
"""Derive the project name from a config path. """Derive the project name from a config path.
``cards.bulkgen.yaml`` ``cards`` ``cards.hokusai.yaml`` ``cards``
""" """
name = config_path.name name = config_path.name
return name.removesuffix(_CONFIG_SUFFIX) return name.removesuffix(_CONFIG_SUFFIX)
def _find_config(directory: Path) -> Path: def _find_config(directory: Path) -> Path:
"""Find the single ``*.bulkgen.yaml`` file in *directory*.""" """Find the single ``*.hokusai.yaml`` file in *directory*."""
candidates = list(directory.glob("*.bulkgen.yaml")) candidates = list(directory.glob("*.hokusai.yaml"))
if len(candidates) == 0: if len(candidates) == 0:
click.echo( click.echo(
click.style("Error: ", fg="red", bold=True) click.style("Error: ", fg="red", bold=True)
+ "No .bulkgen.yaml file found in current directory", + "No .hokusai.yaml file found in current directory",
err=True, err=True,
) )
raise typer.Exit(code=1) raise typer.Exit(code=1)
@ -44,7 +44,7 @@ def _find_config(directory: Path) -> Path:
names = ", ".join(str(c.name) for c in candidates) names = ", ".join(str(c.name) for c in candidates)
click.echo( click.echo(
click.style("Error: ", fg="red", bold=True) click.style("Error: ", fg="red", bold=True)
+ f"Multiple .bulkgen.yaml files found: {names}", + f"Multiple .hokusai.yaml files found: {names}",
err=True, err=True,
) )
raise typer.Exit(code=1) raise typer.Exit(code=1)
@ -116,7 +116,7 @@ def build(
config = load_config(config_path) config = load_config(config_path)
name = _project_name(config_path) name = _project_name(config_path)
click.echo(click.style("bulkgen", fg="cyan", bold=True) + " building targets...\n") click.echo(click.style("hokusai", fg="cyan", bold=True) + " building targets...\n")
result, elapsed = _run_build(config, project_dir, name, target) result, elapsed = _run_build(config, project_dir, name, target)

View file

@ -1,4 +1,4 @@
"""Pydantic models for bulkgen YAML configuration.""" """Pydantic models for hokusai YAML configuration."""
from __future__ import annotations from __future__ import annotations
@ -9,7 +9,7 @@ from typing import Self
import yaml import yaml
from pydantic import BaseModel, model_validator from pydantic import BaseModel, model_validator
from bulkgen.providers.models import Capability from hokusai.providers.models import Capability
IMAGE_EXTENSIONS: frozenset[str] = frozenset({".png", ".jpg", ".jpeg", ".webp"}) IMAGE_EXTENSIONS: frozenset[str] = frozenset({".png", ".jpg", ".jpeg", ".webp"})
TEXT_EXTENSIONS: frozenset[str] = frozenset({".md", ".txt"}) TEXT_EXTENSIONS: frozenset[str] = frozenset({".md", ".txt"})
@ -42,7 +42,7 @@ class TargetConfig(BaseModel):
class ProjectConfig(BaseModel): class ProjectConfig(BaseModel):
"""Top-level configuration parsed from ``<name>.bulkgen.yaml``.""" """Top-level configuration parsed from ``<name>.hokusai.yaml``."""
defaults: Defaults = Defaults() defaults: Defaults = Defaults()
targets: dict[str, TargetConfig] targets: dict[str, TargetConfig]
@ -57,7 +57,7 @@ class ProjectConfig(BaseModel):
def target_type_from_capabilities(capabilities: frozenset[Capability]) -> TargetType: def target_type_from_capabilities(capabilities: frozenset[Capability]) -> TargetType:
"""Derive the target type from a set of required capabilities.""" """Derive the target type from a set of required capabilities."""
from bulkgen.providers.models import Capability from hokusai.providers.models import Capability
if Capability.TEXT_TO_IMAGE in capabilities: if Capability.TEXT_TO_IMAGE in capabilities:
return TargetType.IMAGE return TargetType.IMAGE
@ -65,7 +65,7 @@ def target_type_from_capabilities(capabilities: frozenset[Capability]) -> Target
def load_config(config_path: Path) -> ProjectConfig: def load_config(config_path: Path) -> ProjectConfig:
"""Load and validate a ``.bulkgen.yaml`` file.""" """Load and validate a ``.hokusai.yaml`` file."""
with config_path.open() as f: with config_path.open() as f:
raw = yaml.safe_load(f) # pyright: ignore[reportAny] raw = yaml.safe_load(f) # pyright: ignore[reportAny]
return ProjectConfig.model_validate(raw) return ProjectConfig.model_validate(raw)

View file

@ -6,7 +6,7 @@ from pathlib import Path
import networkx as nx import networkx as nx
from bulkgen.config import ProjectConfig from hokusai.config import ProjectConfig
def build_graph(config: ProjectConfig, project_dir: Path) -> nx.DiGraph[str]: def build_graph(config: ProjectConfig, project_dir: Path) -> nx.DiGraph[str]:

View file

@ -6,10 +6,10 @@ import abc
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from bulkgen.providers.models import ModelInfo from hokusai.providers.models import ModelInfo
if TYPE_CHECKING: if TYPE_CHECKING:
from bulkgen.config import TargetConfig from hokusai.config import TargetConfig
class Provider(abc.ABC): class Provider(abc.ABC):

View file

@ -8,10 +8,10 @@ from typing import override
import httpx import httpx
from bulkgen.config import TargetConfig from hokusai.config import TargetConfig
from bulkgen.providers import Provider from hokusai.providers import Provider
from bulkgen.providers.bfl import BFLClient from hokusai.providers.bfl import BFLClient
from bulkgen.providers.models import Capability, ModelInfo from hokusai.providers.models import Capability, ModelInfo
def _encode_image_b64(path: Path) -> str: def _encode_image_b64(path: Path) -> str:

View file

@ -9,9 +9,9 @@ from typing import override
from mistralai import Mistral, models from mistralai import Mistral, models
from bulkgen.config import IMAGE_EXTENSIONS, TargetConfig from hokusai.config import IMAGE_EXTENSIONS, TargetConfig
from bulkgen.providers import Provider from hokusai.providers import Provider
from bulkgen.providers.models import Capability, ModelInfo from hokusai.providers.models import Capability, ModelInfo
def _image_to_data_url(path: Path) -> str: def _image_to_data_url(path: Path) -> str:
@ -128,7 +128,7 @@ def _build_multimodal_message(
models.TextChunk(text=prompt), models.TextChunk(text=prompt),
] ]
from bulkgen.config import IMAGE_EXTENSIONS from hokusai.config import IMAGE_EXTENSIONS
for name in input_names: for name in input_names:
input_path = project_dir / name input_path = project_dir / name

View file

@ -10,9 +10,9 @@ import httpx
from openai import AsyncOpenAI from openai import AsyncOpenAI
from openai.types.images_response import ImagesResponse from openai.types.images_response import ImagesResponse
from bulkgen.config import TargetConfig from hokusai.config import TargetConfig
from bulkgen.providers import Provider from hokusai.providers import Provider
from bulkgen.providers.models import Capability, ModelInfo from hokusai.providers.models import Capability, ModelInfo
_SIZE = Literal[ _SIZE = Literal[
"auto", "auto",

View file

@ -15,9 +15,9 @@ from openai.types.chat import (
ChatCompletionUserMessageParam, ChatCompletionUserMessageParam,
) )
from bulkgen.config import IMAGE_EXTENSIONS, TargetConfig from hokusai.config import IMAGE_EXTENSIONS, TargetConfig
from bulkgen.providers import Provider from hokusai.providers import Provider
from bulkgen.providers.models import Capability, ModelInfo from hokusai.providers.models import Capability, ModelInfo
def _image_to_data_url(path: Path) -> str: def _image_to_data_url(path: Path) -> str:

View file

@ -2,15 +2,15 @@
from __future__ import annotations from __future__ import annotations
from bulkgen.providers.models import ModelInfo from hokusai.providers.models import ModelInfo
def get_all_models() -> list[ModelInfo]: def get_all_models() -> list[ModelInfo]:
"""Return the merged list of models from all providers.""" """Return the merged list of models from all providers."""
from bulkgen.providers.blackforest import BlackForestProvider from hokusai.providers.blackforest import BlackForestProvider
from bulkgen.providers.mistral import MistralProvider from hokusai.providers.mistral import MistralProvider
from bulkgen.providers.openai_image import OpenAIImageProvider from hokusai.providers.openai_image import OpenAIImageProvider
from bulkgen.providers.openai_text import OpenAITextProvider from hokusai.providers.openai_text import OpenAITextProvider
return ( return (
MistralProvider.get_provided_models() MistralProvider.get_provided_models()

View file

@ -2,7 +2,7 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from bulkgen.config import ( from hokusai.config import (
IMAGE_EXTENSIONS, IMAGE_EXTENSIONS,
TEXT_EXTENSIONS, TEXT_EXTENSIONS,
Defaults, Defaults,
@ -10,7 +10,7 @@ from bulkgen.config import (
TargetType, TargetType,
target_type_from_capabilities, target_type_from_capabilities,
) )
from bulkgen.providers.models import Capability, ModelInfo from hokusai.providers.models import Capability, ModelInfo
def infer_required_capabilities( def infer_required_capabilities(
@ -53,7 +53,7 @@ def resolve_model(
Raises :class:`ValueError` if no suitable model can be found. Raises :class:`ValueError` if no suitable model can be found.
""" """
from bulkgen.providers.registry import get_all_models from hokusai.providers.registry import get_all_models
all_models = get_all_models() all_models = get_all_models()
required = infer_required_capabilities(target_name, target) required = infer_required_capabilities(target_name, target)

View file

@ -1,4 +1,4 @@
"""Incremental build state tracking via ``.<project>.bulkgen-state.yaml``.""" """Incremental build state tracking via ``.<project>.hokusai-state.yaml``."""
from __future__ import annotations from __future__ import annotations
@ -12,10 +12,10 @@ from pydantic import BaseModel
def state_filename(project_name: str) -> str: def state_filename(project_name: str) -> str:
"""Return the state filename for a given project name. """Return the state filename for a given project name.
For a config file named ``cards.bulkgen.yaml`` the project name is For a config file named ``cards.hokusai.yaml`` the project name is
``cards`` and the state file is ``.cards.bulkgen-state.yaml``. ``cards`` and the state file is ``.cards.hokusai-state.yaml``.
""" """
return f".{project_name}.bulkgen-state.yaml" return f".{project_name}.hokusai-state.yaml"
class TargetState(BaseModel): class TargetState(BaseModel):

View file

@ -1,6 +1,6 @@
"""Entry point for the bulkgen CLI.""" """Entry point for the hokusai CLI."""
from bulkgen.cli import app from hokusai.cli import app
def main() -> None: def main() -> None:

View file

@ -6,12 +6,12 @@ self:
... ...
}: }:
let let
cfg = config.programs.bulkgen; cfg = config.programs.hokusai;
in in
{ {
options.programs.bulkgen = { options.programs.hokusai = {
enable = lib.mkEnableOption "bulkgen"; enable = lib.mkEnableOption "hokusai";
package = lib.mkPackageOption self.packages.${pkgs.system} "bulkgen" { }; package = lib.mkPackageOption self.packages.${pkgs.system} "hokusai" { };
}; };
config = lib.mkIf cfg.enable { config = lib.mkIf cfg.enable {

View file

@ -1,5 +1,5 @@
[project] [project]
name = "bulkgen" name = "hokusai"
version = "0.1.0" version = "0.1.0"
description = "Bulk-Generate Images with Generative AI" description = "Bulk-Generate Images with Generative AI"
readme = "README.md" readme = "README.md"
@ -16,7 +16,7 @@ dependencies = [
] ]
[project.scripts] [project.scripts]
bulkgen = "bulkgen.cli:app" hokusai = "hokusai.cli:app"
[build-system] [build-system]
requires = ["hatchling"] requires = ["hatchling"]

View file

@ -1,4 +1,4 @@
"""Shared fixtures for bulkgen integration tests.""" """Shared fixtures for hokusai integration tests."""
from __future__ import annotations from __future__ import annotations
@ -8,7 +8,7 @@ from pathlib import Path
import pytest import pytest
import yaml import yaml
from bulkgen.config import ProjectConfig, load_config from hokusai.config import ProjectConfig, load_config
WriteConfig = Callable[[dict[str, object]], ProjectConfig] WriteConfig = Callable[[dict[str, object]], ProjectConfig]
@ -21,7 +21,7 @@ def project_dir(tmp_path: Path) -> Path:
@pytest.fixture @pytest.fixture
def write_config(project_dir: Path) -> WriteConfig: def write_config(project_dir: Path) -> WriteConfig:
"""Write a bulkgen YAML config and return the loaded ProjectConfig. """Write a hokusai YAML config and return the loaded ProjectConfig.
Usage:: Usage::
@ -29,7 +29,7 @@ def write_config(project_dir: Path) -> WriteConfig:
""" """
def _write(raw: dict[str, object]) -> ProjectConfig: def _write(raw: dict[str, object]) -> ProjectConfig:
config_path = project_dir / "project.bulkgen.yaml" config_path = project_dir / "project.hokusai.yaml"
_ = config_path.write_text(yaml.dump(raw, default_flow_style=False)) _ = config_path.write_text(yaml.dump(raw, default_flow_style=False))
return load_config(config_path) return load_config(config_path)

View file

@ -1,4 +1,4 @@
"""Integration tests for bulkgen.builder.""" """Integration tests for hokusai.builder."""
from __future__ import annotations from __future__ import annotations
@ -9,17 +9,17 @@ from unittest.mock import patch
import pytest import pytest
from bulkgen.builder import ( from hokusai.builder import (
_collect_all_deps, # pyright: ignore[reportPrivateUsage] _collect_all_deps, # pyright: ignore[reportPrivateUsage]
_collect_dep_files, # pyright: ignore[reportPrivateUsage] _collect_dep_files, # pyright: ignore[reportPrivateUsage]
_collect_extra_params, # pyright: ignore[reportPrivateUsage] _collect_extra_params, # pyright: ignore[reportPrivateUsage]
_resolve_prompt, # pyright: ignore[reportPrivateUsage] _resolve_prompt, # pyright: ignore[reportPrivateUsage]
run_build, run_build,
) )
from bulkgen.config import ProjectConfig, TargetConfig from hokusai.config import ProjectConfig, TargetConfig
from bulkgen.providers import Provider from hokusai.providers import Provider
from bulkgen.providers.models import Capability, ModelInfo from hokusai.providers.models import Capability, ModelInfo
from bulkgen.state import load_state from hokusai.state import load_state
WriteConfig = Callable[[dict[str, object]], ProjectConfig] WriteConfig = Callable[[dict[str, object]], ProjectConfig]
@ -197,7 +197,7 @@ class TestRunBuild:
async def test_build_single_text_target( async def test_build_single_text_target(
self, project_dir: Path, simple_text_config: ProjectConfig self, project_dir: Path, simple_text_config: ProjectConfig
) -> None: ) -> None:
with patch("bulkgen.builder._create_providers", return_value=_fake_providers()): with patch("hokusai.builder._create_providers", return_value=_fake_providers()):
result = await run_build(simple_text_config, project_dir, _PROJECT) result = await run_build(simple_text_config, project_dir, _PROJECT)
assert result.built == ["output.txt"] assert result.built == ["output.txt"]
@ -208,7 +208,7 @@ class TestRunBuild:
async def test_build_chain_dependency( async def test_build_chain_dependency(
self, project_dir: Path, multi_target_config: ProjectConfig self, project_dir: Path, multi_target_config: ProjectConfig
) -> None: ) -> None:
with patch("bulkgen.builder._create_providers", return_value=_fake_providers()): with patch("hokusai.builder._create_providers", return_value=_fake_providers()):
result = await run_build(multi_target_config, project_dir, _PROJECT) result = await run_build(multi_target_config, project_dir, _PROJECT)
assert "summary.md" in result.built assert "summary.md" in result.built
@ -223,7 +223,7 @@ class TestRunBuild:
async def test_incremental_build_skips_clean_targets( async def test_incremental_build_skips_clean_targets(
self, project_dir: Path, simple_text_config: ProjectConfig self, project_dir: Path, simple_text_config: ProjectConfig
) -> None: ) -> None:
with patch("bulkgen.builder._create_providers", return_value=_fake_providers()): with patch("hokusai.builder._create_providers", return_value=_fake_providers()):
result1 = await run_build(simple_text_config, project_dir, _PROJECT) result1 = await run_build(simple_text_config, project_dir, _PROJECT)
assert result1.built == ["output.txt"] assert result1.built == ["output.txt"]
@ -235,7 +235,7 @@ class TestRunBuild:
self, project_dir: Path, write_config: WriteConfig self, project_dir: Path, write_config: WriteConfig
) -> None: ) -> None:
config1 = write_config({"targets": {"out.txt": {"prompt": "version 1"}}}) config1 = write_config({"targets": {"out.txt": {"prompt": "version 1"}}})
with patch("bulkgen.builder._create_providers", return_value=_fake_providers()): with patch("hokusai.builder._create_providers", return_value=_fake_providers()):
r1 = await run_build(config1, project_dir, _PROJECT) r1 = await run_build(config1, project_dir, _PROJECT)
assert r1.built == ["out.txt"] assert r1.built == ["out.txt"]
@ -250,7 +250,7 @@ class TestRunBuild:
config = write_config( config = write_config(
{"targets": {"out.md": {"prompt": "x", "inputs": ["data.txt"]}}} {"targets": {"out.md": {"prompt": "x", "inputs": ["data.txt"]}}}
) )
with patch("bulkgen.builder._create_providers", return_value=_fake_providers()): with patch("hokusai.builder._create_providers", return_value=_fake_providers()):
r1 = await run_build(config, project_dir, _PROJECT) r1 = await run_build(config, project_dir, _PROJECT)
assert r1.built == ["out.md"] assert r1.built == ["out.md"]
@ -261,7 +261,7 @@ class TestRunBuild:
async def test_selective_build_single_target( async def test_selective_build_single_target(
self, project_dir: Path, multi_target_config: ProjectConfig self, project_dir: Path, multi_target_config: ProjectConfig
) -> None: ) -> None:
with patch("bulkgen.builder._create_providers", return_value=_fake_providers()): with patch("hokusai.builder._create_providers", return_value=_fake_providers()):
result = await run_build( result = await run_build(
multi_target_config, project_dir, _PROJECT, target="summary.md" multi_target_config, project_dir, _PROJECT, target="summary.md"
) )
@ -273,7 +273,7 @@ class TestRunBuild:
async def test_selective_build_unknown_target_raises( async def test_selective_build_unknown_target_raises(
self, project_dir: Path, simple_text_config: ProjectConfig self, project_dir: Path, simple_text_config: ProjectConfig
) -> None: ) -> None:
with patch("bulkgen.builder._create_providers", return_value=_fake_providers()): with patch("hokusai.builder._create_providers", return_value=_fake_providers()):
with pytest.raises(ValueError, match="Unknown target"): with pytest.raises(ValueError, match="Unknown target"):
_ = await run_build( _ = await run_build(
simple_text_config, project_dir, _PROJECT, target="nonexistent.txt" simple_text_config, project_dir, _PROJECT, target="nonexistent.txt"
@ -321,7 +321,7 @@ class TestRunBuild:
routing_provider.generate = selective_generate # type: ignore[assignment] routing_provider.generate = selective_generate # type: ignore[assignment]
with patch( with patch(
"bulkgen.builder._create_providers", "hokusai.builder._create_providers",
return_value=[routing_provider, FakeImageProvider()], return_value=[routing_provider, FakeImageProvider()],
): ):
result = await run_build(config, project_dir, _PROJECT) result = await run_build(config, project_dir, _PROJECT)
@ -342,7 +342,7 @@ class TestRunBuild:
) )
with patch( with patch(
"bulkgen.builder._create_providers", "hokusai.builder._create_providers",
return_value=[FailingTextProvider(), FakeImageProvider()], return_value=[FailingTextProvider(), FakeImageProvider()],
): ):
result = await run_build(config, project_dir, _PROJECT) result = await run_build(config, project_dir, _PROJECT)
@ -355,7 +355,7 @@ class TestRunBuild:
self, project_dir: Path, simple_text_config: ProjectConfig self, project_dir: Path, simple_text_config: ProjectConfig
) -> None: ) -> None:
with patch( with patch(
"bulkgen.builder._create_providers", "hokusai.builder._create_providers",
return_value=[], return_value=[],
): ):
result = await run_build(simple_text_config, project_dir, _PROJECT) result = await run_build(simple_text_config, project_dir, _PROJECT)
@ -374,7 +374,7 @@ class TestRunBuild:
} }
} }
) )
with patch("bulkgen.builder._create_providers", return_value=_fake_providers()): with patch("hokusai.builder._create_providers", return_value=_fake_providers()):
_ = await run_build(config, project_dir, _PROJECT) _ = await run_build(config, project_dir, _PROJECT)
state = load_state(project_dir, _PROJECT) state = load_state(project_dir, _PROJECT)
@ -386,7 +386,7 @@ class TestRunBuild:
) -> None: ) -> None:
config = write_config({"targets": {"out.txt": {"prompt": prompt_file.name}}}) config = write_config({"targets": {"out.txt": {"prompt": prompt_file.name}}})
with patch("bulkgen.builder._create_providers", return_value=_fake_providers()): with patch("hokusai.builder._create_providers", return_value=_fake_providers()):
result = await run_build(config, project_dir, _PROJECT) result = await run_build(config, project_dir, _PROJECT)
assert result.built == ["out.txt"] assert result.built == ["out.txt"]
@ -396,7 +396,7 @@ class TestRunBuild:
async def test_rebuild_after_output_deleted( async def test_rebuild_after_output_deleted(
self, project_dir: Path, simple_text_config: ProjectConfig self, project_dir: Path, simple_text_config: ProjectConfig
) -> None: ) -> None:
with patch("bulkgen.builder._create_providers", return_value=_fake_providers()): with patch("hokusai.builder._create_providers", return_value=_fake_providers()):
r1 = await run_build(simple_text_config, project_dir, _PROJECT) r1 = await run_build(simple_text_config, project_dir, _PROJECT)
assert r1.built == ["output.txt"] assert r1.built == ["output.txt"]
@ -421,7 +421,7 @@ class TestRunBuild:
} }
} }
) )
with patch("bulkgen.builder._create_providers", return_value=_fake_providers()): with patch("hokusai.builder._create_providers", return_value=_fake_providers()):
result = await run_build(config, project_dir, _PROJECT) result = await run_build(config, project_dir, _PROJECT)
assert set(result.built) == {"left.md", "right.md", "merge.txt"} assert set(result.built) == {"left.md", "right.md", "merge.txt"}

View file

@ -1,4 +1,4 @@
"""Integration tests for bulkgen.cli. """Integration tests for hokusai.cli.
Patching ``Path.cwd()`` produces Any-typed return values from mock objects. Patching ``Path.cwd()`` produces Any-typed return values from mock objects.
""" """
@ -13,8 +13,8 @@ import pytest
import yaml import yaml
from typer.testing import CliRunner from typer.testing import CliRunner
from bulkgen.builder import BuildResult from hokusai.builder import BuildResult
from bulkgen.cli import app from hokusai.cli import app
runner = CliRunner() runner = CliRunner()
@ -28,7 +28,7 @@ def cli_project(tmp_path: Path) -> Path:
"image.png": {"prompt": "Generate image"}, "image.png": {"prompt": "Generate image"},
} }
} }
_ = (tmp_path / "project.bulkgen.yaml").write_text( _ = (tmp_path / "project.hokusai.yaml").write_text(
yaml.dump(config, default_flow_style=False) yaml.dump(config, default_flow_style=False)
) )
return tmp_path return tmp_path
@ -38,24 +38,24 @@ class TestFindConfig:
"""Test config file discovery.""" """Test config file discovery."""
def test_no_config_file(self, tmp_path: Path) -> None: def test_no_config_file(self, tmp_path: Path) -> None:
with patch("bulkgen.cli.Path") as mock_path_cls: with patch("hokusai.cli.Path") as mock_path_cls:
mock_path_cls.cwd.return_value = tmp_path mock_path_cls.cwd.return_value = tmp_path
result = runner.invoke(app, ["build"]) result = runner.invoke(app, ["build"])
assert result.exit_code != 0 assert result.exit_code != 0
assert "No .bulkgen.yaml file found" in result.output assert "No .hokusai.yaml file found" in result.output
def test_multiple_config_files(self, tmp_path: Path) -> None: def test_multiple_config_files(self, tmp_path: Path) -> None:
_ = (tmp_path / "a.bulkgen.yaml").write_text( _ = (tmp_path / "a.hokusai.yaml").write_text(
yaml.dump({"targets": {"x.txt": {"prompt": "a"}}}) yaml.dump({"targets": {"x.txt": {"prompt": "a"}}})
) )
_ = (tmp_path / "b.bulkgen.yaml").write_text( _ = (tmp_path / "b.hokusai.yaml").write_text(
yaml.dump({"targets": {"y.txt": {"prompt": "b"}}}) yaml.dump({"targets": {"y.txt": {"prompt": "b"}}})
) )
with patch("bulkgen.cli.Path") as mock_path_cls: with patch("hokusai.cli.Path") as mock_path_cls:
mock_path_cls.cwd.return_value = tmp_path mock_path_cls.cwd.return_value = tmp_path
result = runner.invoke(app, ["build"]) result = runner.invoke(app, ["build"])
assert result.exit_code != 0 assert result.exit_code != 0
assert "Multiple .bulkgen.yaml files found" in result.output assert "Multiple .hokusai.yaml files found" in result.output
class TestBuildCommand: class TestBuildCommand:
@ -66,9 +66,9 @@ class TestBuildCommand:
built=["output.txt", "image.png"], skipped=[], failed={} built=["output.txt", "image.png"], skipped=[], failed={}
) )
with ( with (
patch("bulkgen.cli.Path") as mock_path_cls, patch("hokusai.cli.Path") as mock_path_cls,
patch( patch(
"bulkgen.cli.run_build", "hokusai.cli.run_build",
new_callable=AsyncMock, new_callable=AsyncMock,
return_value=build_result, return_value=build_result,
), ),
@ -84,9 +84,9 @@ class TestBuildCommand:
built=[], skipped=["output.txt", "image.png"], failed={} built=[], skipped=["output.txt", "image.png"], failed={}
) )
with ( with (
patch("bulkgen.cli.Path") as mock_path_cls, patch("hokusai.cli.Path") as mock_path_cls,
patch( patch(
"bulkgen.cli.run_build", "hokusai.cli.run_build",
new_callable=AsyncMock, new_callable=AsyncMock,
return_value=build_result, return_value=build_result,
), ),
@ -104,9 +104,9 @@ class TestBuildCommand:
failed={"image.png": "Missing BFL_API_KEY"}, failed={"image.png": "Missing BFL_API_KEY"},
) )
with ( with (
patch("bulkgen.cli.Path") as mock_path_cls, patch("hokusai.cli.Path") as mock_path_cls,
patch( patch(
"bulkgen.cli.run_build", "hokusai.cli.run_build",
new_callable=AsyncMock, new_callable=AsyncMock,
return_value=build_result, return_value=build_result,
), ),
@ -120,9 +120,9 @@ class TestBuildCommand:
def test_build_specific_target(self, cli_project: Path) -> None: def test_build_specific_target(self, cli_project: Path) -> None:
build_result = BuildResult(built=["output.txt"], skipped=[], failed={}) build_result = BuildResult(built=["output.txt"], skipped=[], failed={})
with ( with (
patch("bulkgen.cli.Path") as mock_path_cls, patch("hokusai.cli.Path") as mock_path_cls,
patch( patch(
"bulkgen.cli.run_build", "hokusai.cli.run_build",
new_callable=AsyncMock, new_callable=AsyncMock,
return_value=build_result, return_value=build_result,
) as mock_run, ) as mock_run,
@ -142,10 +142,10 @@ class TestCleanCommand:
def test_clean_removes_targets(self, cli_project: Path) -> None: def test_clean_removes_targets(self, cli_project: Path) -> None:
_ = (cli_project / "output.txt").write_text("generated") _ = (cli_project / "output.txt").write_text("generated")
_ = (cli_project / "image.png").write_bytes(b"\x89PNG") _ = (cli_project / "image.png").write_bytes(b"\x89PNG")
state_file = ".project.bulkgen-state.yaml" state_file = ".project.hokusai-state.yaml"
_ = (cli_project / state_file).write_text("targets: {}") _ = (cli_project / state_file).write_text("targets: {}")
with patch("bulkgen.cli.Path") as mock_path_cls: with patch("hokusai.cli.Path") as mock_path_cls:
mock_path_cls.cwd.return_value = cli_project mock_path_cls.cwd.return_value = cli_project
result = runner.invoke(app, ["clean"]) result = runner.invoke(app, ["clean"])
@ -156,7 +156,7 @@ class TestCleanCommand:
assert "Cleaned 2 artifact(s)" in result.output assert "Cleaned 2 artifact(s)" in result.output
def test_clean_no_artifacts(self, cli_project: Path) -> None: def test_clean_no_artifacts(self, cli_project: Path) -> None:
with patch("bulkgen.cli.Path") as mock_path_cls: with patch("hokusai.cli.Path") as mock_path_cls:
mock_path_cls.cwd.return_value = cli_project mock_path_cls.cwd.return_value = cli_project
result = runner.invoke(app, ["clean"]) result = runner.invoke(app, ["clean"])
@ -166,7 +166,7 @@ class TestCleanCommand:
def test_clean_partial_artifacts(self, cli_project: Path) -> None: def test_clean_partial_artifacts(self, cli_project: Path) -> None:
_ = (cli_project / "output.txt").write_text("generated") _ = (cli_project / "output.txt").write_text("generated")
with patch("bulkgen.cli.Path") as mock_path_cls: with patch("hokusai.cli.Path") as mock_path_cls:
mock_path_cls.cwd.return_value = cli_project mock_path_cls.cwd.return_value = cli_project
result = runner.invoke(app, ["clean"]) result = runner.invoke(app, ["clean"])
@ -180,10 +180,10 @@ class TestGraphCommand:
def test_graph_single_target(self, tmp_path: Path) -> None: def test_graph_single_target(self, tmp_path: Path) -> None:
config = {"targets": {"out.txt": {"prompt": "hello"}}} config = {"targets": {"out.txt": {"prompt": "hello"}}}
_ = (tmp_path / "test.bulkgen.yaml").write_text( _ = (tmp_path / "test.hokusai.yaml").write_text(
yaml.dump(config, default_flow_style=False) yaml.dump(config, default_flow_style=False)
) )
with patch("bulkgen.cli.Path") as mock_path_cls: with patch("hokusai.cli.Path") as mock_path_cls:
mock_path_cls.cwd.return_value = tmp_path mock_path_cls.cwd.return_value = tmp_path
result = runner.invoke(app, ["graph"]) result = runner.invoke(app, ["graph"])
@ -198,10 +198,10 @@ class TestGraphCommand:
"step2.txt": {"prompt": "y", "inputs": ["step1.md"]}, "step2.txt": {"prompt": "y", "inputs": ["step1.md"]},
} }
} }
_ = (tmp_path / "test.bulkgen.yaml").write_text( _ = (tmp_path / "test.hokusai.yaml").write_text(
yaml.dump(config, default_flow_style=False) yaml.dump(config, default_flow_style=False)
) )
with patch("bulkgen.cli.Path") as mock_path_cls: with patch("hokusai.cli.Path") as mock_path_cls:
mock_path_cls.cwd.return_value = tmp_path mock_path_cls.cwd.return_value = tmp_path
result = runner.invoke(app, ["graph"]) result = runner.invoke(app, ["graph"])
@ -219,10 +219,10 @@ class TestGraphCommand:
"b.txt": {"prompt": "y", "inputs": ["a.txt"]}, "b.txt": {"prompt": "y", "inputs": ["a.txt"]},
} }
} }
_ = (tmp_path / "test.bulkgen.yaml").write_text( _ = (tmp_path / "test.hokusai.yaml").write_text(
yaml.dump(config, default_flow_style=False) yaml.dump(config, default_flow_style=False)
) )
with patch("bulkgen.cli.Path") as mock_path_cls: with patch("hokusai.cli.Path") as mock_path_cls:
mock_path_cls.cwd.return_value = tmp_path mock_path_cls.cwd.return_value = tmp_path
result = runner.invoke(app, ["graph"]) result = runner.invoke(app, ["graph"])

View file

@ -1,4 +1,4 @@
"""Integration tests for bulkgen.config.""" """Integration tests for hokusai.config."""
from __future__ import annotations from __future__ import annotations
@ -7,14 +7,14 @@ from pathlib import Path
import pytest import pytest
import yaml import yaml
from bulkgen.config import load_config from hokusai.config import load_config
class TestLoadConfig: class TestLoadConfig:
"""Test loading and validating YAML config files end-to-end.""" """Test loading and validating YAML config files end-to-end."""
def test_minimal_config(self, project_dir: Path) -> None: def test_minimal_config(self, project_dir: Path) -> None:
config_path = project_dir / "test.bulkgen.yaml" config_path = project_dir / "test.hokusai.yaml"
_ = config_path.write_text( _ = config_path.write_text(
yaml.dump({"targets": {"out.txt": {"prompt": "hello"}}}) yaml.dump({"targets": {"out.txt": {"prompt": "hello"}}})
) )
@ -47,7 +47,7 @@ class TestLoadConfig:
}, },
}, },
} }
config_path = project_dir / "full.bulkgen.yaml" config_path = project_dir / "full.hokusai.yaml"
_ = config_path.write_text(yaml.dump(raw, default_flow_style=False)) _ = config_path.write_text(yaml.dump(raw, default_flow_style=False))
config = load_config(config_path) config = load_config(config_path)
@ -67,14 +67,14 @@ class TestLoadConfig:
assert story.inputs == ["banner.png"] assert story.inputs == ["banner.png"]
def test_empty_targets_rejected(self, project_dir: Path) -> None: def test_empty_targets_rejected(self, project_dir: Path) -> None:
config_path = project_dir / "empty.bulkgen.yaml" config_path = project_dir / "empty.hokusai.yaml"
_ = config_path.write_text(yaml.dump({"targets": {}})) _ = config_path.write_text(yaml.dump({"targets": {}}))
with pytest.raises(Exception, match="At least one target"): with pytest.raises(Exception, match="At least one target"):
_ = load_config(config_path) _ = load_config(config_path)
def test_missing_prompt_rejected(self, project_dir: Path) -> None: def test_missing_prompt_rejected(self, project_dir: Path) -> None:
config_path = project_dir / "bad.bulkgen.yaml" config_path = project_dir / "bad.hokusai.yaml"
_ = config_path.write_text(yaml.dump({"targets": {"out.txt": {}}})) _ = config_path.write_text(yaml.dump({"targets": {"out.txt": {}}}))
with pytest.raises(Exception): with pytest.raises(Exception):

View file

@ -1,4 +1,4 @@
"""Integration tests for bulkgen.graph.""" """Integration tests for hokusai.graph."""
from __future__ import annotations from __future__ import annotations
@ -7,8 +7,8 @@ from pathlib import Path
import pytest import pytest
from bulkgen.config import ProjectConfig from hokusai.config import ProjectConfig
from bulkgen.graph import build_graph, get_build_order, get_subgraph_for_target from hokusai.graph import build_graph, get_build_order, get_subgraph_for_target
WriteConfig = Callable[[dict[str, object]], ProjectConfig] WriteConfig = Callable[[dict[str, object]], ProjectConfig]

View file

@ -1,4 +1,4 @@
"""Integration tests for bulkgen.providers (image and text). """Integration tests for hokusai.providers (image and text).
Mock-heavy tests produce many Any-typed expressions from MagicMock. Mock-heavy tests produce many Any-typed expressions from MagicMock.
""" """
@ -12,16 +12,16 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from bulkgen.config import TargetConfig from hokusai.config import TargetConfig
from bulkgen.providers.bfl import BFLResult from hokusai.providers.bfl import BFLResult
from bulkgen.providers.blackforest import BlackForestProvider from hokusai.providers.blackforest import BlackForestProvider
from bulkgen.providers.blackforest import ( from hokusai.providers.blackforest import (
_encode_image_b64 as encode_image_b64, # pyright: ignore[reportPrivateUsage] _encode_image_b64 as encode_image_b64, # pyright: ignore[reportPrivateUsage]
) )
from bulkgen.providers.mistral import MistralProvider from hokusai.providers.mistral import MistralProvider
from bulkgen.providers.models import ModelInfo from hokusai.providers.models import ModelInfo
from bulkgen.providers.openai_image import OpenAIImageProvider from hokusai.providers.openai_image import OpenAIImageProvider
from bulkgen.providers.registry import get_all_models from hokusai.providers.registry import get_all_models
def _model(name: str) -> ModelInfo: def _model(name: str) -> ModelInfo:
@ -85,8 +85,8 @@ class TestBlackForestProvider:
bfl_result, mock_http = _make_bfl_mocks(image_bytes) bfl_result, mock_http = _make_bfl_mocks(image_bytes)
with ( with (
patch("bulkgen.providers.blackforest.BFLClient") as mock_cls, patch("hokusai.providers.blackforest.BFLClient") as mock_cls,
patch("bulkgen.providers.blackforest.httpx.AsyncClient") as mock_http_cls, patch("hokusai.providers.blackforest.httpx.AsyncClient") as mock_http_cls,
): ):
mock_cls.return_value.generate = AsyncMock(return_value=bfl_result) mock_cls.return_value.generate = AsyncMock(return_value=bfl_result)
mock_http_cls.return_value = mock_http mock_http_cls.return_value = mock_http
@ -111,8 +111,8 @@ class TestBlackForestProvider:
bfl_result, mock_http = _make_bfl_mocks(image_bytes) bfl_result, mock_http = _make_bfl_mocks(image_bytes)
with ( with (
patch("bulkgen.providers.blackforest.BFLClient") as mock_cls, patch("hokusai.providers.blackforest.BFLClient") as mock_cls,
patch("bulkgen.providers.blackforest.httpx.AsyncClient") as mock_http_cls, patch("hokusai.providers.blackforest.httpx.AsyncClient") as mock_http_cls,
): ):
mock_generate = AsyncMock(return_value=bfl_result) mock_generate = AsyncMock(return_value=bfl_result)
mock_cls.return_value.generate = mock_generate mock_cls.return_value.generate = mock_generate
@ -142,8 +142,8 @@ class TestBlackForestProvider:
bfl_result, mock_http = _make_bfl_mocks(image_bytes) bfl_result, mock_http = _make_bfl_mocks(image_bytes)
with ( with (
patch("bulkgen.providers.blackforest.BFLClient") as mock_cls, patch("hokusai.providers.blackforest.BFLClient") as mock_cls,
patch("bulkgen.providers.blackforest.httpx.AsyncClient") as mock_http_cls, patch("hokusai.providers.blackforest.httpx.AsyncClient") as mock_http_cls,
): ):
mock_generate = AsyncMock(return_value=bfl_result) mock_generate = AsyncMock(return_value=bfl_result)
mock_cls.return_value.generate = mock_generate mock_cls.return_value.generate = mock_generate
@ -177,8 +177,8 @@ class TestBlackForestProvider:
bfl_result, mock_http = _make_bfl_mocks(image_bytes) bfl_result, mock_http = _make_bfl_mocks(image_bytes)
with ( with (
patch("bulkgen.providers.blackforest.BFLClient") as mock_cls, patch("hokusai.providers.blackforest.BFLClient") as mock_cls,
patch("bulkgen.providers.blackforest.httpx.AsyncClient") as mock_http_cls, patch("hokusai.providers.blackforest.httpx.AsyncClient") as mock_http_cls,
): ):
mock_generate = AsyncMock(return_value=bfl_result) mock_generate = AsyncMock(return_value=bfl_result)
mock_cls.return_value.generate = mock_generate mock_cls.return_value.generate = mock_generate
@ -208,8 +208,8 @@ class TestBlackForestProvider:
bfl_result, mock_http = _make_bfl_mocks(image_bytes) bfl_result, mock_http = _make_bfl_mocks(image_bytes)
with ( with (
patch("bulkgen.providers.blackforest.BFLClient") as mock_cls, patch("hokusai.providers.blackforest.BFLClient") as mock_cls,
patch("bulkgen.providers.blackforest.httpx.AsyncClient") as mock_http_cls, patch("hokusai.providers.blackforest.httpx.AsyncClient") as mock_http_cls,
): ):
mock_generate = AsyncMock(return_value=bfl_result) mock_generate = AsyncMock(return_value=bfl_result)
mock_cls.return_value.generate = mock_generate mock_cls.return_value.generate = mock_generate
@ -231,8 +231,8 @@ class TestBlackForestProvider:
async def test_image_no_sample_url_raises(self, project_dir: Path) -> None: async def test_image_no_sample_url_raises(self, project_dir: Path) -> None:
target_config = TargetConfig(prompt="x") target_config = TargetConfig(prompt="x")
with patch("bulkgen.providers.blackforest.BFLClient") as mock_cls: with patch("hokusai.providers.blackforest.BFLClient") as mock_cls:
from bulkgen.providers.bfl import BFLError from hokusai.providers.bfl import BFLError
mock_cls.return_value.generate = AsyncMock( mock_cls.return_value.generate = AsyncMock(
side_effect=BFLError("BFL task test ready but no sample URL: {}") side_effect=BFLError("BFL task test ready but no sample URL: {}")
@ -264,7 +264,7 @@ class TestMistralProvider:
target_config = TargetConfig(prompt="Write a poem") target_config = TargetConfig(prompt="Write a poem")
response = _make_text_response("Roses are red...") response = _make_text_response("Roses are red...")
with patch("bulkgen.providers.mistral.Mistral") as mock_cls: with patch("hokusai.providers.mistral.Mistral") as mock_cls:
mock_cls.return_value = _make_mistral_mock(response) mock_cls.return_value = _make_mistral_mock(response)
provider = MistralProvider(api_key="test-key") provider = MistralProvider(api_key="test-key")
@ -285,7 +285,7 @@ class TestMistralProvider:
target_config = TargetConfig(prompt="Summarize", inputs=["source.txt"]) target_config = TargetConfig(prompt="Summarize", inputs=["source.txt"])
response = _make_text_response("Summary: ...") response = _make_text_response("Summary: ...")
with patch("bulkgen.providers.mistral.Mistral") as mock_cls: with patch("hokusai.providers.mistral.Mistral") as mock_cls:
mock_client = _make_mistral_mock(response) mock_client = _make_mistral_mock(response)
mock_cls.return_value = mock_client mock_cls.return_value = mock_client
@ -309,7 +309,7 @@ class TestMistralProvider:
target_config = TargetConfig(prompt="Describe this image", inputs=["photo.png"]) target_config = TargetConfig(prompt="Describe this image", inputs=["photo.png"])
response = _make_text_response("A beautiful photo") response = _make_text_response("A beautiful photo")
with patch("bulkgen.providers.mistral.Mistral") as mock_cls: with patch("hokusai.providers.mistral.Mistral") as mock_cls:
mock_client = _make_mistral_mock(response) mock_client = _make_mistral_mock(response)
mock_cls.return_value = mock_client mock_cls.return_value = mock_client
@ -334,7 +334,7 @@ class TestMistralProvider:
response = MagicMock() response = MagicMock()
response.choices = [] response.choices = []
with patch("bulkgen.providers.mistral.Mistral") as mock_cls: with patch("hokusai.providers.mistral.Mistral") as mock_cls:
mock_cls.return_value = _make_mistral_mock(response) mock_cls.return_value = _make_mistral_mock(response)
provider = MistralProvider(api_key="test-key") provider = MistralProvider(api_key="test-key")
@ -351,7 +351,7 @@ class TestMistralProvider:
target_config = TargetConfig(prompt="x") target_config = TargetConfig(prompt="x")
response = _make_text_response(None) response = _make_text_response(None)
with patch("bulkgen.providers.mistral.Mistral") as mock_cls: with patch("hokusai.providers.mistral.Mistral") as mock_cls:
mock_cls.return_value = _make_mistral_mock(response) mock_cls.return_value = _make_mistral_mock(response)
provider = MistralProvider(api_key="test-key") provider = MistralProvider(api_key="test-key")
@ -374,7 +374,7 @@ class TestMistralProvider:
) )
response = _make_text_response("Combined") response = _make_text_response("Combined")
with patch("bulkgen.providers.mistral.Mistral") as mock_cls: with patch("hokusai.providers.mistral.Mistral") as mock_cls:
mock_client = _make_mistral_mock(response) mock_client = _make_mistral_mock(response)
mock_cls.return_value = mock_client mock_cls.return_value = mock_client
@ -405,7 +405,7 @@ class TestMistralProvider:
) )
response = _make_text_response("A stylized image") response = _make_text_response("A stylized image")
with patch("bulkgen.providers.mistral.Mistral") as mock_cls: with patch("hokusai.providers.mistral.Mistral") as mock_cls:
mock_client = _make_mistral_mock(response) mock_client = _make_mistral_mock(response)
mock_cls.return_value = mock_client mock_cls.return_value = mock_client
@ -459,7 +459,7 @@ class TestOpenAIImageProvider:
b64 = base64.b64encode(image_bytes).decode() b64 = base64.b64encode(image_bytes).decode()
mock_client = _make_openai_mock(b64) mock_client = _make_openai_mock(b64)
with patch("bulkgen.providers.openai_image.AsyncOpenAI") as mock_cls: with patch("hokusai.providers.openai_image.AsyncOpenAI") as mock_cls:
mock_cls.return_value = mock_client mock_cls.return_value = mock_client
provider = OpenAIImageProvider(api_key="test-key") provider = OpenAIImageProvider(api_key="test-key")
@ -493,7 +493,7 @@ class TestOpenAIImageProvider:
b64 = base64.b64encode(image_bytes).decode() b64 = base64.b64encode(image_bytes).decode()
mock_client = _make_openai_mock(b64) mock_client = _make_openai_mock(b64)
with patch("bulkgen.providers.openai_image.AsyncOpenAI") as mock_cls: with patch("hokusai.providers.openai_image.AsyncOpenAI") as mock_cls:
mock_cls.return_value = mock_client mock_cls.return_value = mock_client
provider = OpenAIImageProvider(api_key="test-key") provider = OpenAIImageProvider(api_key="test-key")

View file

@ -1,15 +1,15 @@
"""Integration tests for bulkgen.config.""" """Integration tests for hokusai.config."""
from __future__ import annotations from __future__ import annotations
import pytest import pytest
from bulkgen.config import ( from hokusai.config import (
Defaults, Defaults,
TargetConfig, TargetConfig,
) )
from bulkgen.providers.models import Capability from hokusai.providers.models import Capability
from bulkgen.resolve import infer_required_capabilities, resolve_model from hokusai.resolve import infer_required_capabilities, resolve_model
class TestInferRequiredCapabilities: class TestInferRequiredCapabilities:

View file

@ -1,4 +1,4 @@
"""Integration tests for bulkgen.state.""" """Integration tests for hokusai.state."""
from __future__ import annotations from __future__ import annotations
@ -6,7 +6,7 @@ from pathlib import Path
import yaml import yaml
from bulkgen.state import ( from hokusai.state import (
BuildState, BuildState,
TargetState, TargetState,
hash_file, hash_file,
@ -41,10 +41,10 @@ class TestStateFilename:
"""Test state filename derivation.""" """Test state filename derivation."""
def test_state_filename(self) -> None: def test_state_filename(self) -> None:
assert state_filename("cards") == ".cards.bulkgen-state.yaml" assert state_filename("cards") == ".cards.hokusai-state.yaml"
def test_state_filename_simple(self) -> None: def test_state_filename_simple(self) -> None:
assert state_filename("project") == ".project.bulkgen-state.yaml" assert state_filename("project") == ".project.hokusai-state.yaml"
class TestStatePersistence: class TestStatePersistence:

2
uv.lock generated
View file

@ -45,7 +45,7 @@ wheels = [
] ]
[[package]] [[package]]
name = "bulkgen" name = "hokusai"
version = "0.1.0" version = "0.1.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [