From 7ab25d49cb6b09e816b1d385c359fe47d2dd9a7b Mon Sep 17 00:00:00 2001 From: Konstantin Fickel Date: Fri, 13 Feb 2026 20:25:28 +0100 Subject: [PATCH] refactor: switch to basedpyright, remove pydantic-settings - Replace pyright with basedpyright in devenv.nix (custom hook) - Add basedpyright to devenv packages - Fix all basedpyright warnings: add DiGraph[str] type args, annotate class attributes, narrow SyncResponse, handle unused call results, suppress unavoidable Any from yaml.safe_load and untyped blackforest - Replace pydantic-settings[yaml] with direct pyyaml dependency - Update CLAUDE.md to reflect basedpyright and dependency changes --- CLAUDE.md | 13 +++++++------ bulkgen/cli.py | 6 +++--- bulkgen/config.py | 2 +- bulkgen/graph.py | 14 +++++++------- bulkgen/providers/image.py | 24 +++++++++++++++++++----- bulkgen/providers/text.py | 8 +++++--- bulkgen/state.py | 2 +- devenv.nix | 12 +++++++++++- pyproject.toml | 2 +- pyrightconfig.json | 2 +- uv.lock | 32 ++------------------------------ 11 files changed, 58 insertions(+), 59 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 208c1ab..c59b9d3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,16 +18,16 @@ uv run pytest # run tests ## Code quality Pre-commit hooks run automatically on `git commit`: -- **pyright** - static type checking (config: `pyrightconfig.json` points to `.devenv/state/venv`) +- **basedpyright** - strict static type checking (config: `pyrightconfig.json` points to `.devenv/state/venv`) - **ruff check** - linting with auto-fix - **ruff format** - formatting - **commitizen** - enforces conventional commit messages (`feat:`, `fix:`, `chore:`, etc.) Run manually: ```bash -/nix/store/h7f5vym2ykpl7ls8icw0wiqgmv9xiwnx-pyright-1.1.407/bin/pyright -/nix/store/xmy9vff4zlbvkz3y830085dzgjpmaj8d-ruff-0.14.14/bin/ruff check -/nix/store/xmy9vff4zlbvkz3y830085dzgjpmaj8d-ruff-0.14.14/bin/ruff format --check +basedpyright +ruff check +ruff format --check ``` ## Code style conventions @@ -110,9 +110,10 @@ The provider writes the result file to `project_dir / target_name`. ## Dependencies - `typer` - CLI framework -- `pydantic` / `pydantic-settings[yaml]` - config parsing (pyyaml comes via the yaml extra) +- `pydantic` - data validation and config models +- `pyyaml` - YAML parsing - `networkx` - dependency graph -- `blackforest` - BlackForestLabs API client (sync, uses `requests`) +- `blackforest` - BlackForestLabs API client (sync, uses `requests`; no type stubs) - `mistralai` - Mistral API client (supports async) - `httpx` - async HTTP for downloading BFL result images (transitive via mistralai) - `hatchling` - build backend diff --git a/bulkgen/cli.py b/bulkgen/cli.py index 6b59584..6c7cb8c 100644 --- a/bulkgen/cli.py +++ b/bulkgen/cli.py @@ -96,6 +96,6 @@ def graph() -> None: typer.echo(f"Stage {i} (targets): {', '.join(targets_in_gen)}") for node in gen: - predecessors = list(dep_graph.predecessors(node)) - if predecessors: - typer.echo(f" {node} <- {', '.join(predecessors)}") + preds: list[str] = list(dep_graph.predecessors(node)) + if preds: + typer.echo(f" {node} <- {', '.join(preds)}") diff --git a/bulkgen/config.py b/bulkgen/config.py index cf735dc..238fa97 100644 --- a/bulkgen/config.py +++ b/bulkgen/config.py @@ -78,5 +78,5 @@ def resolve_model(target_name: str, target: TargetConfig, defaults: Defaults) -> def load_config(config_path: Path) -> ProjectConfig: """Load and validate a ``.bulkgen.yaml`` file.""" with config_path.open() as f: - raw = yaml.safe_load(f) + raw = yaml.safe_load(f) # pyright: ignore[reportAny] return ProjectConfig.model_validate(raw) diff --git a/bulkgen/graph.py b/bulkgen/graph.py index 8405c9a..c4456c9 100644 --- a/bulkgen/graph.py +++ b/bulkgen/graph.py @@ -9,7 +9,7 @@ import networkx as nx from bulkgen.config import ProjectConfig -def build_graph(config: ProjectConfig, project_dir: Path) -> nx.DiGraph: +def build_graph(config: ProjectConfig, project_dir: Path) -> nx.DiGraph[str]: """Build a dependency DAG from the project configuration. Nodes are filenames: target names (keys in ``config.targets``) and @@ -19,7 +19,7 @@ def build_graph(config: ProjectConfig, project_dir: Path) -> nx.DiGraph: Raises :class:`ValueError` if a dependency is neither a defined target nor an existing file, or if the graph contains a cycle. """ - graph = nx.DiGraph() + graph: nx.DiGraph[str] = nx.DiGraph() target_names = set(config.targets) for target_name, target_cfg in config.targets.items(): @@ -37,7 +37,7 @@ def build_graph(config: ProjectConfig, project_dir: Path) -> nx.DiGraph: f"which is neither a defined target nor an existing file" ) raise ValueError(msg) - graph.add_edge(dep, target_name) + _ = graph.add_edge(dep, target_name) if not nx.is_directed_acyclic_graph(graph): cycles = list(nx.simple_cycles(graph)) @@ -47,7 +47,7 @@ def build_graph(config: ProjectConfig, project_dir: Path) -> nx.DiGraph: return graph -def get_build_order(graph: nx.DiGraph) -> list[list[str]]: +def get_build_order(graph: nx.DiGraph[str]) -> list[list[str]]: """Return targets grouped into generations for parallel execution. Each inner list contains nodes with no inter-dependencies that can @@ -56,9 +56,9 @@ def get_build_order(graph: nx.DiGraph) -> list[list[str]]: return [list(gen) for gen in nx.topological_generations(graph)] -def get_subgraph_for_target(graph: nx.DiGraph, target: str) -> nx.DiGraph: +def get_subgraph_for_target(graph: nx.DiGraph[str], target: str) -> nx.DiGraph[str]: """Return the subgraph containing *target* and all its transitive dependencies.""" - ancestors = nx.ancestors(graph, target) + ancestors: set[str] = nx.ancestors(graph, target) # pyright: ignore[reportUnknownMemberType] ancestors.add(target) - subgraph = nx.DiGraph(graph.subgraph(ancestors)) + subgraph: nx.DiGraph[str] = nx.DiGraph(graph.subgraph(ancestors)) return subgraph diff --git a/bulkgen/providers/image.py b/bulkgen/providers/image.py index d8f03f4..b8fa7fe 100644 --- a/bulkgen/providers/image.py +++ b/bulkgen/providers/image.py @@ -8,8 +8,13 @@ from pathlib import Path from typing import override import httpx -from blackforest import BFLClient -from blackforest.types.general.client_config import ClientConfig +from blackforest import BFLClient # pyright: ignore[reportMissingTypeStubs] +from blackforest.types.general.client_config import ( # pyright: ignore[reportMissingTypeStubs] + ClientConfig, +) +from blackforest.types.responses.responses import ( # pyright: ignore[reportMissingTypeStubs] + SyncResponse, +) from bulkgen.config import TargetConfig from bulkgen.providers import Provider @@ -25,6 +30,8 @@ def _encode_image_b64(path: Path) -> str: class ImageProvider(Provider): """Generates images via the BlackForestLabs API.""" + _client: BFLClient + def __init__(self, api_key: str) -> None: self._client = BFLClient(api_key=api_key) @@ -58,13 +65,20 @@ class ImageProvider(Provider): self._client.generate, resolved_model, inputs, _BFL_SYNC_CONFIG ) - image_url: str | None = result.result.get("sample") # type: ignore[union-attr] + if not isinstance(result, SyncResponse): + msg = ( + f"BFL API returned unexpected response type for target '{target_name}'" + ) + raise RuntimeError(msg) + + result_dict: dict[str, str] = result.result # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] + image_url = result_dict.get("sample") if not image_url: msg = f"BFL API did not return an image URL for target '{target_name}'" raise RuntimeError(msg) async with httpx.AsyncClient() as http: response = await http.get(image_url) - response.raise_for_status() + _ = response.raise_for_status() - output_path.write_bytes(response.content) + _ = output_path.write_bytes(response.content) diff --git a/bulkgen/providers/text.py b/bulkgen/providers/text.py index 971deaf..b10206e 100644 --- a/bulkgen/providers/text.py +++ b/bulkgen/providers/text.py @@ -14,6 +14,8 @@ from bulkgen.providers import Provider class TextProvider(Provider): """Generates text via the Mistral API.""" + _api_key: str + def __init__(self, api_key: str) -> None: self._api_key = api_key @@ -50,8 +52,8 @@ class TextProvider(Provider): messages=[models.UserMessage(content=full_prompt)], ) - if response is None or not response.choices: - msg = f"Mistral API returned no response for target '{target_name}'" + if not response.choices: + msg = f"Mistral API returned no choices for target '{target_name}'" raise RuntimeError(msg) content = response.choices[0].message.content @@ -60,4 +62,4 @@ class TextProvider(Provider): raise RuntimeError(msg) text = content if isinstance(content, str) else str(content) - output_path.write_text(text) + _ = output_path.write_text(text) diff --git a/bulkgen/state.py b/bulkgen/state.py index 0a354b0..aad059a 100644 --- a/bulkgen/state.py +++ b/bulkgen/state.py @@ -46,7 +46,7 @@ def load_state(project_dir: Path) -> BuildState: if not state_path.exists(): return BuildState() with state_path.open() as f: - raw = yaml.safe_load(f) + raw = yaml.safe_load(f) # pyright: ignore[reportAny] if raw is None: return BuildState() return BuildState.model_validate(raw) diff --git a/devenv.nix b/devenv.nix index a304522..ce1097e 100644 --- a/devenv.nix +++ b/devenv.nix @@ -1,4 +1,5 @@ { + pkgs, ... }: { @@ -9,8 +10,17 @@ }; }; + packages = [ + pkgs.basedpyright + ]; + git-hooks.hooks = { - pyright.enable = true; + basedpyright = { + enable = true; + entry = "${pkgs.basedpyright}/bin/basedpyright"; + files = "\\.py$"; + types = [ "file" ]; + }; ruff.enable = true; ruff-format.enable = true; commitizen.enable = true; diff --git a/pyproject.toml b/pyproject.toml index eb3e7e8..56b583d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ dependencies = [ "mistralai>=1.0.0", "networkx>=3.6.1", "pydantic>=2.12.5", - "pydantic-settings[yaml]>=2.12.0", + "pyyaml>=6.0", "typer>=0.23.1", ] diff --git a/pyrightconfig.json b/pyrightconfig.json index b6d2af9..637c3ce 100644 --- a/pyrightconfig.json +++ b/pyrightconfig.json @@ -1,4 +1,4 @@ { "venvPath": ".", - "venv": ".devenv/state/venv" + "venv": ".devenv/state/venv", } diff --git a/uv.lock b/uv.lock index 7ce9ec1..271275d 100644 --- a/uv.lock +++ b/uv.lock @@ -55,7 +55,7 @@ dependencies = [ { name = "mistralai" }, { name = "networkx" }, { name = "pydantic" }, - { name = "pydantic-settings", extra = ["yaml"] }, + { name = "pyyaml" }, { name = "typer" }, ] @@ -70,7 +70,7 @@ requires-dist = [ { name = "mistralai", specifier = ">=1.0.0" }, { name = "networkx", specifier = ">=3.6.1" }, { name = "pydantic", specifier = ">=2.12.5" }, - { name = "pydantic-settings", extras = ["yaml"], specifier = ">=2.12.0" }, + { name = "pyyaml", specifier = ">=6.0" }, { name = "typer", specifier = ">=0.23.1" }, ] @@ -498,25 +498,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, ] -[[package]] -name = "pydantic-settings" -version = "2.12.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pydantic" }, - { name = "python-dotenv" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" }, -] - -[package.optional-dependencies] -yaml = [ - { name = "pyyaml" }, -] - [[package]] name = "pygments" version = "2.19.2" @@ -554,15 +535,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] -[[package]] -name = "python-dotenv" -version = "1.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, -] - [[package]] name = "pyyaml" version = "6.0.3"