from __future__ import annotations
from pathlib import Path
import click
from chartbook.__about__ import __version__
def _check_sphinx_installed():
"""Check if Sphinx dependencies are installed.
:raises SystemExit: If Sphinx dependencies are not installed.
"""
try:
import jinja2 # noqa: F401
import sphinx # noqa: F401
except ImportError:
click.echo("Error: Sphinx dependencies not installed.", err=True)
click.echo("", err=True)
click.echo(
"These ship with chartbook, so this usually means a broken or"
" partial installation. Reinstall with:", err=True
)
click.echo(" pip install --force-reinstall chartbook", err=True)
raise SystemExit(1)
@click.group()
@click.version_option(version=__version__)
def main():
"""chartbook CLI tool for generating documentation websites."""
@main.command()
@click.argument("output_dir", type=click.Path(), default="./docs", required=False)
@click.option("--project-dir", type=click.Path(), help="Path to project directory")
@click.option(
"--publish-dir",
type=click.Path(),
default="./_output/to_be_published/",
help="Directory where files will be published",
)
@click.option(
"--docs-build-dir",
type=click.Path(),
default="./_docs",
help="Directory where documentation will be built",
)
@click.option(
"--temp-docs-src-dir",
type=click.Path(),
default="./_docs_src",
help="Directory where documentation source files are temporarily stored in two stage procedure",
)
@click.option(
"--keep-build-dirs",
is_flag=True,
default=False,
help="Keep temporary build directory after generation",
)
@click.option(
"--force-write",
"-f",
is_flag=True,
default=False,
help="Overwrite existing output directory by deleting it first",
)
@click.option(
"--size-threshold",
type=float,
default=50,
help="File size threshold in MB above which to use memory-efficient loading (default: 50)",
)
@click.option(
"--strict/--no-strict",
default=True,
help="Fail on missing source files (default). Use --no-strict to skip affected pipelines instead.",
)
@click.option(
"--strip-mathjax2/--no-strip-mathjax2",
default=True,
help="Strip Plotly's MathJax 2 scripts from notebook outputs to prevent conflicts with Sphinx's MathJax 3 (default: enabled)",
)
def build(
output_dir,
project_dir,
publish_dir,
docs_build_dir,
temp_docs_src_dir,
keep_build_dirs,
force_write,
size_threshold,
strict,
strip_mathjax2,
):
"""Generate HTML documentation in the specified output directory.
:param output_dir: Directory where output will be generated.
:type output_dir: str
:param project_dir: Root directory of the project.
:type project_dir: str
:param publish_dir: Directory where files will be published.
:type publish_dir: str
:param docs_build_dir: Directory where documentation will be built.
:type docs_build_dir: str
:param temp_docs_src_dir: Temporary directory for documentation source files.
:type temp_docs_src_dir: str
:param keep_build_dirs: If True, keeps temporary build directory after generation.
:type keep_build_dirs: bool
:param force_write: If True, overwrites existing output directory.
:type force_write: bool
:param size_threshold: File size threshold in MB above which to use memory-efficient loading.
:type size_threshold: float
:param strict: If True, error and exit on missing source files.
:type strict: bool
:param strip_mathjax2: If True, strip Plotly's MathJax 2 scripts from notebook outputs.
:type strip_mathjax2: bool
"""
# Validate paths for shell/platform mismatches
from chartbook.path_validation import detect_shell_environment, validate_cli_paths
shell_env = detect_shell_environment()
path_args = [p for p in [output_dir, project_dir, publish_dir, docs_build_dir, temp_docs_src_dir] if p is not None]
if path_args:
validate_cli_paths(path_args, shell_env, auto_confirm=True)
# Check for Sphinx dependencies
_check_sphinx_installed()
# Import here to avoid loading Sphinx deps at module level
from chartbook.build_docs import generate_docs
# Convert output_dir to Path
output_dir = Path(output_dir).resolve()
# Prevent deleting the current working directory
if output_dir == Path.cwd():
raise click.UsageError(
"Output directory cannot be the current directory '.' to prevent accidental project deletion"
)
# Check if output directory exists and prompt for confirmation
if output_dir.exists() and not force_write and any(output_dir.iterdir()):
if not click.confirm(
f"Directory '{output_dir}' already exists. Do you want to overwrite it?\n"
"(add the -f/--force option to overwrite without prompting)",
default=False,
):
raise SystemExit(0)
force_write = True
# If project_dir not provided, use current directory
project_dir = resolve_project_dir(project_dir)
# Check for config file and create if needed
config_path = project_dir / "chartbook.toml"
if not config_path.exists():
raise ValueError(f"Could not find chartbook.toml at {config_path}")
# Store whether we need to remove existing directory after successful generation
should_remove_existing = output_dir.exists() and force_write
try:
generate_docs(
output_dir=output_dir,
project_dir=project_dir,
publish_dir=publish_dir,
_docs_dir=docs_build_dir,
temp_docs_src_dir=temp_docs_src_dir,
keep_build_dirs=keep_build_dirs,
should_remove_existing=should_remove_existing,
size_threshold=size_threshold,
strict=strict,
strip_mathjax2=strip_mathjax2,
)
except (ValueError, FileNotFoundError) as e:
click.echo(click.style("Error: ", fg="red", bold=True) + str(e), err=True)
raise SystemExit(1)
click.echo(f"Successfully generated documentation in {output_dir}")
@main.command()
@click.argument("output_dir", type=click.Path(), default="./docs", required=False)
@click.option("--project-dir", type=click.Path(), help="Path to project directory")
def browse(output_dir, project_dir):
"""Open the project documentation in your default browser.
Looks for index.html in the OUTPUT_DIR (default: ./docs) and opens it
using the system's default web browser. Works on macOS, Windows, and Linux.
Examples:
chartbook browse
chartbook browse ./my-docs
chartbook browse --project-dir /path/to/project
"""
# Validate paths for shell/platform mismatches
from chartbook.path_validation import detect_shell_environment, validate_cli_paths
shell_env = detect_shell_environment()
path_args = [p for p in [output_dir, project_dir] if p is not None]
if path_args:
validate_cli_paths(path_args, shell_env, auto_confirm=True)
project_dir = resolve_project_dir(project_dir)
index_path = (project_dir / Path(output_dir) / "index.html").resolve()
if not index_path.is_file():
click.echo("Error: Documentation not found.", err=True)
click.echo(f" Expected: {index_path}", err=True)
click.echo("", err=True)
click.echo("Run 'chartbook build' first.", err=True)
raise SystemExit(1)
import webbrowser
url = index_path.as_uri()
click.echo(f"Opening {index_path}")
if not webbrowser.open(url):
click.echo(f"Could not open browser. Open this URL manually: {url}")
@main.command()
@click.option(
"--publish-dir",
type=click.Path(),
default=None,
help="Directory where files will be published",
)
@click.option("--project-dir", type=click.Path(), help="Path to project directory")
@click.option(
"--verbose",
"-v",
is_flag=True,
default=False,
help="Enable verbose output",
)
def publish(publish_dir: Path | str | None, project_dir: Path | str, verbose: bool):
"""Publish the documentation to the specified output directory.
If no publish directory is provided, a default local directory will be used.
:param publish_dir: Directory where files will be published.
:type publish_dir: Path or str, optional
:param project_dir: Root directory of the project.
:type project_dir: Path or str
:param verbose: If True, enables verbose output.
:type verbose: bool
"""
# Validate paths for shell/platform mismatches
from chartbook.path_validation import detect_shell_environment, validate_cli_paths
shell_env = detect_shell_environment()
path_args = [p for p in [publish_dir, project_dir] if p is not None]
if path_args:
validate_cli_paths(path_args, shell_env, auto_confirm=True)
# Check for Sphinx dependencies
_check_sphinx_installed()
# Import here to avoid loading Sphinx deps at module level
from chartbook.manifest import load_manifest
from chartbook.publish import publish_pipeline
project_dir = resolve_project_dir(project_dir)
manifest = load_manifest(base_dir=project_dir)
pipeline_id = manifest["project"]["id"]
if publish_dir is None:
BASE_DIR = Path(".").resolve()
publish_dir = BASE_DIR / Path("./_output/to_be_published")
else:
publish_dir = Path(publish_dir) / pipeline_id
# if publish_dir is a relative path, convert it to an absolute path relative to the project directory
if not publish_dir.is_absolute():
publish_dir = project_dir / Path(publish_dir)
publish_pipeline(publish_dir=publish_dir, base_dir=project_dir, verbose=verbose)
[docs]
def resolve_project_dir(project_dir: Path | None):
"""Resolve the project directory to an absolute path.
:param project_dir: The project directory path, or None to use cwd.
:type project_dir: Path, optional
:returns: The resolved absolute path to the project directory.
:rtype: Path
"""
if project_dir is None:
project_dir = Path.cwd()
else:
project_dir = Path(project_dir).resolve()
return project_dir
@main.command()
@click.option(
"--no-samples",
is_flag=True,
default=False,
help="Exclude sample values sections from the report",
)
@click.option(
"--no-stats",
is_flag=True,
default=False,
help="Exclude numeric column statistics sections from the report",
)
@click.option(
"--output-dir",
"-o",
type=click.Path(),
default=None,
help="Directory to save the output file (default: current directory)",
)
@click.option(
"--size-threshold",
type=float,
default=50,
help="File size threshold in MB above which to use memory-efficient loading (default: 50)",
)
def create_data_glimpses(no_samples, no_stats, output_dir, size_threshold):
"""Create a data glimpses report from dodo.py tasks.
This command parses the dodo.py file in the current directory to find all
CSV/Parquet files and creates a comprehensive data glimpse report in Markdown format.
:param no_samples: If True, exclude sample values sections from the report.
:type no_samples: bool
:param no_stats: If True, exclude numeric column statistics sections from the report.
:type no_stats: bool
:param output_dir: Directory to save the output file.
:type output_dir: str, optional
:param size_threshold: File size threshold in MB above which to use memory-efficient loading.
:type size_threshold: float
Example usage:
chartbook create-data-glimpses
chartbook create-data-glimpses --no-samples
chartbook create-data-glimpses --no-samples --no-stats
chartbook create-data-glimpses -o ./docs/
chartbook create-data-glimpses --size-threshold 100
"""
from chartbook.create_data_glimpses import main as create_data_glimpses_main
try:
create_data_glimpses_main(
output_dir=output_dir,
no_samples=no_samples,
no_stats=no_stats,
size_threshold=size_threshold,
)
except FileNotFoundError as e:
click.echo(f"Error: {e}", err=True)
import sys
sys.exit(1)
except Exception as e:
click.echo(f"Error generating data glimpses: {e}", err=True)
import sys
sys.exit(1)
# =============================================================================
# ls command group - List catalog objects
# =============================================================================
def _load_catalog_for_cli(catalog_path=None):
"""Load manifest from catalog path or default settings.
:param catalog_path: Optional path to catalog chartbook.toml.
:type catalog_path: str or Path, optional
:returns: Tuple of (manifest, resolved_catalog_path).
:rtype: tuple
:raises SystemExit: If no catalog is configured.
"""
from chartbook.data import _resolve_catalog_path
from chartbook.errors import CatalogNotConfiguredError
from chartbook.manifest import load_manifest
try:
resolved = _resolve_catalog_path(catalog_path)
except CatalogNotConfiguredError:
resolved = _prompt_catalog_init()
try:
manifest = load_manifest(base_dir=resolved.parent)
except (ValueError, FileNotFoundError) as e:
click.echo(click.style("Error: ", fg="red", bold=True) + str(e), err=True)
raise SystemExit(1)
return manifest, resolved
def _get_pipeline_name(pipeline_manifest):
"""Extract pipeline name from manifest.
:param pipeline_manifest: The pipeline manifest dict.
:type pipeline_manifest: dict
:returns: The pipeline name or 'Unknown'.
:rtype: str
"""
return pipeline_manifest.get("project", {}).get("name", "Unknown")
@main.group(invoke_without_command=True)
@click.option("--catalog", type=click.Path(), help="Path to catalog chartbook.toml")
@click.pass_context
def ls(ctx, catalog):
"""List catalog objects (pipelines, dataframes, charts).
Without a subcommand, lists all objects in a tree format.
Use subcommands to list specific object types.
Examples:
chartbook ls
chartbook ls pipelines
chartbook ls dataframes
chartbook ls charts
"""
ctx.ensure_object(dict)
ctx.obj["catalog"] = catalog
if ctx.invoked_subcommand is None:
# List everything in tree format
manifest, catalog_path = _load_catalog_for_cli(catalog)
click.echo(f"Catalog: {catalog_path}")
click.echo("")
if manifest["project"]["type"] == "catalog":
# Catalog with multiple pipelines
for pipeline_id in sorted(manifest["pipelines"].keys()):
pipeline_manifest = manifest["pipelines"][pipeline_id]
pipeline_name = _get_pipeline_name(pipeline_manifest)
click.echo(f"[pipeline] {pipeline_id}: {pipeline_name}")
# List dataframes under this pipeline
if "dataframes" in pipeline_manifest:
for df_id in sorted(pipeline_manifest["dataframes"].keys()):
df_name = pipeline_manifest["dataframes"][df_id].get(
"name", "Unknown"
)
click.echo(f" [dataframe] {pipeline_id}/{df_id}: {df_name}")
# List charts under this pipeline
if "charts" in pipeline_manifest:
for chart_id in sorted(pipeline_manifest["charts"].keys()):
chart_name = pipeline_manifest["charts"][chart_id].get(
"name", "Unknown"
)
click.echo(f" [chart] {pipeline_id}/{chart_id}: {chart_name}")
else:
# Single pipeline
pipeline_id = manifest["project"]["id"]
pipeline_name = _get_pipeline_name(manifest)
click.echo(f"[pipeline] {pipeline_id}: {pipeline_name}")
if "dataframes" in manifest:
for df_id in sorted(manifest["dataframes"].keys()):
df_name = manifest["dataframes"][df_id].get("name", "Unknown")
click.echo(f" [dataframe] {pipeline_id}/{df_id}: {df_name}")
if "charts" in manifest:
for chart_id in sorted(manifest["charts"].keys()):
chart_name = manifest["charts"][chart_id].get("name", "Unknown")
click.echo(f" [chart] {pipeline_id}/{chart_id}: {chart_name}")
@ls.command("pipelines")
@click.pass_context
def ls_pipelines(ctx):
"""List all pipelines."""
catalog = ctx.obj.get("catalog")
manifest, _ = _load_catalog_for_cli(catalog)
if manifest["project"]["type"] == "catalog":
for pipeline_id in sorted(manifest["pipelines"].keys()):
pipeline_manifest = manifest["pipelines"][pipeline_id]
pipeline_name = _get_pipeline_name(pipeline_manifest)
click.echo(f"{pipeline_id}: {pipeline_name}")
else:
pipeline_id = manifest["project"]["id"]
pipeline_name = _get_pipeline_name(manifest)
click.echo(f"{pipeline_id}: {pipeline_name}")
@ls.command("dataframes")
@click.pass_context
def ls_dataframes(ctx):
"""List all dataframes across pipelines."""
catalog = ctx.obj.get("catalog")
manifest, _ = _load_catalog_for_cli(catalog)
if manifest["project"]["type"] == "catalog":
for pipeline_id in sorted(manifest["pipelines"].keys()):
pipeline_manifest = manifest["pipelines"][pipeline_id]
if "dataframes" in pipeline_manifest:
for df_id in sorted(pipeline_manifest["dataframes"].keys()):
df_name = pipeline_manifest["dataframes"][df_id].get(
"name", "Unknown"
)
click.echo(f"{pipeline_id}/{df_id}: {df_name}")
else:
pipeline_id = manifest["project"]["id"]
if "dataframes" in manifest:
for df_id in sorted(manifest["dataframes"].keys()):
df_name = manifest["dataframes"][df_id].get("name", "Unknown")
click.echo(f"{pipeline_id}/{df_id}: {df_name}")
@ls.command("charts")
@click.pass_context
def ls_charts(ctx):
"""List all charts across pipelines."""
catalog = ctx.obj.get("catalog")
manifest, _ = _load_catalog_for_cli(catalog)
if manifest["project"]["type"] == "catalog":
for pipeline_id in sorted(manifest["pipelines"].keys()):
pipeline_manifest = manifest["pipelines"][pipeline_id]
if "charts" in pipeline_manifest:
for chart_id in sorted(pipeline_manifest["charts"].keys()):
chart_name = pipeline_manifest["charts"][chart_id].get(
"name", "Unknown"
)
click.echo(f"{pipeline_id}/{chart_id}: {chart_name}")
else:
pipeline_id = manifest["project"]["id"]
if "charts" in manifest:
for chart_id in sorted(manifest["charts"].keys()):
chart_name = manifest["charts"][chart_id].get("name", "Unknown")
click.echo(f"{pipeline_id}/{chart_id}: {chart_name}")
# =============================================================================
# catalog command group - Manage the catalog
# =============================================================================
def _sanitize_pipeline_key(dirname):
"""Convert a directory name to a valid TOML key for the pipelines table.
:param dirname: The directory name to sanitize.
:type dirname: str
:returns: A sanitized key suitable for use in TOML.
:rtype: str
"""
import re
key = dirname.lower().replace("-", "_").replace(" ", "_")
key = re.sub(r"[^a-z0-9_]", "_", key)
key = re.sub(r"_+", "_", key)
key = key.strip("_")
return key
def _ensure_unique_key(key, existing_keys):
"""Append a numeric suffix if key already exists in the set.
:param key: The candidate key.
:type key: str
:param existing_keys: Set of keys already in use.
:type existing_keys: set
:returns: A unique key.
:rtype: str
"""
if key not in existing_keys:
return key
i = 2
while f"{key}_{i}" in existing_keys:
i += 1
return f"{key}_{i}"
def _load_raw_catalog(catalog_toml_path):
"""Load a catalog TOML file without full manifest processing.
:param catalog_toml_path: Path to the catalog's chartbook.toml.
:type catalog_toml_path: Path
:returns: The raw catalog dictionary.
:rtype: dict
:raises click.UsageError: If the file is not a catalog-type manifest.
"""
import tomli
from chartbook.manifest import detect_v1_format, resolve_project_type
with open(catalog_toml_path, "rb") as f:
data = tomli.load(f)
if detect_v1_format(data):
raise click.UsageError(
f"{catalog_toml_path} uses the old v1 format. "
f"Run: python scripts/migrate_toml_v2.py {catalog_toml_path.parent}"
)
config_type = resolve_project_type(data, source=str(catalog_toml_path))
if config_type != "catalog":
raise click.UsageError(
f"{catalog_toml_path} is not a catalog (type={config_type!r})"
)
data.setdefault("pipelines", {})
return data
def _get_existing_absolute_paths(raw_catalog, catalog_dir):
"""Resolve all existing pipeline paths in the catalog to absolute paths.
:param raw_catalog: The raw catalog dictionary.
:type raw_catalog: dict
:param catalog_dir: The directory containing the catalog TOML.
:type catalog_dir: Path
:returns: A dict mapping absolute paths to their catalog keys.
:rtype: dict
"""
from chartbook.manifest import RESERVED_PIPELINES_KEYS, resolve_platform_path
result = {}
for key, entry in raw_catalog.get("pipelines", {}).items():
if key in RESERVED_PIPELINES_KEYS:
continue
if isinstance(entry, str):
entry = {"path": entry}
pipeline_path = entry.get("path")
if pipeline_path is None:
continue
try:
resolved = resolve_platform_path(pipeline_path)
except (ValueError, TypeError):
continue
abs_path = (catalog_dir / resolved).resolve()
result[abs_path] = key
return result
def _get_members_covered_paths(raw_catalog, catalog_dir):
"""Map absolute paths already covered by pipelines.members patterns.
:returns: Dict of absolute path -> the members pattern that covers it.
:rtype: dict
"""
import glob as glob_mod
covered = {}
members = raw_catalog.get("pipelines", {}).get("members", [])
if not isinstance(members, list):
return covered
for pattern in members:
if not isinstance(pattern, str):
continue
for match in glob_mod.glob(str(catalog_dir / pattern)):
covered[Path(match).resolve()] = pattern
return covered
def _prompt_catalog_init():
"""Prompt the user to create a global catalog if running interactively.
:returns: The resolved catalog path if created, otherwise raises SystemExit.
:rtype: Path
:raises SystemExit: If the user declines or stdin is not a TTY.
"""
import sys
from chartbook.config import create_global_catalog, get_global_catalog_path
if not sys.stdin.isatty():
click.echo(
"Run 'chartbook catalog init' to create a new catalog, "
"or 'chartbook config' to point to an existing one.",
err=True,
)
raise SystemExit(1)
click.echo("No catalog found.", err=True)
if click.confirm("Create a new global catalog now?", default=False):
title = click.prompt("Catalog title", default="My Catalog")
catalog_path = create_global_catalog(title=title)
click.echo(f"Created global catalog: {catalog_path}")
click.echo("")
return get_global_catalog_path()
click.echo("", err=True)
click.echo(
"Run 'chartbook catalog init' to create a new catalog, "
"or 'chartbook config' to point to an existing one.",
err=True,
)
raise SystemExit(1)
def _resolve_catalog_toml_path(catalog_path):
"""Resolve the catalog TOML path from an option or global settings.
:param catalog_path: Optional explicit path to catalog chartbook.toml.
:type catalog_path: str or Path, optional
:returns: The resolved path to the catalog's chartbook.toml.
:rtype: Path
:raises SystemExit: If no catalog is configured.
"""
from chartbook.data import _resolve_catalog_path
from chartbook.errors import CatalogNotConfiguredError
try:
return _resolve_catalog_path(catalog_path)
except CatalogNotConfiguredError:
return _prompt_catalog_init()
@main.group()
def catalog():
"""Manage the chartbook catalog."""
pass
@catalog.command("init")
@click.option("--title", default=None, help="Title for the catalog site")
def catalog_init(title):
"""Initialize the global catalog at ~/.chartbook/chartbook.toml.
Creates a minimal catalog with an empty pipelines section.
Use ``chartbook catalog add`` to add pipelines afterwards.
"""
from chartbook.config import create_global_catalog, get_global_catalog_path
catalog_path = get_global_catalog_path()
if catalog_path.is_file():
click.echo(f"Global catalog already exists: {catalog_path}")
return
if title is None:
title = click.prompt("Catalog title", default="My Catalog")
catalog_path = create_global_catalog(title=title)
click.echo(f"Created global catalog: {catalog_path}")
click.echo("")
click.echo("Next steps:")
click.echo(" chartbook catalog add /path/to/pipeline # add a pipeline")
click.echo(" chartbook catalog build # build HTML docs")
click.echo(" chartbook catalog browse # open in browser")
@catalog.command("add")
@click.argument("paths", type=str, nargs=-1, required=True)
@click.option(
"--catalog",
"catalog_path",
type=click.Path(),
default=None,
help="Path to catalog chartbook.toml (uses default from settings if omitted)",
)
@click.option(
"-y",
"--yes",
is_flag=True,
default=False,
help="Skip confirmation prompt when adding multiple pipelines",
)
def catalog_add(paths, catalog_path, yes):
"""Add pipeline directory(ies) to the catalog.
PATHS can be one or more directories containing a chartbook.toml file,
or glob patterns that expand to such directories.
Examples::
chartbook catalog add /path/to/pipeline
chartbook catalog add /path/to/parent/*
chartbook catalog add /path/to/parent/* -y
chartbook catalog add ./proj1 ./proj2 ./proj3
"""
import glob as glob_mod
import os
import tomli
import tomli_w
from chartbook.path_validation import detect_shell_environment, validate_cli_paths
# Validate paths for shell/platform mismatches before processing
shell_env = detect_shell_environment()
paths = validate_cli_paths(paths, shell_env, auto_confirm=yes)
# Resolve catalog
catalog_toml = _resolve_catalog_toml_path(catalog_path)
catalog_dir = catalog_toml.parent
raw_catalog = _load_raw_catalog(catalog_toml)
# Get existing absolute paths for duplicate detection
existing_abs = _get_existing_absolute_paths(raw_catalog, catalog_dir)
members_covered = _get_members_covered_paths(raw_catalog, catalog_dir)
existing_keys = set(raw_catalog.get("pipelines", {}).keys())
reenabled = 0
# Expand all path arguments (handles globs)
candidate_dirs = []
for p in paths:
expanded = glob_mod.glob(p)
if not expanded:
# Not a glob, treat as literal path
expanded = [p]
for entry in expanded:
entry_path = Path(entry).resolve()
if entry_path.is_dir():
candidate_dirs.append(entry_path)
if not candidate_dirs:
tried = ", ".join(paths)
click.echo(
f"No matching directories found for: {tried}\n"
f"Check that the path(s) exist and are spelled correctly.",
err=True,
)
raise SystemExit(1)
# Validate each candidate
is_multi = len(candidate_dirs) > 1
valid_pipelines = [] # list of (abs_path, pipeline_name)
for d in candidate_dirs:
toml_path = d / "chartbook.toml"
if not toml_path.is_file():
if is_multi:
click.echo(f" Skipping {d.name}/ (no chartbook.toml)")
continue
else:
click.echo(f"Error: No chartbook.toml found in {d}", err=True)
raise SystemExit(1)
try:
with open(toml_path, "rb") as f:
pipeline_toml = tomli.load(f)
except Exception as e:
if is_multi:
click.echo(f" Skipping {d.name}/ (invalid TOML: {e})")
continue
else:
click.echo(f"Error: Invalid TOML in {toml_path}: {e}", err=True)
raise SystemExit(1)
from chartbook.manifest import detect_v1_format, resolve_project_type
if detect_v1_format(pipeline_toml):
if is_multi:
click.echo(f" Skipping {d.name}/ (old v1-format chartbook.toml)")
continue
else:
click.echo(
f"Error: {toml_path} uses the old v1 format. "
f"Run: python scripts/migrate_toml_v2.py {d}",
err=True,
)
raise SystemExit(1)
try:
config_type = resolve_project_type(pipeline_toml, source=str(toml_path))
except ValueError as e:
if is_multi:
click.echo(f" Skipping {d.name}/ ({e})")
continue
else:
click.echo(f"Error: {e}", err=True)
raise SystemExit(1)
if config_type != "pipeline":
if is_multi:
click.echo(f" Skipping {d.name}/ (type={config_type!r}, not pipeline)")
continue
else:
click.echo(
f"Error: {toml_path} is not a pipeline (type={config_type!r})",
err=True,
)
raise SystemExit(1)
# Paths already matched by a members pattern need no explicit entry
if d in members_covered:
click.echo(
f" Already covered by pipelines.members pattern "
f"'{members_covered[d]}': {d.name}/"
)
continue
# Check for duplicates — re-enable if disabled
if d in existing_abs:
key = existing_abs[d]
entry = raw_catalog["pipelines"][key]
if entry.get("disabled", False):
entry.pop("disabled")
reenabled += 1
click.echo(f" Re-enabled '{key}': {d.name}/")
else:
click.echo(
f" Already in catalog as '{key}': {d.name}/"
)
continue
pipeline_name = pipeline_toml.get("project", {}).get("name", d.name)
valid_pipelines.append((d, pipeline_name, pipeline_toml.get("project", {})))
if not valid_pipelines and not reenabled:
click.echo("No new pipelines to add.")
return
if not valid_pipelines and reenabled:
# Only re-enables, no new additions — still need to write
with open(catalog_toml, "wb") as f:
tomli_w.dump(raw_catalog, f)
click.echo("")
click.echo(f"Re-enabled {reenabled} pipeline(s) in {catalog_toml}")
return
from chartbook.identity import derive_pipeline_id
def _derive_catalog_key(directory, project_table):
# Scoped id: explicit project.id, else scope from the git remote /
# repo_url with the directory name, else the bare directory name
try:
return derive_pipeline_id(project_table, directory)
except ValueError:
return _sanitize_pipeline_key(directory.name)
# Prompt for confirmation if multiple
if len(valid_pipelines) > 1 and not yes:
click.echo("")
click.echo("Pipelines to add:")
for d, name, project_table in valid_pipelines:
key = _derive_catalog_key(d, project_table)
key = _ensure_unique_key(key, existing_keys)
click.echo(f" {key}: {name} ({d})")
click.echo("")
if not click.confirm(f"Add {len(valid_pipelines)} pipeline(s)?"):
raise SystemExit(0)
# Add each pipeline
added = 0
for d, name, project_table in valid_pipelines:
key = _derive_catalog_key(d, project_table)
key = _ensure_unique_key(key, existing_keys)
existing_keys.add(key)
try:
rel_path = os.path.relpath(d, catalog_dir)
except ValueError:
# Cross-drive on Windows
rel_path = str(d)
raw_catalog["pipelines"][key] = {"path": rel_path}
click.echo(f" Added '{key}': {name} ({rel_path})")
added += 1
# Write back
with open(catalog_toml, "wb") as f:
tomli_w.dump(raw_catalog, f)
click.echo("")
parts = [f"Added {added} pipeline(s)"]
if reenabled:
parts.append(f"re-enabled {reenabled}")
click.echo(f"{', '.join(parts)} in {catalog_toml}")
def _set_pipeline_disabled(pipeline_id, catalog_path, disabled):
"""Set or clear the disabled flag on a pipeline in the catalog.
:param pipeline_id: The pipeline key in the catalog.
:type pipeline_id: str
:param catalog_path: Optional explicit path to catalog chartbook.toml.
:type catalog_path: str or Path, optional
:param disabled: Whether to disable (True) or enable (False) the pipeline.
:type disabled: bool
"""
import tomli_w
from chartbook.manifest import RESERVED_PIPELINES_KEYS
catalog_toml = _resolve_catalog_toml_path(catalog_path)
raw_catalog = _load_raw_catalog(catalog_toml)
pipelines = raw_catalog.get("pipelines", {})
has_members = isinstance(pipelines.get("members"), list)
if pipeline_id in pipelines and pipeline_id not in RESERVED_PIPELINES_KEYS:
# Explicit entry: toggle its disabled flag (string shorthand becomes
# a table when the flag is set)
entry = pipelines[pipeline_id]
if isinstance(entry, str):
entry = {"path": entry}
pipelines[pipeline_id] = entry
if disabled:
entry["disabled"] = True
else:
entry.pop("disabled", None)
elif has_members:
# Member-discovered pipelines are toggled via the pipelines.disabled
# ID list
disabled_list = pipelines.setdefault("disabled", [])
if disabled and pipeline_id not in disabled_list:
disabled_list.append(pipeline_id)
elif not disabled and pipeline_id in disabled_list:
disabled_list.remove(pipeline_id)
if not disabled_list:
pipelines.pop("disabled", None)
else:
click.echo(f"Error: Pipeline '{pipeline_id}' not found in catalog.", err=True)
click.echo("", err=True)
available = ", ".join(
sorted(k for k in pipelines if k not in RESERVED_PIPELINES_KEYS)
) or "(none)"
click.echo(f"Available pipelines: {available}", err=True)
raise SystemExit(1)
with open(catalog_toml, "wb") as f:
tomli_w.dump(raw_catalog, f)
state = "disabled" if disabled else "enabled"
click.echo(f"Pipeline '{pipeline_id}' {state} in {catalog_toml}")
@catalog.command("disable")
@click.argument("pipeline_id")
@click.option(
"--catalog",
"catalog_path",
type=click.Path(),
default=None,
help="Path to catalog chartbook.toml (uses default from settings if omitted)",
)
def catalog_disable(pipeline_id, catalog_path):
"""Disable a pipeline in the catalog.
The pipeline entry is kept but skipped during builds.
Re-enable with ``chartbook catalog enable``.
"""
_set_pipeline_disabled(pipeline_id, catalog_path, disabled=True)
@catalog.command("enable")
@click.argument("pipeline_id")
@click.option(
"--catalog",
"catalog_path",
type=click.Path(),
default=None,
help="Path to catalog chartbook.toml (uses default from settings if omitted)",
)
def catalog_enable(pipeline_id, catalog_path):
"""Re-enable a previously disabled pipeline in the catalog."""
_set_pipeline_disabled(pipeline_id, catalog_path, disabled=False)
@catalog.command("build")
@click.option(
"-f",
"--force-write",
is_flag=True,
default=False,
help="Overwrite existing docs without prompting",
)
@click.option(
"--strict/--no-strict",
default=False,
help="Skip pipelines with missing source files (default). Use --strict to fail on any missing file.",
)
def catalog_build(force_write, strict):
"""Build HTML documentation for the global catalog.
Uses the catalog path from ``~/.chartbook/settings.toml`` if configured,
otherwise falls back to ``~/.chartbook/chartbook.toml``.
"""
_check_sphinx_installed()
from chartbook.build_docs import generate_docs
from chartbook.config import get_default_catalog_path
catalog_path = get_default_catalog_path()
if catalog_path is None or not catalog_path.is_file():
click.echo("Error: No global catalog found.", err=True)
click.echo("", err=True)
click.echo("Run 'chartbook catalog init' to create one.", err=True)
raise SystemExit(1)
project_dir = catalog_path.parent
config_dir = catalog_path.parent
output_dir = config_dir / "docs"
_docs_dir = config_dir / "_docs"
temp_docs_src_dir = config_dir / "_docs_src"
click.echo(f"Building catalog from: {catalog_path}")
click.echo(f"Output directory: {output_dir}")
generate_docs(
output_dir=output_dir,
project_dir=project_dir,
_docs_dir=_docs_dir,
temp_docs_src_dir=temp_docs_src_dir,
should_remove_existing=force_write,
strict=strict,
)
click.echo("")
click.echo(f"Catalog built successfully: {output_dir / 'index.html'}")
click.echo("Run 'chartbook catalog browse' to open in your browser.")
@catalog.command("browse")
def catalog_browse():
"""Open the global catalog documentation in your default browser."""
from chartbook.config import get_default_catalog_path
catalog_path = get_default_catalog_path()
if catalog_path is None:
click.echo("Error: No global catalog found.", err=True)
click.echo("", err=True)
click.echo("Run 'chartbook catalog init' to create one.", err=True)
raise SystemExit(1)
index_path = catalog_path.parent / "docs" / "index.html"
if not index_path.is_file():
click.echo("Error: Catalog docs not found.", err=True)
click.echo("", err=True)
click.echo("Run 'chartbook catalog build' first.", err=True)
raise SystemExit(1)
import webbrowser
url = index_path.as_uri()
click.echo(f"Opening {index_path}")
if not webbrowser.open(url):
click.echo(f"Could not open browser. Open this URL manually: {url}")
# =============================================================================
# data command group - Data operations
# =============================================================================
@main.group()
def data():
"""Data operations (get paths, docs)."""
pass
@data.command("get-path")
@click.option("--pipeline", required=True, help="Pipeline ID")
@click.option("--dataframe", required=True, help="Dataframe ID")
@click.option("--catalog", type=click.Path(), help="Path to catalog chartbook.toml")
def data_get_path(pipeline, dataframe, catalog):
"""Get the path to a dataframe's parquet file.
Examples:
chartbook data get-path --pipeline yield_curve --dataframe repo_public
"""
from chartbook.data import get_data_path
from chartbook.errors import CatalogNotConfiguredError
try:
path = get_data_path(pipeline, dataframe, catalog_path=catalog)
click.echo(str(path))
except CatalogNotConfiguredError as e:
click.echo(f"Error: {e}", err=True)
click.echo("", err=True)
click.echo("Run 'chartbook config' to set a default catalog.", err=True)
raise SystemExit(1)
except (KeyError, ValueError) as e:
click.echo(f"Error: {e}", err=True)
raise SystemExit(1)
@data.command("get-docs")
@click.option("--pipeline", required=True, help="Pipeline ID")
@click.option("--dataframe", required=True, help="Dataframe ID")
@click.option("--catalog", type=click.Path(), help="Path to catalog chartbook.toml")
def data_get_docs(pipeline, dataframe, catalog):
"""Print documentation content for a dataframe.
Examples:
chartbook data get-docs --pipeline yield_curve --dataframe repo_public
"""
from chartbook.data import get_docs
from chartbook.errors import CatalogNotConfiguredError
try:
docs = get_docs(pipeline, dataframe, catalog_path=catalog)
click.echo(docs)
except CatalogNotConfiguredError as e:
click.echo(f"Error: {e}", err=True)
click.echo("", err=True)
click.echo("Run 'chartbook config' to set a default catalog.", err=True)
raise SystemExit(1)
except (KeyError, ValueError) as e:
click.echo(f"Error: {e}", err=True)
raise SystemExit(1)
except FileNotFoundError as e:
click.echo(f"Error: Documentation file not found: {e}", err=True)
raise SystemExit(1)
@data.command("get-docs-path")
@click.option("--pipeline", required=True, help="Pipeline ID")
@click.option("--dataframe", required=True, help="Dataframe ID")
@click.option("--catalog", type=click.Path(), help="Path to catalog chartbook.toml")
def data_get_docs_path(pipeline, dataframe, catalog):
"""Get the path to a dataframe's documentation source.
Examples:
chartbook data get-docs-path --pipeline yield_curve --dataframe repo_public
"""
from chartbook.data import get_docs_path
from chartbook.errors import CatalogNotConfiguredError
try:
path = get_docs_path(pipeline, dataframe, catalog_path=catalog)
click.echo(str(path))
except CatalogNotConfiguredError as e:
click.echo(f"Error: {e}", err=True)
click.echo("", err=True)
click.echo("Run 'chartbook config' to set a default catalog.", err=True)
raise SystemExit(1)
except (KeyError, ValueError) as e:
click.echo(f"Error: {e}", err=True)
raise SystemExit(1)
@main.command()
def init():
"""Initialize a new chartbook project from the cookiecutter template."""
try:
import cruft # noqa: F401
except ImportError:
click.echo("Error: cruft is not installed.", err=True)
click.echo("", err=True)
click.echo(
"cruft ships with chartbook, so this usually means a broken or"
" partial installation. Reinstall with:", err=True
)
click.echo(" pip install --force-reinstall chartbook", err=True)
raise SystemExit(1)
import subprocess
subprocess.run(
["cruft", "create", "https://github.com/backofficedev/cookiecutter_chartbook"],
check=True,
)
@main.command()
def config():
"""Configure the default catalog path for data loading.
Sets the path to a catalog's ``chartbook.toml`` in
``~/.chartbook/settings.toml`` so that ``data.load()`` can find
pipelines without an explicit ``catalog_path`` argument.
"""
from chartbook.config import (
get_default_catalog_path,
set_default_catalog_path,
)
current = get_default_catalog_path()
if current is not None:
click.echo(f"Current catalog path: {current}")
click.echo("")
raw_path = click.prompt(
"Path to catalog chartbook.toml (or its parent directory)",
type=str,
)
catalog_path = Path(raw_path).expanduser()
try:
set_default_catalog_path(catalog_path)
except FileNotFoundError as exc:
click.echo(f"Error: {exc}", err=True)
raise SystemExit(1)
except ValueError as exc:
click.echo(f"Error: {exc}", err=True)
raise SystemExit(1)
resolved = get_default_catalog_path()
click.echo(f"Catalog path set to: {resolved}")
click.echo("")
click.echo("You can now load data with:")
click.echo(' from chartbook import data')
click.echo(' df = data.load(pipeline="my_pipeline", dataframe="my_df")')
@main.group()
def install():
"""Install bundled resources into the current project."""
VERSION_STAMP_NAME = ".chartbook-skill-version"
@install.command("skill")
@click.option(
"-f",
"--force",
is_flag=True,
default=False,
help="Overwrite existing skill files without prompting",
)
@click.option(
"--project",
is_flag=True,
default=False,
help="Install into ./.claude/skills/ in the current directory (commit it "
"to share the skill with everyone working in the repo) instead of the "
"user-level skills directory",
)
def install_skill(force, project):
"""Install the bundled Claude Code skill.
By default the skill is installed user-level — into
``~/.claude/skills/chartbook/``, or ``$CLAUDE_CONFIG_DIR/skills/chartbook/``
when that variable is set — so it is available in every Claude Code
session. With ``--project`` it is installed into
``./.claude/skills/chartbook/`` in the current directory instead.
The command is idempotent: if the installed files already match the
bundled skill, nothing is rewritten. Files from older skill layouts are
removed so the installed copy exactly mirrors the bundled one, and the
installing chartbook version is recorded alongside the skill files.
:param force: If True, overwrite existing files without prompting.
:type force: bool
:param project: If True, install into the current directory's .claude/
instead of the user-level skills directory.
:type project: bool
"""
import importlib.resources
import os
import shutil
package_path = importlib.resources.files("chartbook")
skills_src = Path(str(package_path)) / "skills"
skill_files = sorted(
p for p in (skills_src.iterdir() if skills_src.is_dir() else []) if p.is_file()
)
if not skill_files:
click.echo("Error: No bundled skill files found in package.", err=True)
raise SystemExit(1)
if project:
base_dir = Path.cwd() / ".claude"
else:
config_dir = os.environ.get("CLAUDE_CONFIG_DIR")
base_dir = (
Path(config_dir).expanduser() if config_dir else Path.home() / ".claude"
)
target_dir = base_dir / "skills" / "chartbook"
expected_names = {p.name for p in skill_files}
def _visible_files():
return [
p
for p in target_dir.iterdir()
if p.is_file() and not p.name.startswith(".")
]
# Idempotent no-op: the installed files already match the bundled skill.
# Hidden files (the version stamp, .DS_Store, ...) don't affect the check.
if target_dir.is_dir():
current = _visible_files()
if {p.name for p in current} == expected_names and all(
(target_dir / p.name).read_bytes() == p.read_bytes() for p in skill_files
):
(target_dir / VERSION_STAMP_NAME).write_text(f"{__version__}\n")
click.echo(f"Skill already up to date in {target_dir}")
return
if target_dir.is_dir() and any(_visible_files()) and not force:
click.echo(f"Skill files already exist in {target_dir}")
if not click.confirm("Overwrite existing files?"):
raise SystemExit(0)
target_dir.mkdir(parents=True, exist_ok=True)
# Drop files from older skill layouts (e.g. REFERENCE.md) so the
# installed copy exactly mirrors the bundled skill
for existing in _visible_files():
if existing.name not in expected_names:
existing.unlink()
click.echo(f" removed stale {existing.name}")
copied = 0
for src_file in skill_files:
shutil.copy2(src_file, target_dir / src_file.name)
click.echo(f" {src_file.name}")
copied += 1
(target_dir / VERSION_STAMP_NAME).write_text(f"{__version__}\n")
click.echo(f"\nInstalled {copied} skill file(s) to {target_dir}")
if __name__ == "__main__":
main()