from __future__ import annotations
import contextlib
import warnings
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)
class _Group(click.Group):
"""A group that tells a user who types a command's old name what it is called now.
``renamed`` maps a first word that used to be a command of the group
to the words that stand in its place; ``arguments_go_to`` names the
subcommand that takes what the group itself took while it was a
command, so that ``chartbook docs ./site -f`` is told to say
``chartbook docs generate ./site -f``. Either is one usage error with
the line to type; nothing is run under the old name.
"""
renamed: dict = {}
arguments_go_to: str = ""
def parse_args(self, ctx, args):
if ctx.resilient_parsing:
return super().parse_args(ctx, args) # shell completion asking: nothing to say
first = args[0] if args else None
if first is not None and first not in self.commands and first != "--help":
# The words as a person types them, whatever the program was invoked
# as (`chartbook`, `main` under a test, `python -m chartbook.cli`)
root = ctx.find_root().info_name or ""
tail = ctx.command_path[len(root):] if ctx.command_path.startswith(root) else ""
path = ("chartbook" + tail).strip()
if first in self.renamed:
old, new, rest = f"{path} {first}", f"{path} {self.renamed[first]}", args[1:]
elif self.arguments_go_to and _reads_as_an_argument(first):
# An option or a path, as the command took them; a word that is
# neither may be a misspelt subcommand, which click says
old, new, rest = path, f"{path} {self.arguments_go_to}", args
else:
return super().parse_args(ctx, args)
raise click.UsageError(f"`{old}` is `{new}` now. Try: {' '.join([new, *rest])}", ctx)
return super().parse_args(ctx, args)
def _reads_as_an_argument(word: str) -> bool:
"""Whether a first word is an option or a path, rather than a possible subcommand."""
import os
return word.startswith(("-", ".")) or "/" in word or os.sep in word or os.path.exists(word)
class _Main(_Group):
renamed = {"browse": "docs open"}
class _Docs(_Group):
arguments_go_to = "generate"
class _Catalog(_Group):
renamed = {"build": "generate", "browse": "open"}
class _Install(_Group):
renamed = {"skill": "claude-skill"}
[docs]
@click.group(cls=_Main)
@click.version_option(version=__version__)
@click.pass_context
def main(ctx):
"""chartbook CLI tool for generating documentation websites."""
# A warning from the settings or a manifest is one line for a person,
# not Python's file, line and source line
warnings.showwarning = _show_warning
# A question about trust is asked once per command, never carried to the next one in this process
_trust_answers.clear()
# `install` is what mends it, and `check` says it as a warning of its own
if not ctx.resilient_parsing and ctx.invoked_subcommand not in (None, "install", "check"):
_say_if_the_skill_is_stale()
def _stderr_is_a_terminal() -> bool:
import sys
try:
return sys.stderr is not None and sys.stderr.isatty()
except (AttributeError, ValueError, OSError): # closed, or not a file
return False
def _say_if_the_skill_is_stale():
"""One line on stderr for each installed copy of the Claude Code skill written for another ChartBook.
Only for a person at a terminal: a script, CI or a test reading the
output never sees it. Reading two small files costs nothing, and nothing
here stops a command (``chartbook.claude_skill``).
"""
try:
import os
from chartbook.run import RUN_DIR_ENV_VAR
# Not inside a script `chartbook run` is running: its own run said it
if not _stderr_is_a_terminal() or os.environ.get(RUN_DIR_ENV_VAR):
return
from chartbook import claude_skill
for copy in claude_skill.stale():
click.echo(click.style("Note: ", fg="yellow", bold=True) + claude_skill.notice(copy), err=True)
except Exception: # noqa: BLE001 - a note about the skill never stops a command
pass
def _show_warning(message, category, filename, lineno, file=None, line=None):
click.echo(f"Warning: {message}", err=True)
def _default_catalog_path():
"""The default catalog from the settings, or a clean error if the settings are faulty."""
from chartbook.config import get_default_catalog_path
try:
return get_default_catalog_path()
except ValueError as error:
raise click.ClickException(str(error)) from None
def _site_options(command):
"""Give a command the arguments of rendering the site.
`docs generate` renders it, and `build` renders it after running the
build script, so both take the same ones.
"""
options = [
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 in MB above which a dataframe page does not read the file for its date range; the glimpse reads only the first rows at any size (default: 50)",
),
click.option(
"--strict/--no-strict",
default=True,
help="Fail on missing source files (default), except in a lone pipeline that has built nothing yet, "
"which is rendered with a warning; typed explicitly, --strict fails on that too. In a catalog, "
"--no-strict skips the pipelines that have missing files 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)",
),
click.option(
"--incremental",
is_flag=True,
default=False,
help="Keep the build directory between builds and redo only what changed since the last incremental build",
),
click.option(
"--watch",
is_flag=True,
default=False,
help="Render incrementally, then again whenever the project's files change, until Ctrl-C",
),
click.option(
"--watch-interval",
type=click.FloatRange(min=0.1),
default=None,
metavar="SECONDS",
help="How often --watch checks for changes (default: 1)",
),
]
for option in reversed(options):
command = option(command)
return command
[docs]
@main.group(cls=_Docs)
def docs():
"""The documentation site: `generate` renders it, `open` opens it.
`chartbook docs generate` renders the site from what the project has
built and writes index.json beside it. `chartbook docs open` opens the
rendered site in your browser. Neither builds the project's data:
`chartbook run` does that, and `chartbook build` runs the build script
and then generates the site.
"""
@docs.command("generate")
@_site_options
def docs_generate(**site):
"""Render the documentation site, in OUTPUT_DIR (./docs by default).
This renders the site and writes index.json beside it. It does not
build the project's data: `chartbook run` does that, and
`chartbook build` does both. A lone pipeline that has built none of
its data, charts, notebooks and models yet, as a new project has not,
is rendered with a warning and each shown as not built; with --strict
typed, and in a catalog, that is an error.
By default every render starts from nothing. With --incremental the
intermediate build directory (--docs-build-dir) is kept, pipelines whose
files have not changed are not regenerated, and only pages that differ
are rendered again. --watch does that in a loop: render, then again each
time a file changes. Open the site with `chartbook docs open` and
refresh.
\b
Examples:
chartbook docs generate -f
chartbook docs generate -f --incremental
chartbook docs generate -f --watch
"""
_render_site(**site)
@docs.command("open")
@click.argument("output_dir", type=click.Path(), default="./docs", required=False)
@click.option("--project-dir", type=click.Path(), help="Path to project directory")
def docs_open(output_dir, project_dir):
"""Open the generated site in your default browser.
Looks for index.html in OUTPUT_DIR (./docs by default) and opens it
with the system's default web browser, on macOS, Windows and Linux.
\b
Examples:
chartbook docs open
chartbook docs open ./my-docs
chartbook docs open --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 docs generate' 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()
@_site_options
@click.option(
"--remake",
is_flag=True,
help="Build even though an asset marked keep = true is not on disk while the store has a version of it (see `chartbook run --remake`)",
)
@click.option(
"--upstream",
is_flag=True,
help="First build and snapshot each pipeline this one depends on by path that has no snapshot to pin (see `chartbook run --upstream`)",
)
def build(remake, upstream, **site):
"""Make everything current: run the build script, then render the site.
The same as `chartbook run` followed by `chartbook docs generate`, and
it takes the arguments of `chartbook docs generate`. In a catalog, the
build script is run in each of the catalog's pipelines before the
catalog's site is rendered.
With no `build` script in [scripts], there is nothing to run, and only
the site is rendered.
Inside a build script, say `chartbook docs generate`. `chartbook build`
there would run the script that called it; it renders the site and
says so.
As `chartbook run`, it stops before the build when an asset marked
`keep = true` is not on disk while the store has a version of it:
`chartbook restore` puts it back, and --remake builds without it.
--upstream first builds and snapshots each pipeline this one depends
on by path that has no snapshot to pin, as `chartbook run --upstream`
does.
\b
Examples:
chartbook build -f
chartbook build -f --project-dir ../rate_forecast
chartbook build -f --upstream
"""
from chartbook import run as runner
from chartbook.manifest import BUILD_SCRIPT
project_dir = resolve_project_dir(site["project_dir"])
if _is_catalog_folder(project_dir):
# Every member's script would run before the render refused the
# catalog: the render's gate comes first. A pipeline's own run is
# gated in `run.run` before its render.
_require_trust_to_render(project_dir, running=True)
after_build = False
if runner.running(project_dir, BUILD_SCRIPT):
click.echo(
click.style("Warning: ", fg="yellow", bold=True)
+ "`chartbook build` was called from this pipeline's own build script. "
"It runs that script and then renders the site, so here it only "
"renders the site. In a build script, say `chartbook docs generate`.",
err=True,
)
elif _has_script(project_dir, BUILD_SCRIPT):
_stop_where_a_build_tool_says_chartbook_build(project_dir, upstream=upstream)
if _ask_before_running(project_dir, BUILD_SCRIPT, may_render=True, upstream=upstream) == "render":
click.echo("The build command was not run; only the site is rendered.", err=True)
_stop_on_a_cycle(project_dir)
else:
_run_script(
project_dir, BUILD_SCRIPT, remake=remake, remake_command="chartbook build --remake", upstream=upstream
)
# So what the script did not make is an error, not "not built yet"
after_build = True
else:
click.echo("No `build` script here, so only the site is rendered.")
_stop_on_a_cycle(project_dir)
_render_site(**site, after_build=after_build)
def _stop_on_a_cycle(project_dir):
"""Stop before rendering when a pipeline here depends, through what it depends on, on itself.
Dependencies between pipelines form a directed acyclic graph, and
`chartbook run` refuses a cycle before it pins or builds anything
(:func:`chartbook.lineage.cycle`).
Where `chartbook build` runs no build command, nothing has looked yet,
so it looks here: at the pipeline, or at each pipeline of a catalog.
"""
from chartbook import lineage
from chartbook import run as runner
from chartbook.snapshot import SnapshotError
try:
steps = runner.plan(project_dir, git=False)
except (runner.RunError, ValueError, OSError):
return # the render says what is wrong
members = {step.id: step.directory for step in steps if step.source == "asked" and step.directory is not None}
shared: dict = {} # the store read once for every pipeline's walk
for step in steps:
if step.source != "asked" or step.directory is None or not step.has_dependencies:
continue
try:
loop = lineage.cycle(step.directory, pipeline=step.id, members=members, shared=shared)
except (SnapshotError, ValueError, OSError):
continue # `chartbook check` says what is wrong with the manifest
if loop is not None:
click.echo(
click.style("Error: ", fg="red", bold=True)
+ f"{step.id}: {lineage.cycle_lead(loop)}, so there is no order to build in.\n"
+ lineage.explain_cycle(loop)
+ "\nNothing was rendered.",
err=True,
)
raise SystemExit(1)
def _stop_where_a_build_tool_says_chartbook_build(project_dir, upstream=False):
"""Stop before any build runs when a pipeline's build tool has a task that says `chartbook build`.
Written when `chartbook build` rendered the site (before 0.2.0). Now it
runs the build command first, which runs that task: under `chartbook
build` the site is rendered twice, and under a plain `doit` the build
runs inside itself. Each pipeline `chartbook build` would build is
looked at, a catalog's members too, and all are named at once
(:func:`chartbook.run.build_tool_runs_chartbook_build`).
"""
from chartbook import run as runner
from chartbook.manifest import BUILD_SCRIPT
from chartbook.store import StoreError
try:
steps = runner.plan(project_dir, git=False, upstream=upstream)
except (runner.RunError, ValueError, OSError, StoreError):
return # the run says what is wrong
found = []
for step in steps:
command = step.scripts.get(BUILD_SCRIPT) if step.directory is not None and step.source in ("asked", "upstream") else None
clash = runner.build_tool_runs_chartbook_build(step.directory, command) if command else None
if clash is not None:
name, number, line = clash
where = name if step.directory == project_dir else str(step.directory / name)
found.append(f" {where}, line {number}: {line}")
if not found:
return
click.echo(
click.style("Error: ", fg="red", bold=True)
+ ("These lines run" if len(found) > 1 else "This line runs")
+ " `chartbook build`:\n"
+ "\n".join(found)
+ "\n`chartbook build` runs the build command first, and the build command runs that line: under "
"`chartbook build` the site would be rendered twice, and under a plain build-tool run (`doit`) the "
"build would run inside itself. Before 0.2.0 `chartbook build` rendered the site; now `chartbook docs "
"generate -f` does. Say that there, or take the task out: `chartbook build` renders the site after "
"the build command. Nothing was built.",
err=True,
)
raise SystemExit(1)
def _render_site(
output_dir,
project_dir,
publish_dir,
docs_build_dir,
temp_docs_src_dir,
keep_build_dirs,
force_write,
size_threshold,
strict,
strip_mathjax2,
incremental,
watch,
watch_interval,
after_build=False,
):
"""Render the documentation site: what `docs` does, and `build` does last.
:param output_dir: Directory where output will be generated.
:param project_dir: Root directory of the project.
:param publish_dir: Directory where files will be published.
:param docs_build_dir: Directory where documentation will be built.
:param temp_docs_src_dir: Temporary directory for documentation source files.
:param keep_build_dirs: If True, keeps temporary build directory after generation.
:param force_write: If True, overwrites existing output directory.
:param size_threshold: File size in MB above which a dataframe page does not read the file for its date range.
:param strict: If True, error and exit on missing source files. Only
`--strict` typed on the command line makes a pipeline that has
built nothing yet an error too.
:param strip_mathjax2: If True, strip Plotly's MathJax 2 scripts from notebook outputs.
:param incremental: If True, keep the build directory and redo only what changed.
:param watch: If True, render incrementally and again on every change.
:param watch_interval: Seconds between checks for changes in watch mode.
:param after_build: The build script has just run, so a pipeline that
has built nothing it declares is an error, not "not built yet".
"""
from click.core import ParameterSource
context = click.get_current_context(silent=True)
asked = context is not None and context.get_parameter_source("strict") is ParameterSource.COMMANDLINE
if strict and not asked:
# The default, not asked for: generate_docs then leaves room for a
# pipeline that has not been built yet
strict = None
# 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()
# The project, before anything is asked or written: with no manifest
# there is nothing to render, as `chartbook build` says it
project_dir = resolve_project_dir(project_dir)
if not (project_dir / "chartbook.toml").is_file():
click.echo(click.style("Error: ", fg="red", bold=True) + f"No chartbook.toml in {project_dir}.", err=True)
raise SystemExit(1)
# Before anything of the project is read: a folder the user has not
# trusted is rendered as a build of files from a store is
untrusted = _require_trust_to_render(project_dir)
# 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
# Store whether we need to remove existing directory after successful generation
should_remove_existing = output_dir.exists() and force_write
with _untrusted_mode(untrusted):
_run_docs_build(
project_dir,
incremental=incremental,
watch=watch,
watch_interval=watch_interval,
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,
after_build=after_build,
)
if not watch:
click.echo(f"Successfully generated documentation in {output_dir}")
# Asking to trust a folder, once, when a person is at a terminal
# (docs_src/design/trust.md, "Asked at a terminal")
#: What a person answered in this process, by folder: one command asks once
_trust_answers: dict = {}
def _stdin_is_a_terminal() -> bool:
import sys
try:
return sys.stdin is not None and sys.stdin.isatty()
except (AttributeError, ValueError, OSError): # closed (`<&-`), or not a file
return False
def _can_ask() -> bool:
"""Whether a person is there to answer: stdin and stderr are terminals, and this is no build ChartBook runs, and no CI.
A script, a pipe, CI and a command inside a build script ChartBook is
running never see a question: they behave as before, refused with the
command that trusts, or rendered as files from a store are.
"""
import os
from chartbook.config import untrusted_source
from chartbook.resolve import batch
from chartbook.run import RUN_DIR_ENV_VAR
if os.environ.get("CI", "").strip() or batch() or os.environ.get(RUN_DIR_ENV_VAR) or untrusted_source():
return False
return _stdin_is_a_terminal() and _stderr_is_a_terminal()
def _shown_text(text, width: int = 160) -> str:
"""Text from a folder, as a question shows it: no control or format character, runs of space folded, cut at *width*.
A manifest is anyone's: a build command padded with blank lines or
spaces, or holding a right-to-left mark or a zero-width space, must not
hide or reorder what the person is agreeing to.
"""
import re
import unicodedata
shown = "".join(
f"<U+{ord(c):04X}>" if unicodedata.category(c)[0] == "C" and c not in "\t" else c for c in str(text)
)
shown = re.sub(r"\s+", " ", shown).strip()
return shown if len(shown) <= width else shown[: width - 1] + "…"
def _shown_command(command, at_most: int = 10) -> list:
"""A build command as a question shows it: its lines that say something, each shown plainly, ten at most."""
lines = [_shown_text(line) for line in str(command).splitlines()]
lines = [line for line in lines if line]
if len(lines) > at_most:
lines = lines[:at_most] + [f"… and {len(lines) - at_most} more line(s): the whole command is in its chartbook.toml"]
return lines or ["(empty)"]
def _pipeline_id_here(folder):
"""The pipeline ID a folder's manifest gives itself, or None; for the question and the record, never for a decision."""
from chartbook.identity import derive_pipeline_id
from chartbook.manifest import _read_manifest_file
try:
raw = _read_manifest_file(folder)
project = raw.get("project") if isinstance(raw.get("project"), dict) else {}
return derive_pipeline_id(project, folder)
except Exception: # noqa: BLE001 - the build says what is wrong with a manifest
return None
def _described(folder, pipeline_id=None) -> str:
"""A folder as a question shows it: its path, then its pipeline ID and its repository, when it has them."""
from chartbook.identity import get_git_remote_url, repository_key
notes = [note for note in (pipeline_id or _pipeline_id_here(folder), repository_key(get_git_remote_url(folder))) if note]
return _shown_text(folder, width=400) + (f" ({', '.join(_shown_text(n, width=120) for n in notes)})" if notes else "")
def _record_trusted(folder, pipeline_id=None, catalog: bool = False) -> None:
"""Record a folder as the user's, at run, as `chartbook trust` there would (a catalog's own folder with no ID)."""
from chartbook import config
from chartbook.identity import get_git_remote_url, repository_key
try:
config.record_trust(
folder, "run", id=None if catalog else (pipeline_id or _pipeline_id_here(folder)),
repository=repository_key(get_git_remote_url(folder)) or None,
)
except (ValueError, OSError) as error:
click.echo(
click.style("Error: ", fg="red", bold=True) + f"could not record {_shown_text(folder, 400)} as trusted: "
+ _explain_os_error(error),
err=True,
)
raise SystemExit(1)
_ANSWERS = {"y": "y", "yes": "y", "r": "r", "render": "r", "n": "n", "no": "n", "": "n"}
def _read_answer(prompt: str) -> str:
"""A line typed at the terminal, after *prompt*.
Read in short waits rather than one blocking read: an interrupt that
the system hands to another thread (polars runs its own) would otherwise
not stop the read until Enter, and Ctrl-C must stop at once.
:raises KeyboardInterrupt: On Ctrl-C.
:raises EOFError: At the end of input.
"""
import os
import sys
if os.name == "nt" or not _stdin_is_a_terminal():
return click.prompt(prompt, default="", show_default=False, err=True, prompt_suffix=" ")
import select
click.echo(f"{prompt} ", nl=False, err=True)
while True:
ready, _, _ = select.select([sys.stdin], [], [], 0.2)
if ready:
line = sys.stdin.readline()
if line == "":
raise EOFError
return line.rstrip("\r\n")
def _ask(question: str, choices: str) -> str:
"""One answer among *choices* (``"yrn"`` or ``"yn"``). Enter, end of input and Ctrl-C are n; anything else is asked again."""
shown = "/".join(choice.upper() if choice == "n" else choice for choice in choices)
while True:
try:
answer = _read_answer(f"{question} [{shown}]:")
except (click.Abort, EOFError, KeyboardInterrupt):
click.echo("", err=True)
return "n"
letter = _ANSWERS.get(answer.strip().lower())
if letter is not None and letter in choices:
return letter
click.echo(f" Type {', '.join(choices[:-1])} or {choices[-1]}.", err=True)
def _build_command_of(folder):
"""The build command a folder's manifest declares, or None; shown in a question, never run from here."""
from chartbook.manifest import BUILD_SCRIPT, _read_manifest_file, read_scripts
try:
return read_scripts(_read_manifest_file(folder)).get(BUILD_SCRIPT)
except Exception: # noqa: BLE001 - the build says what is wrong with a manifest
return None
def _ask_to_trust_a_folder(folder, command=None, may_render=True, script="build") -> str:
"""Ask once whether a pipeline's folder is the user's: ``y`` (recorded at run), ``r`` (render only) or ``n``.
A yes is trust at `run`, as `chartbook trust` records it: the question
says so, and shows the build command a later `chartbook build` would
then run without asking, even where only the site is rendered now.
:param command: The command about to run (the *script*), shown in the
question; None when only the site is to be rendered.
:param may_render: Whether ``r`` is offered: rendering the site without
running the folder's code.
"""
from chartbook.config import trust_of
key = Path(folder).resolve()
if key in _trust_answers:
return _trust_answers[key]
if trust_of(key) == "render":
lines = ["This folder is trusted to render its site, not to run its code:"]
elif command:
lines = ["ChartBook has not run the code in this folder before:"]
else:
lines = ["ChartBook has not rendered this folder before:"]
lines.append(f" {_described(key)}")
if command:
said = "Its build command" if script == "build" else f"Its {_shown_text(script, 40)!r} script"
lines.append(f"{said} runs on this machine:")
lines += [f" {line}" for line in _shown_command(command)]
else:
lines.append("Rendering it as your own runs its manifest's SQL queries and reads the files it names, wherever they are.")
later = _build_command_of(key)
if later:
lines.append("Trusting it also lets `chartbook build` run its build command, without asking:")
lines += [f" {line}" for line in _shown_command(later)]
lines.append(" y it's mine: trust this folder and the folders inside it, and don't ask again")
if may_render:
lines.append(
" r render its site only, without running its code" if command
else " r render it without its queries and without files from outside it"
)
lines.append(" n stop")
click.echo("\n".join(lines), err=True)
answer = _ask("Trust this folder?", "yrn" if may_render else "yn")
if answer == "y":
_record_trusted(key)
click.echo(
f"Trusted: {_shown_text(key, 400)} (`chartbook trust --list` shows it; `chartbook trust --forget` there "
f"takes it back)",
err=True,
)
_trust_answers[key] = answer
return answer
def _ask_to_trust_folders(folders: list, running: bool, script: str = "build") -> bool:
"""Ask once whether every listed folder is the user's, a catalog's; True when they are, and recorded at run.
Each folder is shown with the build command a later `chartbook build`
would run without asking, since a yes is trust at `run`.
:param folders: ``(folder, pipeline ID or None, what it is)`` for each;
what it is ``"the catalog's own folder"`` for the catalog's.
:param running: Whether their scripts run now, said in the question.
"""
lines = ["These folders are not trusted yet:"]
for folder, pipeline_id, note in folders:
lines.append(f" {_described(folder, pipeline_id)}{f' {note}' if note else ''}")
command = _build_command_of(folder)
if command:
first, *rest = _shown_command(command, at_most=3)
lines.append(f" build: {first}")
lines += [f" {line}" for line in rest]
what = "build scripts" if script == "build" else f"{_shown_text(script, 40)!r} scripts"
lines.append(
f"Building them as your own runs their {what} and their manifests' SQL queries, and reads the files they "
f"name, wherever they are." if running else
"Rendering them as your own runs their manifests' SQL queries and reads the files they name, wherever they "
"are; trusting them also lets `chartbook build` run their build commands, without asking."
)
lines += [" y they're mine: trust them and the folders inside them, and don't ask again", " n stop"]
click.echo("\n".join(lines), err=True)
if _ask("Trust them?", "yn") != "y":
return False
for folder, pipeline_id, note in folders:
_record_trusted(folder, pipeline_id, catalog=note == "the catalog's own folder")
_trust_answers[Path(folder).resolve()] = "y"
click.echo(f"Trusted: {len(folders)} folder(s) (`chartbook trust --list` shows them)", err=True)
return True
def _ask_before_running(project_dir, script, may_render, upstream=False) -> str:
"""At a terminal, ask about each folder whose script would run and is not trusted to run: ``"go"`` or ``"render"``.
``"render"``, for `chartbook build` alone, is the answer r: the site is
rendered without running the folder's code. ``n`` stops here. Where no
one can be asked, nothing is asked, and the run refuses as it always
did (:func:`chartbook.run._require_trust_to_run`).
"""
from chartbook import run as runner
from chartbook.config import trust_of
if not _can_ask():
return "go"
from chartbook.store import StoreError
try:
steps = runner.plan(project_dir, git=False, upstream=upstream)
except (runner.RunError, ValueError, OSError, StoreError):
return "go" # the run says what is wrong
try:
waiting = [
step for step in steps
if step.source in ("asked", "upstream") and step.directory is not None and script in step.scripts
and trust_of(step.directory) != "run"
]
except ValueError:
return "go" # a trust record that cannot be read: the run says so
if not waiting:
return "go"
here = Path(project_dir).resolve()
if len(waiting) == 1 and waiting[0].directory.resolve() == here:
answer = _ask_to_trust_a_folder(here, waiting[0].scripts[script], may_render=may_render, script=script)
if answer == "r":
return "render"
if answer == "n":
click.echo("Nothing was run.", err=True)
raise SystemExit(1)
return "go"
folders = [
(step.directory, step.id, "an upstream: built, then snapshotted" if step.source == "upstream" else None)
for step in waiting
]
if not _ask_to_trust_folders(folders, running=True, script=script):
click.echo("Nothing was run.", err=True)
raise SystemExit(1)
return "go"
def _require_trust_to_render(project_dir, running: bool = False) -> bool:
"""Whether a site is rendered as one from a store is, by how far the user trusts its folder (``docs_src/design/trust.md``).
A pipeline folder with no trust is rendered as a build of files from a
store is (:func:`_untrusted_mode` sets ``CHARTBOOK_UNTRUSTED_SOURCE=1``
for the render, before anything of the folder is read), and the user
is told why. A catalog's own folder and every member folder on this
machine must be trusted at least to render, since the untrusted mode
is the process's and cannot be one member's; one that is not is an
error that lists them, and nothing is built.
:returns: True to render as a build of files from a store.
:raises SystemExit: For a catalog with a folder that is not trusted,
or a trust record that cannot be read.
"""
from chartbook.config import trust_of
from chartbook.manifest import _read_manifest_file, catalog_member_dirs, resolve_project_type
def fail(message):
click.echo(click.style("Error: ", fg="red", bold=True) + _said(message), err=True)
raise SystemExit(1)
try:
raw = _read_manifest_file(project_dir)
is_catalog = resolve_project_type(raw, source=str(project_dir / "chartbook.toml")) == "catalog"
except (ValueError, FileNotFoundError):
# A manifest that cannot be read is the build's error to say
return False
here = "here" if project_dir.resolve() == Path.cwd().resolve() else f"in {project_dir}"
try:
if not is_catalog:
answered = _trust_answers.get(Path(project_dir).resolve())
if trust_of(project_dir) is None and answered is None and _can_ask():
answered = _ask_to_trust_a_folder(project_dir)
if answered == "n":
click.echo("Nothing was rendered.", err=True)
raise SystemExit(1)
if trust_of(project_dir) is None and answered == "r":
click.echo(
click.style("Warning: ", fg="yellow", bold=True)
+ "rendered as files from a store are, as you answered: no query is run, and no template or "
"file from outside the folder is read.",
err=True,
)
return True
if trust_of(project_dir) is None:
click.echo(
click.style("Warning: ", fg="yellow", bold=True)
+ f"{project_dir} is not a pipeline you trust, so it is read as files from a store are: "
f"no query is run, and no template or file from outside the folder is read. If it is "
f"yours to run, `chartbook trust` {here} trusts it; `chartbook trust --render` trusts it "
f"to render only.",
err=True,
)
return True
return False
try:
# The build reads the same entries next, and says what it warns of
with warnings.catch_warnings():
warnings.simplefilter("ignore")
members = catalog_member_dirs(project_dir)
except (ValueError, FileNotFoundError, TypeError, KeyError):
return False # the build says what is wrong with the catalog
untrusted = []
if trust_of(project_dir) is None:
untrusted.append(f" {project_dir} (the catalog's own folder)")
for pipeline_id, directory in members.items():
if not (directory / "chartbook.toml").is_file():
continue # nothing to trust or read there; the build says the entry names no pipeline
if trust_of(directory) is None:
untrusted.append(f" {directory} ({pipeline_id})")
except ValueError as error:
fail(error)
# Building runs each member's script too: one question about every
# folder this command needs, a member trusted to render only included
wanted = (lambda level: level != "run") if running else (lambda level: level is None)
if _can_ask() and (untrusted or (running and any(
(d / "chartbook.toml").is_file() and wanted(trust_of(d)) for d in members.values()
))):
folders = []
if trust_of(project_dir) is None:
folders.append((project_dir, None, "the catalog's own folder"))
for pipeline_id, directory in members.items():
if (directory / "chartbook.toml").is_file() and wanted(trust_of(directory)):
folders.append((directory, pipeline_id, None))
if _ask_to_trust_folders(folders, running=running):
return False
click.echo("Nothing was built.", err=True)
raise SystemExit(1)
if untrusted:
fail(
"a catalog's site is built only from folders you trust, and these are not trusted:\n"
+ "\n".join(untrusted)
+ f"\n`chartbook trust --members` {here} trusts the catalog's folder and each member folder on "
f"this machine (`chartbook trust --members --render` to render only); `chartbook trust` in one "
f"folder trusts that one."
)
return False
def _is_catalog_folder(project_dir) -> bool:
"""Whether the manifest in *project_dir* is a catalog's; False when there is none or it cannot be read."""
from chartbook.manifest import _read_manifest_file, resolve_project_type
try:
raw = _read_manifest_file(project_dir)
return resolve_project_type(raw, source=str(project_dir / "chartbook.toml")) == "catalog"
except (ValueError, FileNotFoundError):
return False
@contextlib.contextmanager
def _untrusted_mode(untrusted: bool):
"""``CHARTBOOK_UNTRUSTED_SOURCE=1`` for the render of a folder the user has not trusted, and only for it.
The mode is read from the environment wherever a build decides what it
may read or run (:func:`chartbook.config.untrusted_source`), so it is
set there; and it is put back after, since the process may be a test's,
or a library's, that renders again.
"""
import os
from chartbook.config import UNTRUSTED_ENV_VAR
if not untrusted:
yield
return
before = os.environ.get(UNTRUSTED_ENV_VAR)
os.environ[UNTRUSTED_ENV_VAR] = "1"
try:
yield
finally:
if before is None:
os.environ.pop(UNTRUSTED_ENV_VAR, None)
else:
os.environ[UNTRUSTED_ENV_VAR] = before
def _run_docs_build(manifest_dir, incremental, watch, watch_interval, **build_kwargs):
"""Run a documentation build once, or keep rebuilding it on change.
:param manifest_dir: The directory holding the project's chartbook.toml.
:param incremental: Keep the build directory and redo only what changed.
:param watch: Build incrementally, then rebuild whenever inputs change.
:param watch_interval: Seconds between checks in watch mode, or None.
:param build_kwargs: Passed to ``generate_docs``.
"""
from chartbook.build_docs import generate_docs
if watch_interval is not None and not watch:
raise click.UsageError("--watch-interval only applies with --watch.")
def report(error):
click.echo(click.style("Error: ", fg="red", bold=True) + _said(error), err=True)
if not watch:
try:
summary = generate_docs(incremental=incremental, **build_kwargs)
except (ValueError, FileNotFoundError) as e:
report(e)
raise SystemExit(1)
if summary is not None:
click.echo(summary.describe())
return
from chartbook import incremental as incremental_builds
from chartbook.manifest import load_manifest
def build_once():
try:
summary = generate_docs(incremental=True, **build_kwargs)
except (ValueError, FileNotFoundError) as e:
report(e)
raise SystemExit(1)
click.echo(summary.describe())
def snapshot():
manifest = load_manifest(base_dir=manifest_dir)
return incremental_builds.snapshot_inputs(manifest, manifest_dir)
incremental_builds.watch(
build_once, snapshot, interval=watch_interval or 1.0, echo=click.echo
)
@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):
"""Stage a pipeline's publishable files into a directory.
If no publish directory is provided, a default local directory will be used.
Publishing copies files and does nothing else: no network, no registry.
Sending a pipeline to a registry is a different act with a different
verb, `chartbook push`, which is reserved and not yet implemented.
"""
# 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)
# Publishing copies the files the manifest names: in a folder the user
# has not trusted, as a build of files from a store, nothing outside it
untrusted = _require_trust_to_render(project_dir)
try:
with _untrusted_mode(untrusted):
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)
except (ValueError, FileNotFoundError) as e:
click.echo(click.style("Error: ", fg="red", bold=True) + _said(e), err=True)
raise SystemExit(1)
@main.command(hidden=True)
def push():
"""Reserved: send a pipeline to a registry. Not yet supported.
`publish` already means "stage files into a directory", so the registry
gets its own verb rather than a second meaning for that one. The name is
claimed here so that nothing else takes it first.
"""
click.echo(
click.style("Error: ", fg="red", bold=True)
+ "'chartbook push' is reserved for sending a pipeline to a registry, "
"which is not yet supported. To stage a pipeline's files into a "
"directory, use 'chartbook publish'.",
err=True,
)
raise SystemExit(1)
[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
def _as_named(path=None) -> Path:
"""A folder as the user named it, made absolute with its links kept; the current folder as the shell has it by default.
For reading a catalog whose entries' paths were written from the
folder as named through a link (:func:`chartbook.manifest.catalog_entry_dir`).
The current folder is the shell's ``$PWD`` when that is it, since the
one the system gives has every link followed.
"""
import os
if path is not None:
return Path(os.path.abspath(path))
here = Path.cwd()
shell = os.environ.get("PWD", "")
try:
if shell and os.path.isabs(shell) and os.path.samefile(shell, here):
return Path(shell)
except OSError:
pass
return here
@main.command()
@click.option("--project-dir", type=click.Path(), help="Path to project directory")
@click.option(
"--within",
"within_days",
type=click.IntRange(min=0),
default=30,
show_default=True,
metavar="DAYS",
help="Fail on licenses that expire within this many days",
)
@click.option(
"--policy",
"policy_path",
type=click.Path(),
default=None,
help="Check required fields against the [policy] of this catalog "
"chartbook.toml (or its directory) instead of the project's own",
)
@click.option(
"--skip-outputs",
is_flag=True,
default=False,
help="Do not require data files, charts and notebooks to exist. For CI "
"that checks a project without building it",
)
def check(project_dir, within_days, policy_path, skip_outputs):
"""Check a project's metadata and paths; exit nonzero on any problem.
Meant for CI. Works on a pipeline or a catalog and reports four things,
each naming the chartbook.toml and field to edit:
\b
- fields the policy requires that are missing or empty
- declared paths that point at files that do not exist
- pipelines in [dependencies] that cannot be found where they are said
to be, and training data they do not declare
- dataframe licenses that are expired, or expire within --within days
Required fields come from the [policy] of the catalog or pipeline
checked, or from --policy, which checks a pipeline against the catalog
it is published to. With no policy anywhere, the default recommended
fields are reported as warnings and do not fail the check.
Only metadata and paths are checked; no data file is opened.
Data files, charts and notebooks are things a pipeline builds, and are
usually not in git. A CI job that has not run the pipeline will find
them all missing; --skip-outputs leaves them out and checks the rest.
Examples:
chartbook check
chartbook check --within 90
chartbook check --policy ../catalog
chartbook check --skip-outputs
"""
import warnings
from chartbook.check import NOT_BUILT_YET, display_path, not_built_hint, read_policy, run_checks
from chartbook.manifest import (
_read_manifest_file,
get_pipeline_ids,
get_pipeline_manifest,
load_manifest,
)
# A catalog is read from its folder as named too, links and all (_as_named)
named_dir = _as_named(project_dir)
project_dir = resolve_project_dir(project_dir)
manifest_path = project_dir / "chartbook.toml"
# An installed Claude Code skill written for an older ChartBook teaches
# the wrong commands: said first, before anything can stop the check,
# and it fails nothing
try:
from chartbook import claude_skill
for copy in claude_skill.stale(project_dir):
click.echo(f"warning: {claude_skill.notice(copy)}", err=True)
except Exception: # noqa: BLE001 - a note about the skill never stops a check
pass
try:
# Manifest warnings (e.g. unknown [project] keys) belong in the
# report, not in Python's warning format
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
manifest = load_manifest(base_dir=named_dir)
# A catalog's manifest always carries a resolved policy, so whether
# one was written down is read from the file itself
policy_given = "policy" in _read_manifest_file(project_dir)
if policy_path is not None:
# A catalog that sets no policy gives none: its default lists
# are recommendations, as they are everywhere else
manifest["policy"], policy_given = read_policy(policy_path)
policy_label = f"the policy in {policy_path}"
elif manifest["project"]["type"] == "catalog":
policy_label = "the catalog policy"
elif policy_given:
policy_label = "the pipeline's policy"
else:
policy_label = "the default policy"
except (ValueError, FileNotFoundError, TypeError, KeyError) as e:
click.echo(click.style("Error: ", fg="red", bold=True) + _said(e), err=True)
raise SystemExit(1)
# The checks warn about what they could not look at
with warnings.catch_warnings(record=True) as unchecked:
warnings.simplefilter("always")
problems = run_checks(
manifest,
project_dir,
within_days=within_days,
policy_label=policy_label,
skip_outputs=skip_outputs,
policy_given=policy_given,
)
for warning in [*caught, *unchecked]:
click.echo(f"warning: {warning.message}", err=True)
for problem in problems:
click.echo(problem.format(relative_to=Path.cwd()))
if problems:
files = len({p.file for p in problems})
click.echo(
f"\n{len(problems)} problem(s) found in {files} manifest(s).", err=True
)
if any(p.message.startswith(f"{NOT_BUILT_YET}:") for p in problems):
click.echo(not_built_hint(manifest), err=True)
_say_trust_level(manifest, project_dir)
raise SystemExit(1)
pipelines = [
get_pipeline_manifest(manifest, pid) for pid in get_pipeline_ids(manifest)
]
# Models and dependencies are counted only where there are some, so that
# the line for a project without them reads as it always has
sections = ["dataframes", "charts", "notebooks"]
for optional in ("models", "dependencies"):
if any(p.get(optional) for p in pipelines):
sections.append(optional)
counts = ", ".join(
f"{sum(len(p.get(section, {})) for p in pipelines)} {section}"
for section in sections
)
shown = display_path(manifest_path, relative_to=Path.cwd())
note = "; built outputs not checked" if skip_outputs else ""
click.echo(f"{shown}: OK ({len(pipelines)} pipeline(s), {counts}{note})")
_say_trust_level(manifest, project_dir)
def _say_trust_level(manifest, project_dir):
"""One last line of `chartbook check`, when a build here would not run as the user's own.
`check` reads and runs nothing, so it is not gated; it says the level
the folder is trusted at (``docs_src/design/trust.md``) when that is
not `run`, and the command that trusts it. For a catalog, the lowest
level among its own folder and its member folders on this machine.
"""
from chartbook.config import TRUST_LEVELS, trust_of
from chartbook.manifest import get_pipeline_dirs
try:
folders = [project_dir]
if manifest["project"]["type"] == "catalog":
folders += list(get_pipeline_dirs(manifest).values())
levels = [trust_of(folder) for folder in folders]
except (ValueError, OSError):
return # the record is faulty: the build says so
if all(level == "run" for level in levels):
return
lowest = None if None in levels else min(levels, key=TRUST_LEVELS.index)
if manifest["project"]["type"] == "catalog":
standing = "Not every folder of this catalog is trusted" + (" to run" if lowest == "render" else "")
command = "`chartbook trust --members` here trusts the catalog's folder and each member folder on this machine"
else:
standing = "This pipeline is " + ("trusted to render, not to run" if lowest == "render" else "not trusted")
command = "`chartbook trust` here trusts it"
refused = "`chartbook build` and `chartbook run` refuse to run the build script"
if lowest is None:
refused += (
", and `chartbook docs generate` renders the site as one from a store is (no query run, no file from "
"outside the folder)"
)
click.echo(f"{standing}: {refused}. {command}; `chartbook trust --list` shows what is trusted.")
@main.command("path")
@click.argument("ref")
@click.option(
"--kind",
type=click.Choice(["dataframe", "chart", "notebook", "note", "model"]),
help="The kind of asset REF names, when REF does not say "
"(default: dataframe). The same as writing KIND/REF.",
)
@click.option(
"--project-dir",
type=click.Path(),
help="Where to look for the pipeline: its chartbook.toml is found by "
"walking up from here (default: the current directory)",
)
def path_command(ref, kind, project_dir):
"""Print the path of an asset: this pipeline's, or a pinned copy of another's.
REF names an asset declared in the enclosing chartbook.toml. A dataframe
is named by its key alone; anything else has its kind in front:
\b
treasury_rates [dataframes.treasury_rates]
chart/yield_curve [charts.yield_curve]
notebook/analysis [notebooks.analysis]
note/methodology [notes.methodology]
model/rate_forecaster [models.rate_forecaster]
The path is printed absolute, whether or not the file exists yet, so a
build tool can use it for inputs and outputs alike. This is
`chartbook.assets.path()` for Make, shell, R and Stata:
\b
MERGED := $(shell chartbook path merged_rates)
An undeclared name fails, naming the table to add.
An asset of another pipeline has that pipeline in front:
\b
fed_yield_curve:treasury_rates
fed_yield_curve:chart/curve_today
The pipeline must be in [dependencies], and what is printed is the path
of the copy that chartbook.lock pins, in the store: not the other
pipeline's own directory. Run `chartbook pin` first. That copy stays as
it is until `chartbook update`.
Examples:
chartbook path repo_public
chartbook path chart/repo_rates
chartbook path repo_rates --kind chart --project-dir ../yield_curve
chartbook path fed_yield_curve:treasury_rates
"""
from chartbook.assets import path as resolve_path
from chartbook.store import StoreError
try:
asset_path = resolve_path(
ref, kind=kind, project_dir=project_dir or Path.cwd()
)
except (LookupError, FileNotFoundError, ValueError, TypeError, StoreError) as e:
click.echo(click.style("Error: ", fg="red", bold=True) + _said(e), err=True)
raise SystemExit(1)
# Nothing but the path on stdout: this output gets captured
click.echo(str(asset_path))
@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 write data_glimpses.md in, made if it does not exist "
"(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)",
)
@click.option("--project-dir", type=click.Path(), help="Path to project directory")
def create_data_glimpses(no_samples, no_stats, output_dir, size_threshold, project_dir):
"""Create a data glimpses report for the project's declared dataframes.
This command reads chartbook.toml, takes every dataframe it declares, and
creates a comprehensive data glimpse report in Markdown format. The list
comes from the manifest alone, so it works the same whether the project
is built with doit, Snakemake, Make or plain scripts. Declared dataframes
whose files do not exist yet are skipped.
The report is data_glimpses.md in --output-dir, which is made if it does
not exist. An --output-dir ending in .md is the report's own path.
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
project_dir = resolve_project_dir(project_dir)
# The report reads the files the manifest names: in a folder the user
# has not trusted, as a build of files from a store, nothing outside it
untrusted = _require_trust_to_render(project_dir)
try:
with _untrusted_mode(untrusted):
create_data_glimpses_main(
output_dir=output_dir,
no_samples=no_samples,
no_stats=no_stats,
size_threshold=size_threshold,
project_dir=project_dir,
)
except (FileNotFoundError, NotADirectoryError) 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()
except ValueError as error: # the settings file that names the default catalog is faulty
raise click.ClickException(str(error)) from None
# Read from the folder as it was named, links and all, as well as the
# folder itself: an entry's path written from the former still reads
named = _as_named(catalog_path) if catalog_path is not None else resolved
if named.is_dir():
named = named / "chartbook.toml"
try:
manifest = load_manifest(base_dir=named.parent)
except (ValueError, FileNotFoundError) as e:
click.echo(click.style("Error: ", fg="red", bold=True) + _said(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("index")
def index_group():
"""Read the index.json a build writes beside the site."""
@index_group.command("diff")
@click.argument("before", type=click.Path(exists=True))
@click.argument("after", type=click.Path(exists=True))
@click.option("--json", "as_json", is_flag=True, help="Print the diff as JSON, for a script")
@click.option(
"--exit-code", is_flag=True, help="Exit 1 when the two differ, as `git diff --exit-code` does"
)
def index_diff(before, after, as_json, exit_code):
"""Say what changed between two builds, from their index.json files.
BEFORE and AFTER are two index.json files, or the folders that hold
them (the site folder, `docs/`). Records are matched by kind and ID;
the two are compared as published, so a build directory's index and
the site's compare as equals, and `generated_at` is ignored.
\b
Examples:
chartbook index diff docs-main/ docs/
chartbook index diff old.json docs/index.json --exit-code
"""
import json
from chartbook import indexdiff
try:
found = indexdiff.diff(indexdiff.read_index(before), indexdiff.read_index(after))
except (OSError, ValueError) as e:
click.echo(click.style("Error: ", fg="red", bold=True) + _said(e), err=True)
raise SystemExit(2)
if as_json:
click.echo(json.dumps(found.to_dict(), indent=2, default=str))
else:
click.echo(indexdiff.format_diff(found))
if exit_code and not found.empty:
raise SystemExit(1)
#: How `chartbook ls` shows a pipeline the catalog names with no folder here
STORE_ONLY = "(store only: not on this machine; the catalog shows its snapshots)"
def _from_the_store(manifest, kinds=("Dataframe", "Chart")):
"""The dataframes and charts of each pipeline a catalog names with no folder here, by pipeline ID.
As the newest snapshot of each in the store lists them, each as
``(kind, ID, name)``; its name is plain text, as its owner wrote it.
At most :data:`chartbook.snapshot.STORED_ASSETS_SHOWN` of each, with how
many more there are. A store that cannot be read gives none.
:returns: By pipeline ID, the assets listed and how many are not.
"""
pipeline_ids = manifest.get("store_pipelines") or []
if not pipeline_ids:
return {}
from chartbook import snapshot
from chartbook.store import StoreError, default_store
try:
store = default_store()
found, _ = snapshot.readable_snapshots(store)
except (StoreError, OSError, ValueError):
return {pipeline_id: ([], 0) for pipeline_id in pipeline_ids}
listed = {}
for pipeline_id in pipeline_ids:
newest = max((t for t in found if t.pipeline == pipeline_id), key=lambda t: t.made_at, default=None)
assets = snapshot.stored_assets(newest, store) if newest is not None else []
shown = sorted((a for a in assets if a["kind"] in kinds), key=lambda a: (kinds.index(a["kind"]), a["id"]))
rows = [(a["kind"], a["id"], a["name"] or "Unknown") for a in shown]
listed[pipeline_id] = (rows[: snapshot.STORED_ASSETS_SHOWN], max(len(rows) - snapshot.STORED_ASSETS_SHOWN, 0))
return listed
@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, and those it shows from the store
stored = _from_the_store(manifest)
for pipeline_id in sorted([*manifest["pipelines"], *(manifest.get("store_pipelines") or [])]):
if pipeline_id not in manifest["pipelines"]:
click.echo(f"[pipeline] {pipeline_id}: {STORE_ONLY}")
rows, more = stored.get(pipeline_id, ([], 0))
for kind, asset_id, name in rows:
click.echo(f" [{kind.lower()}] {asset_id}: {name}")
if more:
click.echo(f" ... and {more} more")
continue
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/{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/{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"], *(manifest.get("store_pipelines") or [])]):
if pipeline_id not in manifest["pipelines"]:
click.echo(f"{pipeline_id}: {STORE_ONLY}")
continue
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":
stored = _from_the_store(manifest, ("Dataframe",))
for pipeline_id in sorted([*manifest["pipelines"], *stored]):
if pipeline_id not in manifest["pipelines"]:
rows, more = stored[pipeline_id]
for _, asset_id, name in rows:
click.echo(f"{asset_id}: {name}")
if more:
click.echo(f"... and {more} more of {pipeline_id}")
continue
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":
stored = _from_the_store(manifest, ("Chart",))
for pipeline_id in sorted([*manifest["pipelines"], *stored]):
if pipeline_id not in manifest["pipelines"]:
rows, more = stored[pipeline_id]
for _, asset_id, name in rows:
click.echo(f"{asset_id}: {name}")
if more:
click.echo(f"... and {more} more of {pipeline_id}")
continue
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/{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/{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}"
)
try:
config_type = resolve_project_type(data, source=str(catalog_toml_path))
except ValueError as error: # a requirement this ChartBook does not meet, or a type it cannot tell
raise click.ClickException(str(error)) from None
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 _reenable(raw_catalog, key, entry=True) -> bool:
"""Enable a pipeline of the catalog again: its entry's ``disabled`` flag, and the ``disabled`` list.
:param entry: Whether it has an entry of its own; one ``members`` finds is on the list only.
:returns: Whether it was disabled.
"""
listed_entry = raw_catalog["pipelines"].get(key) if entry else None
flagged = isinstance(listed_entry, dict) and bool(listed_entry.pop("disabled", False))
return _off_the_disabled_list(raw_catalog, key) or flagged
def _another_checkout(directory, listed, covered, checkouts):
"""The pipeline of the catalog that a folder is another checkout of: a worktree of its repository, or a second clone.
:param listed: The folders the catalog's entries name, each with its key.
:param covered: The folders ``members`` patterns match, each with its pattern.
:param checkouts: Where each folder is in its repository, as looked up already; filled in.
:returns: The other folder, its key in the catalog, and the pattern
that found it (None for an entry); or None.
"""
from chartbook.identity import checkout_of, derive_pipeline_id, same_pipeline_checked_out
from chartbook.manifest import _read_manifest_file
def where(folder):
if folder not in checkouts:
checkouts[folder] = checkout_of(folder)
return checkouts[folder]
mine = where(directory)
if mine is None:
return None
for folder, key in listed.items():
if folder != directory and same_pipeline_checked_out(where(folder), mine):
return folder, key, None
for folder, pattern in covered.items():
if folder == directory or not (folder / "chartbook.toml").is_file():
continue
if same_pipeline_checked_out(where(folder), mine):
try:
project = _read_manifest_file(folder).get("project")
key = derive_pipeline_id(project if isinstance(project, dict) else {}, folder)
except (OSError, ValueError):
continue
return folder, key, pattern
return None
def _off_the_disabled_list(raw_catalog, key) -> bool:
"""Take a pipeline ID off the catalog's ``[pipelines] disabled`` list, every time it is there.
:returns: Whether it was on it.
"""
pipelines = raw_catalog.get("pipelines", {})
listed = pipelines.get("disabled")
if not isinstance(listed, list) or key not in listed:
return False
pipelines["disabled"] = [other for other in listed if other != key]
if not pipelines["disabled"]:
pipelines.pop("disabled")
return True
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, catalog_entry_dir, 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_entry_dir(catalog_dir, resolved)
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()
[docs]
@main.group(cls=_Catalog)
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}")
# You made it: its folder is trusted to run, as a project `chartbook init` makes is
if _record_trust_or_say(catalog_path.parent, "run"):
click.echo(f"Trusted: {catalog_path.parent} (chartbook trust --list)")
click.echo("")
click.echo("Next steps:")
click.echo(" chartbook catalog add /path/to/pipeline # add a pipeline")
click.echo(" chartbook catalog generate # render its site")
click.echo(" chartbook catalog open # open it in the 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. Its folder is taken with every link in its path
# followed, as each pipeline's is below: a relative path is worked out
# between the two as they really are, and read back the same way (a
# home folder reached through a link, /var on macOS)
catalog_toml = _resolve_catalog_toml_path(catalog_path)
catalog_dir = catalog_toml.parent.resolve()
raw_catalog = _load_raw_catalog(catalog_toml)
# Get existing absolute paths for duplicate detection
existing_abs = _get_existing_absolute_paths(raw_catalog, catalog_toml.parent)
members_covered = _get_members_covered_paths(raw_catalog, catalog_dir)
existing_keys = set(raw_catalog.get("pipelines", {}).keys())
checkouts = {} # where each folder looked at is in its repository
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)
# In one order, whatever order the filesystem gave the glob: a
# repository's main worktree before one made with `git worktree add`,
# so that of two checkouts of one pipeline the main one is the entry and
# the other is said to be another checkout of it; then by path. Found by
# CI, where the worktree came first on one filesystem and not another
from chartbook.identity import checkout_of
def where(folder):
if folder not in checkouts:
checkouts[folder] = checkout_of(folder)
return checkouts[folder]
candidate_dirs.sort(key=lambda d: (not (where(d) is None or where(d).main), str(d)))
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]
if _reenable(raw_catalog, key):
reenabled += 1
click.echo(f" Re-enabled '{key}': {d.name}/")
else:
click.echo(
f" Already in catalog as '{key}': {d.name}/"
)
continue
# Another checkout of a pipeline the catalog has (a worktree, a
# second clone) is that pipeline, whatever its key there
other = _another_checkout(d, existing_abs, members_covered, checkouts)
if other is not None:
folder, key, pattern = other
how = f"found by pipelines.members pattern '{pattern}', " if pattern else ""
said = f"({how}another checkout of the same repository, {folder}): {d.name}/"
if _reenable(raw_catalog, key, entry=pattern is None):
reenabled += 1
click.echo(f" Re-enabled '{key}' {said}")
else:
click.echo(f" Already in catalog as '{key}' {said}")
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)
from chartbook.manifest import RESERVED_PIPELINES_KEYS
# An entry with no path is a pipeline the catalog shows from the store:
# a folder of the same ID is that pipeline, and fills the entry in
from_the_store = {
key
for key, entry in raw_catalog.get("pipelines", {}).items()
if key not in RESERVED_PIPELINES_KEYS and isinstance(entry, dict) and entry.get("path") is None
}
from chartbook.identity import checkout_of, same_pipeline_checked_out
# The folder each key names, listed already or added now: a second
# checkout of one repository is that same pipeline, not another one
folder_of = {key: folder for folder, key in existing_abs.items()}
planned = [] # (directory, name, key, whether it fills in an entry from the store)
for d, name, project_table in valid_pipelines:
key = _derive_catalog_key(d, project_table)
fills = key in from_the_store
if fills:
from_the_store.discard(key)
elif key in folder_of and same_pipeline_checked_out(checkout_of(folder_of[key]), checkout_of(d)):
click.echo(
f" Already in catalog as '{key}' (another checkout of the same repository, "
f"{folder_of[key]}): {d.name}/"
)
continue
else:
key = _ensure_unique_key(key, existing_keys)
existing_keys.add(key)
folder_of.setdefault(key, d)
planned.append((d, name, key, fills))
if not planned:
# Every one was another checkout of a pipeline already listed
if reenabled:
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}")
else:
click.echo("No new pipelines to add.")
return
# Prompt for confirmation if multiple
if len(planned) > 1 and not yes:
click.echo("")
click.echo("Pipelines to add:")
for d, name, key, fills in planned:
shown = " (shown from the store until now)" if fills else ""
click.echo(f" {key}: {name} ({d}){shown}")
click.echo("")
if not click.confirm(f"Add {len(planned)} pipeline(s)?"):
raise SystemExit(0)
# Add each pipeline
added = 0
for d, name, key, fills in planned:
# With / on every system, so a catalog file is read the same on each
try:
rel_path = Path(os.path.relpath(d, catalog_dir)).as_posix()
except ValueError:
# Cross-drive on Windows
rel_path = Path(d).as_posix()
# What is added is enabled, whatever the disabled list said of its ID
listed = _off_the_disabled_list(raw_catalog, key)
enabled = " (it was on the disabled list)" if listed else ""
if fills:
entry = raw_catalog["pipelines"][key]
entry["path"] = rel_path
entry.pop("disabled", None)
click.echo(f" Added '{key}', which the catalog showed from the store: {name} ({rel_path}){enabled}")
else:
raw_catalog["pipelines"][key] = {"path": rel_path}
click.echo(f" Added '{key}': {name} ({rel_path}){enabled}")
added += 1
# Adding a folder to your own catalog asks for it to be rendered
# there: trusted to render, unless it is trusted already. To run
# stays yours to say (`chartbook build` in a catalog runs every
# member's script)
if _trust_to_render_if_untrusted(d, key):
click.echo(f" Trusted to render: {d} (chartbook trust --list; `chartbook trust` there to run)")
# 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("generate")
@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.",
)
@click.option(
"--clean",
is_flag=True,
default=False,
help="Throw away what was kept from earlier builds and rebuild everything",
)
@click.option(
"--watch",
is_flag=True,
default=False,
help="After building, rebuild whenever a pipeline's files change, until Ctrl-C",
)
@click.option(
"--watch-interval",
type=click.FloatRange(min=0.1),
default=None,
metavar="SECONDS",
help="How often --watch checks for changes (default: 1)",
)
def catalog_generate(force_write, strict, clean, watch, watch_interval):
"""Render the global catalog's site, into docs/ beside its chartbook.toml.
The catalog is the one ~/.chartbook/settings.toml names, or else
~/.chartbook/chartbook.toml. `chartbook catalog open` opens the site.
The build is incremental. Its working files are kept next to the catalog
(in ``_docs/``), and the next build redoes only the pipelines whose files
changed since: for most of a large catalog, most of the time, nothing.
Nothing runs in between; it is a record on disk that the next build
reads. --clean discards it and rebuilds everything. --watch keeps
rebuilding as files change.
"""
_check_sphinx_installed()
catalog_path = _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"Generating the catalog's site from: {catalog_path}")
click.echo(f"Output directory: {output_dir}")
# As `chartbook docs generate` in the catalog's folder: every folder it
# reads must be trusted. Before --clean, so that a refused build leaves
# the kept tree. A settings file may point the catalog at a pipeline's
# folder: that renders as `chartbook docs generate` there would, trusted
# or not
untrusted = _require_trust_to_render(project_dir)
if clean:
from chartbook.incremental import looks_like_build_tree
if looks_like_build_tree(_docs_dir):
import shutil
shutil.rmtree(_docs_dir)
# A site built with MkDocs is built whole: the kept build directory is Sphinx's
from chartbook.manifest import load_manifest
try:
incremental = load_manifest(project_dir)["project"].get("writer", "sphinx") != "mkdocs"
except (ValueError, FileNotFoundError, TypeError, KeyError) as e:
click.echo(click.style("Error: ", fg="red", bold=True) + _said(e), err=True)
raise SystemExit(1)
if not incremental and watch:
click.echo(
click.style("Error: ", fg="red", bold=True)
+ "--watch rebuilds with Sphinx; this catalog is built with MkDocs ([project] writer = \"mkdocs\"), "
"whole, each time. Run it without --watch.",
err=True,
)
raise SystemExit(1)
with _untrusted_mode(untrusted):
_run_docs_build(
project_dir,
incremental=incremental,
watch=watch,
watch_interval=watch_interval,
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,
)
if watch:
return
click.echo("")
click.echo(f"Catalog site generated: {output_dir / 'index.html'}")
click.echo("Run 'chartbook catalog open' to open it in your browser.")
@catalog.command("open")
def catalog_open():
"""Open the global catalog's site in your default browser."""
catalog_path = _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 generate' 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:
# The command is in the working directory's pipeline: the calling file is this one
path = get_data_path(pipeline, dataframe, catalog_path=catalog, project_dir=Path.cwd())
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 (LookupError, OSError, 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)
def _echo_pin_report(report):
"""Print what a pin or an update did, one line per dependency and exception."""
from chartbook.check import display_path
by_dependency = {}
for event in report.events:
by_dependency.setdefault(event.dependency, []).append(event)
for dependency, events in by_dependency.items():
whole = [e for e in events if e.asset is None]
assets = [e for e in events if e.asset is not None]
counts = {}
for event in assets:
counts[event.status] = counts.get(event.status, 0) + 1
words = {
"pinned": "pinned",
"restored": "put back in the store",
"moved": "moved",
"kept": "already pinned",
"stale": "changed upstream since it was pinned",
"not_built": "not built",
"outside": "outside the repository",
"let_go": "let go",
"dropped": "no longer declared",
"lost": "lost",
"unsynced": "not fully synced",
}
summary = ", ".join(
f"{counts[status]} {words[status]}" for status in words if status in counts
)
for event in whole:
if event.status == "fetched":
click.echo(f"{dependency}: fetched {event.detail}")
elif event.status == "removed":
summary = "no longer a dependency; its pins are let go"
elif event.status in ("unreachable", "unfetched", "no_snapshot"):
note = f"cannot be pinned: {event.detail}"
summary = f"{note}; {summary}" if summary else note
click.echo(f"{dependency}: {summary or 'declares nothing to pin'}")
for event in whole:
if event.status == "relocated":
click.echo(
f" [dependencies] now says {event.detail}. It stays pinned "
f"where it was: `chartbook update {dependency}` follows it."
)
elif event.status in ("passed_over", "unchecked", "id_changed"):
click.echo(f" {event.detail}.")
for event in assets:
if event.status == "moved":
click.echo(f" {event.asset}\n {_short_hash(event.old)} -> {_short_hash(event.new)}")
elif event.status == "not_built":
click.echo(f" not built: {event.asset} ({event.detail})")
elif event.status == "stale":
click.echo(f" changed upstream: {event.asset}")
elif event.status == "let_go":
click.echo(f" let go: {event.asset} ({event.detail})")
elif event.status == "outside":
click.echo(f" not pinned: {event.asset}: {event.detail}")
elif event.status == "dropped":
click.echo(f" no longer declared: {event.asset}")
elif event.status == "lost":
pinned_at = f" ({_short_hash(event.old)})" if event.old else ""
click.echo(f" lost: {event.asset}{pinned_at}: {event.detail}")
elif event.status == "unsynced":
click.echo(f" not fully synced: {event.asset}: {event.detail}")
if dependency in report.snapshots:
taken = report.snapshots[dependency]
made_in = f", which says it was made in {taken.repository}" if getattr(taken, "repository", "") else ""
click.echo(
f" From its snapshot at {taken.commit[:7]}, made {_when(taken.made_at)}{made_in}."
)
if dependency in report.git and counts.get("let_go"):
click.echo(
" What was let go was built at the commit pinned before, and is "
"not built at this one. `chartbook run` builds it and pins it."
)
elif counts.get("let_go"):
click.echo(" What was let go is not built in the snapshot this moved to.")
if dependency in report.git and counts.get("not_built"):
click.echo(" A git dependency has only what its repository commits.")
if not report.events:
click.echo("No dependencies to pin.")
shown = display_path(report.lockfile, relative_to=Path.cwd())
if report.written:
click.echo(f"Wrote {shown}.")
elif report.lockfile.exists():
click.echo(f"{shown} is unchanged.")
stale = report.count("stale")
if stale:
click.echo(
f"{stale} pinned asset(s) have changed upstream. `chartbook update` "
f"moves the pins."
)
def _when(made_at):
"""A snapshot's time as a person reads it: 2026-10-13 18:22 UTC."""
return f"{made_at[:10]} {made_at[11:16]} UTC"
def _short_hash(content_hash):
"""A content hash as a person reads it in a line of output: its first twelve digits.
``sha256:4b1f9a0c2e3d…``, as a provenance panel and ``index diff`` show
one. The lockfile keeps the whole hash.
"""
if isinstance(content_hash, str) and len(content_hash) > 19:
return content_hash[:19] + "…"
return content_hash
def _run_pin(project_dir, dependencies=None, on_refused=None):
"""Run a pin, or an update when *dependencies* is given; exit 1 on a problem.
:param on_refused: Called before exiting when the lockfile was not
written because its new pins would close a cycle, or lead into one.
"""
from chartbook import lock
from chartbook.store import StoreError
project_dir = resolve_project_dir(project_dir)
try:
if dependencies is None:
report = lock.pin(project_dir)
else:
report = lock.update(project_dir, dependencies)
except (lock.LockError, StoreError, ValueError, OSError) as e:
click.echo(click.style("Error: ", fg="red", bold=True) + _explain_os_error(e), err=True)
raise SystemExit(1)
if report.refused:
# Nothing was written, so what it would have pinned is not shown as pinned
click.echo(click.style("Error: ", fg="red", bold=True) + report.refused, err=True)
if on_refused is not None:
on_refused()
raise SystemExit(1)
_echo_pin_report(report)
if report.unrooted:
click.echo(
f"The store's note of this lockfile, for `store gc`, could not be written "
f"({report.unrooted}); `store gc` goes by the note it has."
)
if report.problems:
click.echo(
f"\n{len(report.problems)} thing(s) could not be pinned.", err=True
)
raise SystemExit(1)
def _echo_frozen_report(step, report):
"""Print what `--frozen` found: one line when all is well, else each problem."""
who = f"{step.id}: " if step is not None else ""
if not report.problems and not report.dependencies:
click.echo(f"{who}no dependencies to check. Nothing written.")
return
if report.unrooted:
# Only the note for gc: the pins are as they were
click.echo(
f"{who}the store's note that this machine reads these pins could not be written "
f"({report.unrooted}): `store gc` on another machine does not know of them.",
err=True,
)
written = "Nothing written but the store's note that this machine reads these pins." if report.noted else "Nothing written."
if not report.problems:
click.echo(
f"{who}{report.pins} pin(s) of {report.dependencies} dependenc"
f"{'y' if report.dependencies == 1 else 'ies'}, all in the store. {written}"
)
return
for problem in report.problems:
where = problem.asset or problem.dependency
click.echo(f"{who}{where + ': ' if where else ''}{problem.message}", err=True)
click.echo(
f"{who}{len(report.problems)} problem(s): the pipeline cannot be built from its "
f"lockfile alone. {written}",
err=True,
)
@main.command()
@click.option("--project-dir", type=click.Path(), help="Path to the pipeline")
@click.option(
"--frozen",
is_flag=True,
help="Write nothing but the store's note for gc: say whether the lockfile is complete and every pin is in the store",
)
def pin(project_dir, frozen):
"""Freeze what this pipeline depends on.
Each pipeline in [dependencies] is pinned to a snapshot of it: what its
owner declared ready, at a commit (`chartbook snapshot`). The content
hash of each of its assets is written to chartbook.lock. Commit that
file: a change to what the project depends on then shows in a diff.
None of the other pipeline's data in its folder is read. It can be
half-way through a change, and what you pin is still the build that
was declared ready. Its chartbook.toml and chartbook.lock are read, to
see that the pin makes no cycle: a pin whose new or moved entries would
lead back to this pipeline, or into pipelines that depend on each
other, is not written.
A pin that already exists is never moved, whatever the other pipeline
has done since; `chartbook update` moves pins. What `pin` does do again
is pin a dependency that has no pin yet.
A dependency that is a git repository is fetched, and pinned from a
clean copy of the commit.
--frozen pins nothing and writes nothing but, where the store can be
written, its note that this machine reads these pins, so that `store
gc` on another machine keeps them: not the lockfile, no object. It says
whether the pipeline can be built from its lockfile alone, every
dependency pinned and every pin in the store, and exits 1 if not. For
CI, and for a store you may only read.
\b
Examples:
chartbook pin
chartbook pin --project-dir ../rate_forecast
chartbook pin --frozen
"""
if frozen:
_run_frozen(project_dir)
return
_run_pin(project_dir)
def _said(error) -> str:
"""An error as it may be printed: with no password that a path or URL in it carried (``//user:pw@host``)."""
from chartbook.config import without_credentials
return without_credentials(str(error))
def _explain_os_error(error):
"""An OSError as a sentence: what a read-only store means, else the error."""
from chartbook import lock
from chartbook.store import ReadOnlyStoreError, default_store
if isinstance(error, ReadOnlyStoreError):
return _said(
f"{error} This had to write it: a git dependency's first pin and a "
f"snapshot both put files into the store. Do it where the store can be written; or, "
f"if the store already has what the lockfile names, use --frozen."
)
if isinstance(error, OSError) and lock.is_read_only(error):
try:
the_store = default_store()
root = str(getattr(the_store, "root", ""))
except Exception: # pragma: no cover - the store path is unreadable
the_store, root = None, ""
if root and str(getattr(error, "filename", "") or "").startswith(root):
return _said(
f"The store at {the_store} is read-only for you, and this had to write "
f"it: a git dependency's first pin and a snapshot "
f"both put files into the store. Do it where the store can be "
f"written; or, if the store already has what the lockfile names, "
f"use --frozen. ({error})"
)
return _said(error)
def _run_frozen(project_dir):
"""Say whether a pipeline can be built from its lockfile alone; exit 1 if not."""
from chartbook import lock
from chartbook.store import StoreError
try:
report = lock.verify(resolve_project_dir(project_dir), note=True)
except (lock.LockError, StoreError, ValueError, OSError) as e:
click.echo(click.style("Error: ", fg="red", bold=True) + _explain_os_error(e), err=True)
raise SystemExit(1)
_echo_frozen_report(None, report)
if report.problems:
raise SystemExit(1)
# Built from its lockfile alone, as `chartbook run --frozen` would: not in a cycle
from chartbook import lineage
from chartbook.snapshot import SnapshotError
from chartbook.snapshot import pipeline_in
try:
folder = resolve_project_dir(project_dir)
loop, pipeline_id = lineage.cycle(folder), pipeline_in(folder)
except (SnapshotError, ValueError, OSError):
loop = None
if loop is not None:
click.echo(
click.style("Error: ", fg="red", bold=True)
+ f"{pipeline_id}: {lineage.cycle_lead(loop)}, so there is no order to build in.\n"
+ lineage.explain_cycle(loop),
err=True,
)
raise SystemExit(1)
@main.command()
@click.argument("dependencies", nargs=-1)
@click.option("--project-dir", type=click.Path(), help="Path to the pipeline")
def update(dependencies, project_dir):
"""Move pins forward to the newest snapshot of each dependency.
With no argument, every dependency is updated. Name some to update only
those, as they are written in [dependencies]; the scope may be left off
when only one has that name. Each pin that changes is printed with its
old and new hash, and chartbook.lock is rewritten.
Nothing is rebuilt: this pipeline's own outputs are as they were.
\b
Examples:
chartbook update
chartbook update fed_yield_curve
"""
_run_pin(project_dir, dependencies=list(dependencies))
@main.command("add")
@click.argument("pipeline")
@click.option("--as", "key", metavar="NAME", help="The name to know it by in code, PIPELINE by default (`NAME:dataframe`)")
@click.option("--commit", metavar="COMMIT", help="Take its snapshots at this commit (the start of one), not the newest")
@click.option("--project-dir", type=click.Path(), help="Path to the pipeline")
def add_command(pipeline, key, commit, project_dir):
"""Declare a dependency on another pipeline by its ID, and pin its newest snapshot from the store.
Writes `"PIPELINE" = {}` under [dependencies] in this pipeline's
chartbook.toml, leaving the rest of the file as it is, then runs
`chartbook pin`. The pipeline is read from its snapshots in the store
alone: no folder of it here, and no clone. `chartbook update` moves the
pin to a newer snapshot.
\b
Examples:
chartbook add acme/rates
chartbook add acme/rates --as rates
chartbook add acme/rates --commit 3f2a9c1
"""
from chartbook.identity import validate_pipeline_id
from chartbook.manifest import dependency_line, validate_dependencies, with_dependency
project_dir = resolve_project_dir(project_dir)
manifest_file = project_dir / "chartbook.toml"
try:
# Before anything is written: a manifest for a newer ChartBook is not edited by this one
from chartbook.manifest import _read_manifest_file, require_chartbook
if manifest_file.is_file():
require_chartbook(_read_manifest_file(project_dir), str(manifest_file))
validate_pipeline_id(pipeline)
from chartbook.snapshot import SnapshotError, pipeline_in
try:
itself = pipeline_in(project_dir)
except (SnapshotError, ValueError):
itself = None
if pipeline == itself:
raise ValueError(f"{pipeline} is this pipeline itself: a pipeline cannot depend on itself.")
key = key or pipeline
entry = {}
if key != pipeline:
entry["id"] = pipeline
if commit:
entry["commit"] = commit
with open(manifest_file, encoding="utf-8", newline="") as f: # as it is, line endings included
text = f.read()
changed = with_dependency(text, key, entry)
import tomli
validate_dependencies(tomli.loads(changed).get("dependencies") or {}, project_dir)
except (ValueError, OSError) as e:
click.echo(click.style("Error: ", fg="red", bold=True) + _explain_os_error(e), err=True)
raise SystemExit(1)
with open(manifest_file, "w", encoding="utf-8", newline="") as f:
f.write(changed)
click.echo(f"Added to [dependencies] in {manifest_file}: {dependency_line(key, entry)}")
def put_back():
# A dependency whose pin would close a cycle, or lead into one, is not added either
with open(manifest_file, "w", encoding="utf-8", newline="") as f:
f.write(text)
click.echo(f"{pipeline} was not added: {manifest_file} is as it was.", err=True)
_run_pin(project_dir, on_refused=put_back)
@main.command()
@click.option("--project-dir", type=click.Path(), help="Path to the pipeline")
@click.option(
"--deep",
is_flag=True,
help="Also refuse if any snapshot upstream has a gap in its own lineage (see `chartbook lineage`)",
)
def snapshot(project_dir, deep):
"""Declare this pipeline's build ready for the pipelines that depend on it.
Every asset the pipeline declares and has built is copied into the
store, labelled with the commit the pipeline is at. That is a snapshot,
and it is what other pipelines pin: they never read this folder. With
the bytes go the pipeline's own index at that commit and the files a
site needs (the manifest, the README, the write-ups, the site pages),
only those git tracks, so a version's site can be built from the store.
Everything in the pipeline's folder must be committed first. The label
says "this is what that commit built", and with uncommitted changes it
would not be true. What git ignores, such as built data, does not count.
A snapshot is made when you ask for one, not by every build. Until you
make another, pipelines that depend on this one keep getting this one,
however much you change here. When you do, they get it with
`chartbook update`, and not before.
Build first: this does not run the build. `chartbook run` does.
\b
Examples:
chartbook run && chartbook snapshot
chartbook snapshot --project-dir ../fed_yield_curve
"""
from chartbook import snapshot as snapshots
from chartbook.store import StoreError
project_dir = resolve_project_dir(project_dir)
try:
report = snapshots.make(project_dir, deep=deep)
except (snapshots.SnapshotError, StoreError, ValueError, OSError) as e:
click.echo(click.style("Error: ", fg="red", bold=True) + _explain_os_error(e), err=True)
raise SystemExit(1)
_echo_snapshot(report)
def _lineage_line(node) -> str:
"""How one pipeline of a lineage is shown."""
made = f"{node.made_at[:10]} {node.made_at[11:19]} UTC" if node.made_at else ""
if node.kind == "working":
return f"{node.pipeline} (this folder)"
if node.kind == "snapshot":
seen = " (shown above)" if node.seen else ""
checked = {"pins": "pins checked", "deep": "lineage checked"}.get(node.checked, "made before pins were checked")
tagged = f", tagged {', '.join(node.tags)}" if node.tags else ""
return f"{node.label}: {node.pipeline} {node.commit[:7]}{tagged}, made {made}, {checked}{seen}"
if node.kind == "git":
at = f" at {node.commit[:7]}" if node.commit else ""
return f"{node.label}: pinned from its git clone{at}; not walked further"
if node.kind == "no_snapshot":
return f"{node.label}: its pin names no snapshot"
if node.kind == "nothing":
return f"{node.label}: nothing pinned"
if node.kind == "mismatch":
return f"{node.label}: its snapshot at {node.commit[:7]} does not hold what was pinned"
if node.kind == "unreadable":
return f"{node.label}: a record that cannot be read"
return f"{node.label}: not found in the store"
def _lineage_lines(node, prefix: str = "", last: bool = True, top: bool = True) -> list:
"""A lineage as a tree, each gap under the pipeline it is at."""
lines = [(prefix + ("" if top else ("└── " if last else "├── "))) + _lineage_line(node)]
inner = prefix + ("" if top else (" " if last else "│ "))
for gap in node.gaps:
lines.append(f"{inner}{'│ ' if node.children else ' '} gap: {gap}")
for number, child in enumerate(node.children):
lines += _lineage_lines(child, inner, number == len(node.children) - 1, top=False)
return lines
@main.command("lineage")
@click.option("--project-dir", type=click.Path(), help="Path to the pipeline")
@click.option("--check", is_flag=True, help="Exit 1 if there is any gap: for CI")
@click.option(
"--rehash",
is_flag=True,
help="Also hash every asset of every snapshot again, which reads all of their bytes",
)
@click.option("--json", "as_json", is_flag=True, help="Print the lineage as JSON")
def lineage_command(project_dir, check, rehash, as_json):
"""Show where this pipeline's data comes from: every snapshot upstream, and any gap.
Follows the pins up from this pipeline's lockfile: each snapshot it is
pinned to, the snapshots those were pinned to, and so on. Each step is
checked: the snapshot is in the store, what was pinned is what it
holds (by hash), and its recorded pins cover every dependency its own
manifest declares. Nothing is written.
\b
Examples:
chartbook lineage
chartbook lineage --check
chartbook lineage --rehash --json
"""
import json
from chartbook import lineage
from chartbook import snapshot as snapshots
from chartbook.store import StoreError
project_dir = resolve_project_dir(project_dir)
try:
found = lineage.walk(project_dir, rehash=rehash)
except (snapshots.SnapshotError, StoreError, ValueError, OSError) as e:
click.echo(click.style("Error: ", fg="red", bold=True) + _explain_os_error(e), err=True)
raise SystemExit(2)
gaps = found.gaps()
if as_json:
click.echo(json.dumps(found.to_dict(), indent=2))
else:
for line in _lineage_lines(found.root):
click.echo(line)
rehashed = f", {found.rehashed} object(s) hashed again" if rehash else ""
said = f"{len(gaps)} gap(s)" if gaps else "no gaps"
click.echo(f"{found.snapshots} snapshot(s) upstream{rehashed}; {said}.")
if check and gaps:
raise SystemExit(1)
@main.command("run")
@click.argument("script", required=False, default="build")
@click.option(
"--project-dir",
type=click.Path(),
help="Path to the pipeline, or to a catalog (default: the current directory)",
)
@click.option(
"--force",
is_flag=True,
help="Build a git dependency again, even if it is built already",
)
@click.option(
"--snapshot",
"make_snapshot",
is_flag=True,
help="After the build, declare it ready: make a snapshot (see `chartbook snapshot`)",
)
@click.option(
"--frozen",
is_flag=True,
help="Pin nothing and write nothing before the build but the store's note for gc; refuse unless every pin is in the store",
)
@click.option(
"--remake",
is_flag=True,
help="Build even though an asset marked keep = true is not on disk while the store has a version of it, so that the build makes it again (`chartbook restore` puts it back instead)",
)
@click.option(
"--upstream",
is_flag=True,
help="First build and snapshot each pipeline this one depends on by path that has no snapshot to pin, its own first; each must be trusted and committed",
)
def run_command(script, project_dir, force, make_snapshot, frozen, remake, upstream):
"""Run a script in this pipeline: `build`, unless another is named.
SCRIPT is a name from [scripts] in chartbook.toml.
\b
[scripts]
build = "doit"
test = "pytest"
`build` is the script that makes a pipeline's data current. Before it
runs, whatever this pipeline depends on and has not pinned yet is
pinned, so its code can read it. A pin that exists is never moved: you
are told what has changed, and `chartbook update` moves pins.
It never builds in a folder this pipeline depends on, unless you say
--upstream (below). That folder is somebody's work, and this pipeline
reads what is pinned, not what is there.
A dependency that is a git repository is built once, in ChartBook's own
clean copy of the commit, so that what it builds can be pinned. --force
builds it again, which is how data it pulls from outside is refreshed.
In a catalog, the script is run in each of the catalog's pipelines.
--snapshot builds and then declares the build ready, in one command.
It is `chartbook run` followed by `chartbook snapshot`, with two
differences: it checks that everything is committed before it builds,
and the snapshot records that the build ran in the same command.
\b
Examples:
chartbook run
chartbook run test
chartbook run --force
chartbook run --snapshot
chartbook run --frozen
chartbook run --upstream
--frozen pins nothing and writes nothing before the build but, where
the store can be written, its note that this machine reads these pins:
not the lockfile, no object, and no git dependency is fetched or built.
The build runs only if the lockfile is complete and every pin is in
the store (`chartbook pin --frozen` says the same without building).
For CI, and for a store you may only read.
An asset chartbook.toml marks `keep = true` cost something to make. If
one is not on disk while the store has a version of it, `build` stops
before anything runs and says so: `chartbook restore` puts it back,
and --remake runs the build without it, which makes it again.
--upstream is for folders that are yours. A pipeline this one depends
on by path, on this machine, that has no snapshot to pin and is not
pinned yet is built first, and snapshotted once its build succeeds, so
that this one can pin it; its own the same way, each before what
depends on it. Each must be trusted to run, have a `build` script and
have everything committed, and its build must leave it so. The first
that fails stops the run.
"""
project_dir = resolve_project_dir(project_dir)
from chartbook.manifest import BUILD_SCRIPT
# Not for arguments the run refuses anyway: it says why, and nothing is recorded
refused = (frozen and (make_snapshot or upstream)) or ((frozen or make_snapshot or upstream) and script != BUILD_SCRIPT)
if not refused:
_ask_before_running(project_dir, script, may_render=False, upstream=upstream)
_run_script(
project_dir,
script,
force=force,
snapshot=make_snapshot,
frozen=frozen,
remake=remake,
upstream=upstream,
)
def _has_script(project_dir, script):
"""Whether a pipeline, or any pipeline of a catalog, has a script by this name."""
from chartbook import run as runner
try:
steps = runner.plan(project_dir, git=False)
except (runner.RunError, ValueError, OSError) as e:
click.echo(click.style("Error: ", fg="red", bold=True) + _said(e), err=True)
raise SystemExit(1)
return any(script in step.scripts for step in steps if step.source == "asked")
def _echo_snapshot(report, upstream=False):
"""Print what making a snapshot did."""
taken = report.snapshot
built = sum(1 for record in taken.assets.values() if record.get("content_hash"))
summary = f"{built} asset(s) in the store"
if report.not_built:
summary += f", {len(report.not_built)} not built"
click.echo(f"{taken.pipeline} at {taken.commit[:7]}: {summary}")
for asset, declared_path in report.not_built:
click.echo(f" not built: {asset} ({declared_path})")
for model, version in report.new_versions:
click.echo(f" {model}: new version {version}")
if report.index_skipped:
click.echo(
f" index not recorded: {report.index_skipped}. The bytes and the documents are in the "
f"store; `chartbook check` says what a docs build would refuse."
)
for path, why in report.documents_skipped:
shown = path if path.isprintable() else ascii(path) # a name may carry what a terminal would act on
click.echo(f" document not kept: {shown} ({why})")
for model, label in report.skipped_labels:
click.echo(
f" label not recorded: {model} {label!r}. A label is letters, digits, '.', "
f"'_' and '-', does not read as a hash, and names a version by twelve or more "
f"of its digits; `chartbook check` says the same."
)
if report.made and upstream:
click.echo("Snapshot made, for what depends on it in this run to pin.")
elif report.made:
click.echo(
"Snapshot made. A pipeline that depends on this one gets it with "
"`chartbook update`."
)
else:
click.echo(
f"Nothing has changed since the snapshot of {_when(taken.made_at)}. "
f"No new one was made."
)
def _run_script(
project_dir, script, force=False, snapshot=False, frozen=False, remake=False, remake_command="chartbook run --remake",
upstream=False,
):
"""Run a named script as `chartbook run` does; exit with its code if it fails."""
from chartbook import lock
from chartbook import run as runner
from chartbook.store import StoreError
def show_pins(step, pins):
# Said only when there is something to say: a pin that is not
# simply still in place, or a lockfile that was written
quiet = ("kept", "not_built") # as they were the last time
if pins.written or any(event.status not in quiet for event in pins.events):
click.echo(f"{step.id}: pinning what it depends on")
_echo_pin_report(pins)
try:
report = runner.run(
project_dir,
script,
echo=click.echo,
on_pin=show_pins,
force=force,
snapshot=snapshot,
on_snapshot=lambda step, made: _echo_snapshot(made, upstream=step.snapshot_first),
frozen=frozen,
on_verify=_echo_frozen_report,
remake=remake,
remake_command=remake_command,
upstream=upstream,
)
except (runner.RunError, lock.LockError, StoreError, ValueError, OSError) as e:
click.echo(click.style("Error: ", fg="red", bold=True) + _explain_os_error(e), err=True)
raise SystemExit(1)
if report.failed is not None:
click.echo(
click.style("Error: ", fg="red", bold=True)
+ f"{report.failed.id}: {report.reason}. Nothing after it was run.",
err=True,
)
raise SystemExit(report.exit_code or 1)
click.echo(f"Ran {script} in {len(report.ran)} pipeline(s).")
@main.command("restore")
@click.argument("names", nargs=-1)
@click.option("--project-dir", type=click.Path(), help="Path to the pipeline (default: the current directory)")
def restore_command(names, project_dir):
"""Put this pipeline's own assets back on disk, from its snapshots in the store.
With no NAMES, every asset chartbook.toml marks `keep = true` that is
not on disk: a costly output, after a clean or in a fresh clone. Name
assets to put those back, kept or not, as `chartbook path` names them:
`embeddings`, `model/forecaster`, `chart/curve`.
Each comes from the newest snapshot of this pipeline that holds it,
with its bytes in the store, and is written at the path the manifest
declares now; the command says which commit and date it came from.
Nothing on disk is written over: an asset that is there is left, and
said. This is the mirror of `chartbook snapshot`, which copies the
folder into the store. Whether your build tool then skips the step
that makes the asset is its own rule; the guide on keeping costly
outputs has the recipe for doit.
Exits 1 if an asset named could not be put back. With no NAMES, a kept
asset that is missing and that no snapshot holds yet (a first build) is
said, and the command exits 0, so that CI may run it before every
build. The snapshots are found through the commits the clone has: a
shallow clone (CI's `fetch-depth: 1`) sees none of them.
\b
Examples:
chartbook restore
chartbook restore embeddings
chartbook restore model/forecaster chart/curve
"""
from chartbook import snapshot as snapshots
from chartbook.store import StoreError, default_store
directory = resolve_project_dir(project_dir)
try:
report = snapshots.restore(directory, list(names) or None, default_store())
except (snapshots.SnapshotError, StoreError, ValueError, OSError) as e:
click.echo(click.style("Error: ", fg="red", bold=True) + _said(e), err=True)
raise SystemExit(1)
for name, path, taken, size in report.put_back:
click.echo(
f"{name}: put back at {path} from the snapshot at {taken.commit[:7]}, made {_when(taken.made_at)} "
f"({_format_bytes(size)})"
)
for name, why in report.left:
click.echo(f"{name}: not put back: {_printable(why)}", err=True)
if not names and not report.put_back and not report.left:
click.echo("Nothing to restore: nothing marked `keep = true` is missing from disk.")
# Asked for by name and not put back: an error. Found missing on its own
# and not put back (a first build, with no snapshot yet): said, and
# nothing more, so that a CI step may run this before every build
if names and report.left:
raise SystemExit(1)
@main.group()
def store():
"""Look after the store: where pinned files and snapshots are kept.
The store keeps files under the hash of what is in them. It is a folder
on this machine, or one machines share (on a shared disk, or synced by
a service such as Google Drive), or a bucket. It is ~/.chartbook/store
unless CHARTBOOK_STORE, or [store] url in ~/.chartbook/settings.toml or
in the organization's settings file (/etc/chartbook/settings.toml, or
what CHARTBOOK_SYSTEM_SETTINGS names), says otherwise. `chartbook store
info` says which it is.
"""
@store.command("info")
def store_info():
"""Say which store this machine uses, and what set it.
The store is a setting of the machine, read in layers: CHARTBOOK_STORE,
then [store] url in your settings file, then in the organization's,
then the default. On a machine that is to share a store with another,
this is what to check first.
\b
Examples:
chartbook store info
CHARTBOOK_STORE=/shared/store chartbook store info
"""
from chartbook.config import (
STORE_ENV_VAR,
folder_of_store_url,
get_cache_path,
get_global_settings_path,
get_store_setting,
get_system_settings_path,
shown_store_url,
store_kind,
without_credentials,
)
from chartbook.store import store_for_url
try:
url, origin = get_store_setting()
kind = store_kind(url)
folder = folder_of_store_url(url) if kind == "folder" else None
# Never a user name or password, which a URL, its endpoint or a //host path may carry
shown = shown_store_url(str(folder) if folder is not None else url)
cache = get_cache_path()
except ValueError as e:
click.echo(click.style("Error: ", fg="red", bold=True) + without_credentials(str(e)), err=True)
raise SystemExit(1)
# The store of that kind, as the first pin or read would make it: a bucket
# needs its library, and this is where to hear that it is missing, not then
try:
store_for_url(url)
not_usable = None
except ValueError as e:
not_usable = e
said_by = {
"environment": f"set by {STORE_ENV_VAR}",
"user": f"set by [store] url in {get_global_settings_path()}, your settings",
"system": f"set by [store] url in {get_system_settings_path()}, the organization's settings",
"default": f"the default: neither {STORE_ENV_VAR} nor a [store] url in a settings file says otherwise",
}[origin]
if folder is not None and not folder.exists():
said_by += "; nothing is there yet"
what = {"folder": "a folder", "s3": "an S3 bucket"}.get(kind, f"a {kind}:// store")
click.echo(f"Store: {shown}")
click.echo(f" {what}, {said_by}")
if not_usable is not None:
click.echo(click.style("Error: ", fg="red", bold=True) + without_credentials(str(not_usable)), err=True)
raise SystemExit(1)
click.echo(f"Cache: {shown_store_url(str(cache))}")
click.echo(" where what is read from a remote store is kept on this machine")
@store.command("verify")
def store_verify():
"""Hash every object in the store again; exit nonzero if one has changed.
An object's name is the hash of its content, so this finds whatever has
changed since it was stored: a file the store hard-linked or symlinked
to that has since been rewritten, a link whose file has gone, or
anything edited in the store by hand. Every byte is read, so on a large
store it takes a while.
Examples:
chartbook store verify
CHARTBOOK_STORE=/shared/store chartbook store verify
"""
from chartbook.store import StoreError, default_store
try:
the_store = default_store()
total = len(list(the_store.hashes()))
problems = the_store.verify()
except (StoreError, ValueError) as e:
click.echo(click.style("Error: ", fg="red", bold=True) + _said(e), err=True)
raise SystemExit(1)
for problem in problems:
click.echo(problem.format())
if problems:
click.echo(
f"\n{len(problems)} of {total} object(s) in {the_store} are "
f"not what their names say.",
err=True,
)
raise SystemExit(1)
click.echo(f"{the_store}: OK ({total} object(s))")
def _printable(text) -> str:
"""Text a store holds, as it may be printed: a control character (a line break, an escape) is a ``?``."""
return "".join("?" if ord(c) < 32 or 127 <= ord(c) < 160 else c for c in str(text))
def _format_bytes(size):
"""A size for a person: ``18.2 KB``, ``4.1 GB``."""
size = float(size)
for unit in ("bytes", "KB", "MB", "GB"):
if size < 1024 or unit == "GB":
return f"{int(size)} bytes" if unit == "bytes" else f"{size:.1f} {unit}"
size /= 1024
@store.command("gc")
@click.option("--dry-run", is_flag=True, help="List what would be removed, and remove nothing")
@click.option("--yes", "-y", is_flag=True, help="Do not ask")
@click.option(
"--forget-missing",
is_flag=True,
help="Stop keeping the pins of a lockfile on this machine that cannot be found (another machine's are never forgotten from here)",
)
@click.option(
"--keep-days",
type=click.FloatRange(min=0),
default=None,
metavar="DAYS",
help="Keep a snapshot that nothing pins and that is not the newest of its pipeline for this long (default: 7)",
)
@click.option(
"--keep-lineage",
is_flag=True,
help="Also keep every snapshot a kept snapshot was made from, as far up as it goes, so `chartbook lineage` finds them all",
)
@click.option(
"--aggressive",
is_flag=True,
help="Let names keep nothing: no snapshot is kept for a git tag or branch, or for a model label",
)
def store_gc(dry_run, yes, forget_missing, keep_days, keep_lineage, aggressive):
"""Remove from the store what nothing needs any more.
The store is shared by every project of yours. Two things keep what is
in it. A lockfile: each `chartbook pin` and `chartbook update` notes
its lockfile here, and every object one of them names is kept. And a
snapshot: what `chartbook snapshot` kept of a pipeline at a commit.
A snapshot is kept if a lockfile pins it, if a lockfile pins a model
version by its label and this is the newest snapshot holding that
version, if it is the newest of its pipeline from its repository or the
newest a branch or tag can pin from, if it says which repository the
kept snapshots are of, if a label points at a model version in it (in
that pipeline's chartbook.toml, or as its newest snapshot recorded), if
it is the newest holding a version of an asset marked `keep = true`
(every version of such an asset is kept, --aggressive or not, and the
command says what the marks keep), or
if it was made in the last 7 days (--keep-days). Otherwise it may be
removed, and what only it named goes with it. A store keeps what work
needs now, and lets old lineage go: with --keep-lineage, every snapshot
a kept snapshot was made from is kept too, as far up as it goes, for
`chartbook lineage` to check. With --aggressive, names keep nothing: a
snapshot a git tag or branch names, or one holding a labelled model
version that no lockfile pins, goes once nothing else keeps it.
A lockfile that cannot be found, because its project was deleted or its
drive is not plugged in, still keeps what it last pinned. Pass
--forget-missing to let those go. A note only ever adds to what is kept:
what a lockfile's note recorded at its last pin is kept even when the
lockfile here says otherwise now. A note of a lockfile on another
machine, which a store in a shared or synced folder has, is kept with
--forget-missing or without, as is a note from before notes named their
machine and a sync service's copy of a note; each is listed with its
file under roots/, which you remove once no machine needs it. In your
own store, under ~/.chartbook, a note from before notes named their
machine is this machine's: its lockfile is read, and the note is
removed once this machine has a note of that lockfile and the lockfile
is read here, or with --forget-missing once the lockfile is gone.
What would be removed is listed, and nothing is removed until you say
yes. An object stored in the last hour is never removed: a pin puts
files in the store before it writes the lockfile that names them. In a
store users share, what another user put there may be theirs alone to
remove: gc removes what it can, says what it removed, lists each thing
it could not remove with what to do, and exits 1.
\b
Examples:
chartbook store gc --dry-run
chartbook store gc
chartbook store gc --keep-days 7
chartbook store gc --yes --forget-missing
chartbook store gc --keep-lineage
chartbook store gc --aggressive --keep-days 0
"""
from chartbook import lock
from chartbook import snapshot as snapshots
from chartbook.store import StoreError, default_store
days = keep_days if keep_days is not None else snapshots.KEEP_DAYS
try:
the_store = default_store()
plan = lock.plan_gc(the_store, forget_missing=forget_missing, keep_days=days, keep_lineage=keep_lineage, aggressive=aggressive)
except (StoreError, lock.LockError, ValueError) as e:
click.echo(click.style("Error: ", fg="red", bold=True) + _said(e), err=True)
click.echo("Nothing was removed.", err=True)
raise SystemExit(1)
def carry_out() -> tuple[int, int]:
"""Remove what the plan says to, and say what could not be removed, if anything could not."""
try:
return lock.run_gc(plan, the_store)
except lock.GcError as e:
# Everything else went: say so, then each thing that stayed and what to do
if e.removed_snapshots or e.removed:
click.echo(f"Removed {e.removed_snapshots} snapshot(s) and {e.removed} object(s), {_format_bytes(e.size_bytes)}.")
click.echo(click.style("Error: ", fg="red", bold=True) + f"{len(e.failures)} thing(s) could not be removed:", err=True)
for _, why in e.failures:
click.echo(f" {_printable(_said(why))}", err=True) # the store's words name the thing and say what to do
if any(what.startswith("the snapshot") for what, _ in e.failures):
click.echo("What a record that stayed names stays with it.", err=True)
raise SystemExit(1)
except (StoreError, lock.LockError, ValueError) as e:
click.echo(click.style("Error: ", fg="red", bold=True) + _said(e), err=True)
click.echo("Nothing was removed.", err=True)
raise SystemExit(1)
# A kept snapshot keeps objects too, which no lockfile may name
named_by = f"{len(plan.lockfiles) + len(plan.elsewhere)} lockfile(s)" + (" or a kept snapshot" if plan.snapshots_kept else "")
click.echo(f"Kept: {plan.kept} object(s), named by {named_by}")
for lockfile, found in plan.lockfiles:
if found:
note = ""
elif lockfile in plan.not_read:
note = " (not read: not a chartbook.lock on this machine; its pins are kept)"
elif forget_missing:
note = " (not found; forgotten)"
else:
note = " (not found; its pins are kept)"
if lockfile in plan.forget_old:
# In this user's own store: a note from before notes named their machine, this machine's
from chartbook.store import root_name
note += f" (its note under no machine name goes: {the_store}/roots/{root_name(lockfile)})"
click.echo(f" {_printable(lockfile)}{note}")
from chartbook.store import this_host
here = this_host()
for root in plan.elsewhere:
# A note is written by whoever can write to the store: what it says is printed, never obeyed
host = _printable(root.host)
if root.copy:
whose = "a sync service's copy of a note"
elif not root.machine:
whose = "noted under no machine name (before notes named their machine, or by a CI runner with none), so perhaps another machine's"
elif root.host and root.host == here:
# Not this machine's name now: an earlier one of its own, or another machine of the same host name
whose = f"noted by {host} under another machine name than this one's now"
else:
whose = f"on another machine, {host}" if root.host else "on another machine"
read = "; the lockfile here at that path is read as well" if root.lockfile in plan.read_here else ""
click.echo(
f" {_printable(root.lockfile)} ({whose}: what it recorded at its last pin is kept{read}; "
f"note {the_store}/roots/{_printable(root.note)})"
)
if plan.snapshots_kept:
reasons = {}
for _, why in plan.snapshots_kept:
reasons[why] = reasons.get(why, 0) + 1
said = ", ".join(f"{count} because {why}" for why, count in reasons.items())
click.echo(
f"Kept: {len(plan.snapshots_kept)} snapshot(s), naming {plan.snapshots} "
f"object(s): {said}"
)
if plan.kept_versions:
# What the marks keep, asset by asset: the bill for `keep = true`
by_asset: dict = {}
for kept in plan.kept_versions:
entry = by_asset.setdefault((kept.pipeline, kept.name), {"versions": {}, "snapshots": set()})
entry["versions"].setdefault(kept.content_hash, kept.size_bytes)
entry["snapshots"].add(kept.snapshot.path)
for (pipeline, name), entry in sorted(by_asset.items()):
click.echo(
f"Kept for `keep = true`: {len(entry['versions'])} version(s) of "
f"{_printable(pipeline)}:{_printable(name)}, {_format_bytes(sum(entry['versions'].values()))}, "
f"in {len(entry['snapshots'])} snapshot(s)"
)
if plan.recent:
click.echo(
f"Kept: {len(plan.recent)} object(s) nothing names, stored in the last hour"
)
if any(not found for _, found in plan.lockfiles) and any(
"cannot be found" in why for _, why in plan.snapshots_kept
):
click.echo(
" A lockfile that cannot be found keeps the snapshots its note recorded "
"(for a note from before notes did, the newest holding each object it "
"named). --forget-missing lets them go."
)
if not plan.delete and not plan.snapshots_delete:
click.echo("Nothing to remove.")
if not dry_run:
carry_out() # forgets the missing, if asked to
return
if plan.snapshots_delete:
click.echo(f"Would remove {len(plan.snapshots_delete)} snapshot(s):")
for taken, why in plan.snapshots_delete:
click.echo(
f" {taken.pipeline} at {taken.commit[:7]}, made "
f"{_when(taken.made_at)}: {why}"
)
summary = (
f"{len(plan.delete)} object(s), {_format_bytes(plan.size_bytes)}, that "
f"nothing names"
)
if dry_run:
if plan.delete:
click.echo(f"Would remove {summary}:")
for content_hash, size in plan.delete:
click.echo(f" {content_hash} {_format_bytes(size)}")
return
if plan.delete:
click.echo(f"Would remove {summary}.")
if not yes and not click.confirm("Remove them?", default=False):
click.echo("Nothing was removed.")
return
removed_snapshots, removed = carry_out()
click.echo(
f"Removed {removed_snapshots} snapshot(s) and {removed} object(s), "
f"{_format_bytes(plan.size_bytes)}."
)
spared = len(plan.snapshots_delete) - removed_snapshots + len(plan.delete) - removed
if spared:
click.echo(f"{spared} of them were pinned while you were deciding, and kept.")
@main.command()
@click.option(
"--git",
"use_git",
is_flag=True,
default=False,
help="Run git init in the new project and make an initial commit",
)
@click.option(
"--github",
"use_github",
is_flag=True,
default=False,
help="Also create a GitHub repository for it with gh and push the commit "
"(implies --git)",
)
@click.option(
"--public/--private",
"public",
default=None,
help="Visibility of the repository --github creates (default: private)",
)
def init(use_git, use_github, public):
"""Initialize a new chartbook project from the cookiecutter template.
The template is the one that came with this ChartBook (the `template/`
folder of ChartBook's repository, which the package carries), so the
two always match, and rendering it needs no network. When the commit
the template is at is known, the new project's `.cruft.json` names the
template on GitHub and that commit, so `cruft update` there brings in
later changes to it; when it is not, there is no `.cruft.json`, and the
command prints the `cruft link` that makes one.
With --git, the new project is also made a git repository with one
commit. With --github, a GitHub repository is then created for it under
the account `gh` is logged in as, and the commit is pushed. Both are off
by default.
--github needs the GitHub CLI installed and logged in (`gh auth login`).
That is checked first, so if it is missing nothing is created.
Examples:
chartbook init
chartbook init --git
chartbook init --github
chartbook init --github --public
"""
try:
import cookiecutter # noqa: F401
except ImportError:
click.echo("Error: cookiecutter is not installed.", err=True)
click.echo("", err=True)
click.echo(
"cookiecutter 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)
from chartbook import scaffold
if public is not None and not use_github:
raise click.UsageError("--public/--private only applies with --github.")
use_git = use_git or use_github
cwd = Path.cwd()
def fail(error):
click.echo(click.style("Error: ", fg="red", bold=True) + _said(error), err=True)
raise SystemExit(1)
if use_git:
# Before the template is rendered, so a missing tool or login
# leaves nothing behind
try:
scaffold.preflight(cwd, use_github=use_github)
except scaffold.ScaffoldError as e:
fail(e)
outer_repo = scaffold.enclosing_repository(cwd)
if outer_repo:
click.echo(
f"Note: {cwd} is inside the git repository {outer_repo}; the "
f"new project will be its own repository nested in it.",
err=True,
)
# The template that came with this ChartBook, rendered here: no network
from cookiecutter.exceptions import CookiecutterException
try:
rendered = scaffold.render(cwd)
except KeyboardInterrupt:
raise SystemExit(130)
except scaffold.ScaffoldError as e:
# A source checkout git cannot read, or a project folder there already
fail(e)
except CookiecutterException as e:
click.echo(click.style("Error: ", fg="red", bold=True) + str(e), err=True)
# The template's own message is above; its advice is said again
# last, where it is seen. The name made from the project's name may
# be refused too ("2024 Rates")
click.echo(
"If the folder name was refused, run `chartbook init` again and, at the second "
"question, press Enter, or type a name of lowercase letters, digits and `_`.",
err=True,
)
raise SystemExit(1)
project_dir = rendered.project_dir
# You made it: a build of it runs as your own, as `chartbook trust` would say
if _trust_new_project(project_dir):
click.echo(" Trusted: this folder (chartbook trust --list)")
if rendered.commit is None:
# A .cruft.json naming no commit is one cruft takes for up to date, and never updates
click.echo(
f"Note: this ChartBook cannot name the commit of {scaffold.TEMPLATE_REPOSITORY} its "
f"template is at ({rendered.why}), so {project_dir.name} has no {scaffold.CRUFT_MARKER}, "
f"and cruft cannot update it yet. Once you know the commit, run this in "
f"{project_dir.name}, with the commit in place of <commit>; it gives cruft the answers "
f"you gave here:\n"
f" {rendered.link_command()}",
err=True,
)
elif rendered.pushed is False:
click.echo(
f"Note: the template's commit, {rendered.commit[:12]}, is on no branch of a remote that is "
f"{scaffold.TEMPLATE_REPOSITORY}, and in no tag, of the checkout this ChartBook runs from, "
f"so it may not be on GitHub, where `cruft update` looks for it. Push it before you run "
f"`cruft update`.",
err=True,
)
if not use_git:
return
try:
scaffold.git_init_and_commit(project_dir)
click.echo(f"Initialized a git repository in {project_dir} with one commit.")
if use_github:
created = scaffold.github_create_and_push(project_dir, public=bool(public))
visibility = "public" if public else "private"
click.echo(f"Created a {visibility} GitHub repository and pushed:")
click.echo(f" {created}")
except scaffold.ScaffoldError as e:
fail(e)
if use_github:
# With the remote in place, the pipeline's scope comes from it
from chartbook.identity import clear_git_remote_cache, derive_pipeline_id, get_git_remote_url, repository_key
clear_git_remote_cache()
click.echo(f"Pipeline ID: {derive_pipeline_id(_project_table(project_dir), project_dir)}")
# ...and so does the trust record's note of it, and of its repository
_trust_new_project(project_dir, repository=repository_key(get_git_remote_url(project_dir)) or None)
def _project_table(project_dir) -> dict:
"""The raw ``[project]`` table of a folder's chartbook.toml; ``{}`` with none."""
import tomli
manifest_path = project_dir / "chartbook.toml"
if not manifest_path.is_file():
return {}
with open(manifest_path, "rb") as f:
return tomli.load(f).get("project", {})
def _trust_new_project(project_dir, repository=None) -> bool:
"""Record a project `chartbook init` just made as trusted to run; say so if that cannot be done.
:returns: Whether the record was written.
"""
from chartbook.identity import derive_pipeline_id
return _record_trust_or_say(
project_dir, "run", id=derive_pipeline_id(_project_table(project_dir), project_dir), repository=repository
)
def _record_trust_or_say(folder, level, id=None, repository=None) -> bool:
"""Record a folder the user made or added as trusted, or say in one line why that could not be done and go on.
For `init`, `catalog init` and `catalog add`: the command's own work
is done, and a record that cannot be written (no home folder) is not a
reason to undo it.
:returns: Whether the record was written.
"""
from chartbook.config import record_trust
try:
record_trust(folder, level, id=id, repository=repository)
except (OSError, RuntimeError, ValueError) as error:
click.echo(
f"Note: {folder} could not be recorded as trusted ({_said(error)}), so a build of it runs "
f"as one from a store does until `chartbook trust` in that folder records it.",
err=True,
)
return False
return True
def _trust_to_render_if_untrusted(folder, pipeline_id) -> bool:
"""Record a folder `catalog add` is adding as trusted to render, unless it is trusted already.
A folder already trusted, to render or to run, keeps its level: a
record at `render` would take `run` away from a project the user made.
:returns: Whether a record was written.
"""
from chartbook.config import recorded_trust
from chartbook.identity import get_git_remote_url, repository_key
try:
# The record alone: CHARTBOOK_TRUST=all or CI would say "run" today
# and leave the folder untrusted when they are gone
if recorded_trust(folder) is not None:
return False
except ValueError as error:
click.echo(f"Note: {_said(error)} The folder's trust was left as it is.", err=True)
return False
return _record_trust_or_say(folder, "render", id=pipeline_id, repository=repository_key(get_git_remote_url(folder)) or None)
@main.command()
@click.argument("path", type=click.Path(), required=False)
@click.option(
"--render",
"render_only",
is_flag=True,
default=False,
help="Trust it to render only: queries run and the files its manifest names are read, and its build script is not run",
)
@click.option(
"--repository",
is_flag=True,
default=False,
help="Trust the folder's origin repository instead of the folder: every clone of it, wherever it is",
)
@click.option(
"--members",
is_flag=True,
default=False,
help="In a catalog: trust the catalog's folder and each member folder on this machine",
)
@click.option("--list", "list_", is_flag=True, default=False, help="Show what is trusted, and how")
@click.option(
"--forget",
is_flag=True,
default=False,
help="No longer trust the folder (or, with --repository, its repository)",
)
def trust(path, render_only, repository, members, list_, forget):
"""Trust a pipeline's folder, so that a build of it runs as your own.
A build of a folder you have not trusted runs as a build of files from
a store does: `chartbook docs generate` renders its site with no query
run and no template or file from outside the folder, and `chartbook build` and
`chartbook run` refuse to run its build script, which is code. A
project `chartbook init` made is trusted as it is made. One you cloned
is trusted when you say so, here.
With no PATH, this folder. A folder's trust covers the folders inside
it, so `chartbook trust ~/GitRepositories/ftsfr` trusts every pipeline
under it. --repository trusts the folder's origin repository instead:
every clone of it, wherever it is. --members, in a catalog, trusts the
catalog's folder and each of its member folders on this machine.
Two levels. `run`, the default, lets the build script run. --render
lets queries run and the files the manifest names be read, wherever
they are, and no more. The record is ~/.chartbook/trust.toml, yours;
--list shows it, and --forget takes a folder (or, with --repository,
a repository) out of it. Under CI (the environment variable) the
checkout is trusted to run without a record, and CHARTBOOK_TRUST=all
trusts every folder.
\b
Examples:
chartbook trust
chartbook trust --render
chartbook trust ../rates
chartbook trust --repository
chartbook trust --members
chartbook trust --list
chartbook trust --forget
"""
import os
from chartbook import config
from chartbook.identity import derive_pipeline_id, get_git_remote_url, repository_key
from chartbook.manifest import _read_manifest_file, catalog_member_dirs, resolve_project_type
if list_ and (path or render_only or repository or members or forget):
raise click.UsageError("--list shows what is trusted; it takes no PATH and no other option.")
if forget and (render_only or members):
raise click.UsageError("--forget takes a PATH, and --repository; a level or --members means nothing to it.")
if repository and members:
raise click.UsageError("--repository trusts one repository and --members a catalog's folders: one or the other.")
def fail(message):
click.echo(click.style("Error: ", fg="red", bold=True) + _said(message), err=True)
raise SystemExit(1)
level = "render" if render_only else "run"
verb = {"render": "Trusted to render", "run": "Trusted to run"}
if list_:
try:
entries = config.read_trust()
except ValueError as error:
fail(error)
mode = os.environ.get(config.TRUST_ENV_VAR, "").strip()
if mode == "all":
click.echo(f"{config.TRUST_ENV_VAR}=all: every folder is trusted to run, whatever the record says.")
elif os.environ.get("CI", "").strip():
click.echo(f"CI is set: {config._ci_checkout()} and the folders inside it are trusted to run.")
pipelines = [entry for entry in entries if entry["kind"] == "pipeline"]
repositories = [entry for entry in entries if entry["kind"] == "repository"]
if not pipelines and not repositories:
click.echo(
f"Nothing is recorded in {config.get_trust_path()}. `chartbook trust` in a pipeline's folder "
f"trusts it; a project `chartbook init` makes is recorded as it is made."
)
return
click.echo(f"{config.get_trust_path()}:")
if pipelines:
click.echo("Pipelines (each covers the folders inside it):")
for entry in pipelines:
notes = [note for note in (entry["id"], entry["repository"]) if note]
said = f" {entry['level']:<7} {entry['path']}"
if notes:
said += f" ({', '.join(notes)})"
if entry["since"]:
said += f" since {entry['since']}"
click.echo(said)
if repositories:
click.echo("Repositories (every clone whose origin remote is it):")
for entry in repositories:
said = f" {entry['level']:<7} {entry['url']}"
if entry["since"]:
said += f" since {entry['since']}"
click.echo(said)
return
folder = resolve_project_dir(path)
# A folder that is gone can still be forgotten; trusting one needs it to be there
if not folder.is_dir() and not (forget and not repository):
fail(f"{folder} is not a folder.")
manifest_path = folder / "chartbook.toml"
if forget:
try:
if repository:
remote = repository_key(get_git_remote_url(folder))
if not remote:
fail(f"{folder} has no origin remote, so there is no repository to forget.")
removed = config.forget_trust(url=remote)
named = remote
else:
removed = config.forget_trust(path=folder)
named = str(folder)
except (ValueError, OSError) as error:
fail(error)
if removed:
click.echo(f"No longer trusted: {named} ({removed} entr{'y' if removed == 1 else 'ies'} removed).")
else:
click.echo(f"Nothing was recorded for {named}; `chartbook trust --list` shows what is.")
return
if repository:
remote = repository_key(get_git_remote_url(folder))
if not remote:
fail(
f"{folder} has no origin remote, so there is no repository to trust. "
f"`chartbook trust` trusts the folder itself."
)
try:
config.record_repository_trust(remote, level)
except (ValueError, OSError) as error:
fail(error)
click.echo(f"{verb[level]}: {remote} (every clone whose origin remote is it; this one is {folder})")
return
if members:
try:
member_dirs = catalog_member_dirs(folder)
except FileNotFoundError:
fail(f"No chartbook.toml in {folder}. --members is for a catalog's folder.")
except ValueError as error:
fail(f"{error} --members is for a catalog's folder; `chartbook trust` trusts a pipeline's.")
# A folder with no pipeline in it is not recorded: whatever was put
# there later would be trusted. Nothing is recorded until every
# member can be, as `chartbook trust /nowhere` records nothing
missing = [(pipeline_id, d) for pipeline_id, d in member_dirs.items() if not (d / "chartbook.toml").is_file()]
if missing:
fail(
"these members of the catalog have no chartbook.toml where the catalog says they are, so nothing "
"was recorded (a folder trusted now would trust whatever is put there later):\n"
+ "\n".join(f" {d} ({pipeline_id})" for pipeline_id, d in missing)
+ "\nFix or remove their entries in the catalog's chartbook.toml, and run this again."
)
try:
config.record_trust(folder, level, id=None, repository=repository_key(get_git_remote_url(folder)) or None)
click.echo(f"{verb[level]}: {folder} (the catalog's folder)")
for pipeline_id, directory in member_dirs.items():
config.record_trust(
directory, level, id=pipeline_id, repository=repository_key(get_git_remote_url(directory)) or None
)
click.echo(f"{verb[level]}: {directory} ({pipeline_id})")
except (ValueError, OSError) as error:
fail(error)
if not member_dirs:
click.echo("The catalog has no member folder on this machine; what it shows from the store needs no trust.")
return
pipeline_id = None
try:
raw = _read_manifest_file(folder) if manifest_path.is_file() else None
if raw is not None and resolve_project_type(raw, source=str(manifest_path)) == "pipeline":
pipeline_id = derive_pipeline_id(raw.get("project") or {}, folder)
except ValueError:
raw = None # a manifest that cannot be read is the build's to say; the folder is still trusted
remote = repository_key(get_git_remote_url(folder)) or None
try:
config.record_trust(folder, level, id=pipeline_id, repository=remote)
except (ValueError, OSError) as error:
fail(error)
notes = ", ".join(note for note in (pipeline_id, remote) if note)
click.echo(f"{verb[level]}: {folder}" + (f" ({notes})" if notes else ""))
if not manifest_path.is_file():
click.echo(
f"There is no chartbook.toml in {folder}, so this trusts every pipeline in the folders inside it."
)
elif raw is not None and pipeline_id is None:
click.echo("That is a catalog's folder; `chartbook trust --members` trusts its member folders too.")
@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 (
set_default_catalog_path,
)
current = _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 = _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")')
[docs]
@main.group(cls=_Install)
def install():
"""Install bundled resources: user-level by default, or into the current directory with --project."""
@install.command("claude-skill")
@click.option(
"-f",
"--force",
is_flag=True,
default=False,
help="Overwrite skill files changed by hand 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_claude_skill(force, project):
"""Install the Claude Code skill that comes with this ChartBook.
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 skill's SKILL.md names the ChartBook version it was written for. A
copy for an older version is replaced, and the command says which
version it replaced. A copy for a newer version, or for this version
with files changed by hand, is replaced when you say so, or with
--force. When the files already match, nothing is copied. Files of
older layouts are removed, so the copy mirrors the bundled skill.
Any other command, run at a terminal, says in one line when your copy or
the project's is for an older ChartBook, and `chartbook check` warns.
\b
Examples:
chartbook install claude-skill
chartbook install claude-skill --project
"""
import shutil
from chartbook import claude_skill
skill_files = claude_skill.bundled_files()
if not skill_files:
click.echo("Error: No bundled skill files found in package.", err=True)
raise SystemExit(1)
target_dir = Path.cwd() / ".claude" / "skills" / claude_skill.SKILL_NAME if project else claude_skill.user_dir()
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(".")]
old_stamp = target_dir / claude_skill.OLD_STAMP
existed = target_dir.is_dir() and (any(_visible_files()) or old_stamp.is_file())
before = claude_skill._shown_version(target_dir) if existed else None
standing = claude_skill.relation(target_dir) if existed else None
shown = claude_skill.shown(target_dir)
# Nothing to do: the installed files are the bundled ones. Hidden files
# (.DS_Store, ...) do not count; an old stamp beside them goes
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
):
old_stamp.unlink(missing_ok=True)
click.echo(f"Skill already up to date in {shown} (ChartBook {__version__})")
return
# A copy for an older version is an upgrade, and replaced; one for a
# newer version, for this version but changed by hand, or naming none,
# is asked about
if existed and not force and (standing != "older" or before is None):
if standing == "newer":
click.echo(f"The skill in {shown} is for ChartBook {before}, newer than this one, {__version__}.")
elif standing == "current":
click.echo(f"The skill in {shown} is this version's, with files changed since it was installed.")
else:
click.echo(f"Skill files already exist in {shown}, naming no ChartBook version.")
if not click.confirm(f"Replace them with ChartBook {__version__}'s?"):
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}")
old_stamp.unlink(missing_ok=True)
for src_file in skill_files:
shutil.copyfile(src_file, target_dir / src_file.name)
click.echo(f" {src_file.name}")
if existed and before and standing != "current":
click.echo(f"\nUpdated the skill in {shown} from ChartBook {before} to {__version__}")
else:
click.echo(f"\nInstalled {len(skill_files)} skill file(s) to {shown} (ChartBook {__version__})")
if __name__ == "__main__":
main()