"""``recon-gen docs`` — mkdocs handbook site for an L2 instance.
Six operations:
apply — build the site to ``site/`` (wraps ``mkdocs build``).
serve — live-reload preview (wraps ``mkdocs serve``).
clean — ``rm -rf site/``.
test — pytest the docs gates (link sweep + persona neutrality).
export — extract mkdocs source for hand-build (legacy ``export docs``).
screenshot — capture deployed dashboards to PNG (legacy ``export screenshots``).
No ``--execute`` here — building a static site to a directory isn't
a destructive side effect. The "emit" and the "do it" are the same
operation.
The ``--l2`` flag is honored via the ``QS_DOCS_L2_INSTANCE`` env var
that ``main.py`` reads at mkdocs-macros define-env time.
"""
from __future__ import annotations
import os
import shutil
import subprocess
import sys
from pathlib import Path
import click
from recon_gen.cli._helpers import l2_instance_option, load_config
# ``_REPO_ROOT`` was the cwd for ``mkdocs build`` pre-restructure when
# ``mkdocs.yml`` lived at the repo root. Now the config ships inside
# the package and mkdocs is invoked with ``-f <bundled mkdocs.yml>``,
# which resolves all docs paths relative to the config file regardless
# of cwd. Kept around because ``docs test`` still cwds here to run
# pytest against ``tests/docs/`` — which only exists in dev mode.
_REPO_ROOT = Path(__file__).resolve().parents[3]
# Bundled mkdocs.yml — same package-relative path in dev
# (``<repo>/src/recon_gen/mkdocs.yml``) and installed mode
# (``<site-packages>/recon_gen/mkdocs.yml``). One level up from
# the actual ``docs/`` content so ``docs_dir: docs`` is a sibling
# (mkdocs rejects ``docs_dir: .``).
_BUNDLED_MKDOCS_YML = Path(__file__).resolve().parent.parent / "mkdocs.yml"
[docs]
def build_docs_site(
l2_instance_path: str | None,
output_dir: str | Path,
*,
strict: bool = True,
config: Path | None = None,
) -> int:
"""Run ``mkdocs build`` (the bundled config) into ``output_dir``.
Returns the mkdocs exit code (0 = success). Threads the active L2
instance via ``QS_DOCS_L2_INSTANCE`` and sets ``cwd`` to the bundled
config's directory so mkdocs-macros's ``include_dir: docs/_macros``
resolves (X.2.s.1 — mkdocs-macros joins ``include_dir`` against the
process cwd, not the config-file dir). ``config`` overrides the
config file (``docs apply --portable`` passes its synthesized
``mkdocs.portable.yml``); the default is the bundled ``mkdocs.yml``.
``output_dir`` is resolved to absolute so cwd doesn't affect where
the site lands.
Shared by ``docs apply`` and by ``dashboards`` / ``studio`` (which builds
the site into a tempdir and embeds it at ``/docs`` — X.2.i).
"""
env = os.environ.copy()
if l2_instance_path is not None:
env["QS_DOCS_L2_INSTANCE"] = str(Path(l2_instance_path).resolve())
cmd = [
sys.executable, "-m", "mkdocs", "build",
"-f", str(config or _BUNDLED_MKDOCS_YML),
"-d", str(Path(output_dir).resolve()),
]
if strict:
cmd.append("--strict")
return subprocess.call(cmd, env=env, cwd=_BUNDLED_MKDOCS_YML.parent)
def _bake_portable_wasm(output_dir: Path) -> None:
"""Inline the wasm-graphviz module so diagrams render via file://.
Modern browsers (Chrome / Firefox / Safari) refuse ES module
imports from ``file://`` URLs for security. The default
``qs-graphviz-wasm.js`` does ``await import("./wasm-graphviz/index.js")``
which works fine over HTTP but rejects with a CORS-shaped error
when the page is opened by double-click — diagrams stay as empty
``<template>`` placeholders.
For ``--portable`` builds we post-process the rendered ``site/``:
read the vendored wasm bundle, strip its ES ``export``, wrap the
bundle + renderer logic in one IIFE so ``Graphviz`` is available
in lexical scope (no module fetch), and overwrite
``stylesheets/qs-graphviz-wasm.js``. The ``wasm-graphviz/`` dir
is then unused and removed to keep the portable bundle lean.
Source tree stays untouched — only the rendered ``output_dir/``
gets the inline form. HTTP-served builds (default ``docs apply``,
Pages workflow) keep the dynamic-import path.
"""
import re
style_dir = output_dir / "stylesheets"
wasm_path = style_dir / "wasm-graphviz" / "index.js"
qs_path = style_dir / "qs-graphviz-wasm.js"
if not wasm_path.exists():
raise click.ClickException(
f"--portable post-process: expected {wasm_path} to exist after "
f"mkdocs build. Did the docs build skip the stylesheets/ tree?"
)
if not qs_path.exists():
raise click.ClickException(
f"--portable post-process: expected {qs_path} to exist after "
f"mkdocs build."
)
wasm_text = wasm_path.read_text(encoding="utf-8")
# The vendored bundle ends with `...;export{Ft as Graphviz};` and a
# sourceMappingURL pragma. Capture the local name (Ft today; the
# variable is renamed across upstream releases) so we can rebind it
# to ``Graphviz`` after the IIFE wrap.
export_re = re.compile(r"export\s*\{\s*(\w+)\s+as\s+Graphviz\s*\}\s*;?")
m = export_re.search(wasm_text)
if m is None:
raise click.ClickException(
f"--portable post-process: couldn't find "
f"`export {{<name> as Graphviz}}` at end of {wasm_path}. "
f"The vendored upstream may have changed shape — re-vendor "
f"or update the regex in cli/docs.py::_bake_portable_wasm."
)
local_name = m.group(1)
wasm_body = wasm_text[: m.start()].rstrip()
wasm_body = re.sub(
r"//#\s*sourceMappingURL=.*$", "", wasm_body, flags=re.MULTILINE,
).rstrip()
# The renderer's `getRenderer()` does
# const mod = await import("./wasm-graphviz/index.js");
# return await mod.Graphviz.load();
# Swap to a direct reference now that Graphviz is lexically in scope.
qs_text = qs_path.read_text(encoding="utf-8")
import_re = re.compile(
r'const\s+mod\s*=\s*await\s+import\s*\(\s*"\.\/wasm-graphviz\/index\.js"\s*\)\s*;\s*'
r'return\s+await\s+mod\.Graphviz\.load\s*\(\s*\)\s*;',
re.DOTALL,
)
if not import_re.search(qs_text):
raise click.ClickException(
f"--portable post-process: couldn't find the dynamic-import "
f"call in {qs_path}. Was qs-graphviz-wasm.js refactored?"
)
qs_text = import_re.sub("return await Graphviz.load();", qs_text)
portable_text = (
"/* docs apply --portable: wasm-graphviz inlined so diagrams\n"
" * render under file:// (modern browsers block ES module\n"
" * imports from file:// for security). HTTP-served builds keep\n"
" * the dynamic-import path; this transform only runs on the\n"
" * rendered site/. */\n"
"(() => {\n"
+ wasm_body
+ "\n"
+ f"const Graphviz = {local_name};\n"
+ qs_text
+ "\n})();\n"
)
qs_path.write_text(portable_text, encoding="utf-8")
shutil.rmtree(style_dir / "wasm-graphviz")
# Per CLI app slug: (module path, builder function, output-subdir slug).
# The output-subdir slug is the short form (l1, l2ft, inv, exec) that
# matches the existing `docs/walkthroughs/screenshots/<short>/` convention
# already wired into handbook + walkthrough markdown refs. Lazy-imported
# in _build_app_for_screenshots so the CLI loads quickly when this command
# isn't invoked.
SCREENSHOT_APPS: dict[str, tuple[str, str, str]] = {
"l1-dashboard": (
"recon_gen.apps.l1_dashboard.app", "build_l1_dashboard_app",
"l1",
),
"l2-flow-tracing": (
"recon_gen.apps.l2_flow_tracing.app", "build_l2_flow_tracing_app",
"l2ft",
),
"investigation": (
"recon_gen.apps.investigation.app", "build_investigation_app",
"inv",
),
"executives": (
"recon_gen.apps.executives.app", "build_executives_app",
"exec",
),
}
# Per-app DateTimeParam names that the screenshots CLI sets when
# --date-from / --date-to are passed. The seed anchors at date(2030,1,1)
# but the dashboards default to "rolling 7 days back from today" — so
# without these overrides the captured PNGs render "no data" on every
# date-filtered sheet.
_APP_DATE_PARAMS: dict[str, dict[str, list[str]]] = {
"l1-dashboard": {
"from": ["pL1DateStart"],
"to": ["pL1DateEnd", "pL1DsBalanceDate"], # DS picker = single day
},
"l2-flow-tracing": {
"from": ["pL2ftDateStart", "pL2ftChainsDateStart", "pL2ftTtDateStart"],
"to": ["pL2ftDateEnd", "pL2ftChainsDateEnd", "pL2ftTtDateEnd"],
},
"investigation": {
"from": [], "to": [], # No date params
},
"executives": {
"from": ["pExecDateStart"],
"to": ["pExecDateEnd"],
},
}
[docs]
def parse_viewport(text: str) -> tuple[int, int]:
"""Parse a ``WxH`` string into ``(width, height)`` integers."""
parts = text.lower().split("x")
if len(parts) != 2:
raise click.BadParameter(
f"viewport must be WxH (e.g. 1280x900); got {text!r}"
)
try:
width, height = int(parts[0]), int(parts[1])
except ValueError:
raise click.BadParameter(
f"viewport WxH must be integers; got {text!r}"
)
if width <= 0 or height <= 0:
raise click.BadParameter(
f"viewport dimensions must be positive; got {text!r}"
)
return width, height
def _bundled_dir(name: str) -> Path:
"""Return a real Path to a bundled package data directory."""
from importlib.resources import as_file, files
traversable = files("recon_gen") / name
# as_file() materializes to a real Path even when imported from a zip.
# Closing the context manager when the wheel is on-disk is a no-op.
with as_file(traversable) as path:
if not path.is_dir():
raise click.ClickException(
f"Bundled '{name}/' directory not found in installed package."
)
return path
def _copy_tree(src: Path, dst: Path) -> int:
shutil.copytree(src, dst, dirs_exist_ok=True)
return sum(1 for p in dst.rglob("*") if p.is_file())
def _build_app_for_screenshots(app_slug: str, cfg, l2_instance): # type: ignore[no-untyped-def]: cfg/l2_instance untyped pending CLI-wide sweep
"""Import + call the builder for ``app_slug``; resolve auto-IDs."""
import importlib
mod_path, fn_name, _subdir = SCREENSHOT_APPS[app_slug]
mod = importlib.import_module(mod_path)
builder = getattr(mod, fn_name)
app = builder(cfg, l2_instance=l2_instance)
# emit_analysis() resolves auto-IDs on the tree so sheet objects
# match what was deployed.
app.emit_analysis()
return app
def _warm_db_for_screenshots(database_url: str) -> None:
"""Per the F12 cold-start footgun: SELECT 1 to warm the cluster
before generating an embed URL. Without this, QuickSight shows
'We can't open that dashboard' on the first walk."""
scheme = (database_url.split("://", 1)[0] or "").lower()
if scheme.startswith("oracle"):
import oracledb # type: ignore[import-untyped]: third-party library lacks PEP 561 stubs
try:
conn = oracledb.connect(database_url.split("://", 1)[1])
except Exception as exc: # pragma: no cover — env-specific
raise click.ClickException(
f"Oracle warmup failed ({exc}); pass --skip-warmup to bypass."
)
try:
with conn.cursor() as cur:
# oracledb lacks PEP 561 stubs — cursor methods come back as Unknown.
cur.execute("SELECT 1 FROM dual") # type: ignore[unknown-member]: oracledb cursor is Unknown without stubs
cur.fetchall() # type: ignore[unknown-member]: oracledb cursor is Unknown without stubs
finally:
conn.close()
return
import psycopg
conn = psycopg.connect(database_url)
try:
with conn.cursor() as cur:
cur.execute("SELECT 1")
cur.fetchall()
finally:
conn.close()
@click.group()
def docs() -> None:
"""mkdocs handbook site (build, serve, test, export, screenshot)."""
@docs.command("apply")
@l2_instance_option()
@click.option(
"-o", "--output", "output",
type=click.Path(), default="site",
help="Output directory for the built site (default: site/).",
)
@click.option(
"--strict/--no-strict", default=True,
help="Pass --strict to mkdocs (default on; treats warnings as errors).",
)
@click.option(
"--portable", is_flag=True,
help=(
"Build with use_directory_urls: false so the rendered site "
"opens via file:// without a web server (every page emits "
"as <slug>/index.html with relative links). The default "
"build uses pretty URLs (/scenario/) which require an HTTP "
"server to map the slug to its index.html."
),
)
def docs_apply(
l2_instance_path: str | None,
output: str,
strict: bool,
portable: bool,
) -> None:
"""Build the docs site to ``site/`` (or ``-o DIR``).
Wraps ``mkdocs build``. The L2 instance bound via ``--l2`` (or
falling back to the bundled spec_example) drives every
``{{ vocab.* }}`` substitution in the rendered prose.
With ``--portable``, emits a static-site that opens via ``file://``
— handy for shipping the rendered handbook on a USB stick, in a
zip attachment, or to a wiki that doesn't run a web server.
No ``--execute``: building a static site IS the operation.
"""
config_to_use = _BUNDLED_MKDOCS_YML
portable_yml: Path | None = None
if portable:
# Synthesize a tiny INHERIT-based config that overrides keys
# that don't make sense for an offline / file:// build.
# Written next to the bundled mkdocs.yml so the relative
# ``INHERIT: mkdocs.yml`` resolves correctly in both dev and
# installed mode (was broken pre-restructure when this lived
# at ``_REPO_ROOT`` — installed wheel doesn't have a writable
# repo root, just ``<site-packages>/lib/pythonX/``).
#
# `use_directory_urls: false` — emit `<slug>/index.html`
# so links resolve via file:// without a web server.
# `theme.font: false` — Material defaults to fetching Roboto
# from fonts.googleapis.com on every page load. Offline
# readers get an ugly Times New Roman fallback while the
# fetch hangs; disabling drops the link tag entirely so
# Material renders in the OS system font (San Francisco
# / Segoe UI / Cantarell).
portable_yml = _BUNDLED_MKDOCS_YML.parent / "mkdocs.portable.yml"
portable_yml.write_text(
"INHERIT: mkdocs.yml\n"
"use_directory_urls: false\n"
"theme:\n"
" font: false\n",
encoding="utf-8",
)
config_to_use = portable_yml
click.echo(f"$ mkdocs build -f {config_to_use} -d {output}")
try:
exit_code = build_docs_site(
l2_instance_path, output, strict=strict, config=config_to_use,
)
finally:
if portable_yml is not None and portable_yml.exists():
portable_yml.unlink()
if exit_code != 0:
raise click.ClickException(f"mkdocs build failed (exit {exit_code}).")
if portable:
out_path = Path(output).resolve()
_bake_portable_wasm(out_path)
index = out_path / "index.html"
click.echo(
f"Portable site at {out_path}/. "
f"Open {index} directly in a browser (no web server needed)."
)
@docs.command("serve")
@l2_instance_option()
@click.option(
"--port", "-p", default=8000, show_default=True, type=int,
help="Port to bind for live-reload preview.",
)
def docs_serve(l2_instance_path: str | None, port: int) -> None:
"""Live-reload preview at http://localhost:PORT (default 8000).
Wraps ``mkdocs serve``. Edit any docs source file and the browser
refreshes automatically. Useful for iterating on persona-block
YAML edits or new walkthrough drafts.
"""
env = os.environ.copy()
if l2_instance_path is not None:
env["QS_DOCS_L2_INSTANCE"] = str(Path(l2_instance_path).resolve())
cmd = [
sys.executable, "-m", "mkdocs", "serve",
"-f", str(_BUNDLED_MKDOCS_YML),
"-a", f"127.0.0.1:{port}",
]
# cwd MUST be the bundled mkdocs.yml's directory — mkdocs-macros
# resolves its ``include_dir: docs/_macros`` against cwd, not
# against the config file's directory (X.2.s.1: from a non-editable
# `pip install`, the default cwd has no ``docs/_macros`` → mkdocs
# serve fails with "docs/_macros does not exist" before it can
# serve a single page). Mirrors ``docs_apply``.
click.echo(f"$ {' '.join(cmd)}")
subprocess.call(cmd, env=env, cwd=_BUNDLED_MKDOCS_YML.parent)
@docs.command("clean")
@click.option(
"-o", "--output", "output",
type=click.Path(), default="site",
help="Directory to remove (default: site/).",
)
def docs_clean(output: str) -> None:
"""Remove the built site directory."""
target = Path(output)
if not target.exists():
click.echo(f"{target}/ doesn't exist; nothing to clean.")
return
shutil.rmtree(target)
click.echo(f"Removed {target}/")
@docs.command("test")
@click.option(
"--pytest-args", default="",
help="Extra args passed verbatim to pytest (e.g. '-k links -v').",
)
def docs_test(pytest_args: str) -> None:
"""Run the docs gates (link sweep + persona-neutral check)."""
pytest_argv = (
[sys.executable, "-m", "pytest", "tests/docs/", "-q"]
+ (pytest_args.split() if pytest_args else [])
)
pyright_argv = [
sys.executable, "-m", "pyright",
"src/recon_gen/common/handbook/",
"main.py",
]
failed: list[str] = []
click.echo(f"$ {' '.join(pytest_argv)}")
if subprocess.call(pytest_argv, cwd=_REPO_ROOT) != 0:
failed.append("pytest")
click.echo(f"$ {' '.join(pyright_argv)}")
if subprocess.call(pyright_argv, cwd=_REPO_ROOT) != 0:
failed.append("pyright")
if failed:
raise click.ClickException(f"docs test failed: {', '.join(failed)}")
click.echo("docs test: OK")
@docs.command("export")
@click.option(
"-o", "--output", "output",
type=click.Path(), required=True,
help="Target directory; created if missing, merged into if existing.",
)
@click.option(
"--l2", "l2_instance_path",
type=click.Path(exists=True),
help=(
"Optional path to an L2 institution YAML to bind the rendered "
"docs against. Validated here; binding happens at mkdocs build "
"time via QS_DOCS_L2_INSTANCE env var."
),
)
def docs_export(output: str, l2_instance_path: str | None) -> None:
"""Copy the bundled mkdocs source to a folder for hand-build.
Different from ``docs apply``: ``apply`` builds the site INTO a
directory; ``export`` copies the SOURCE FILES so an integrator
can use their own mkdocs config / theme / build pipeline.
"""
src = _bundled_dir("docs")
dst = Path(output)
count = _copy_tree(src, dst)
click.echo(f"Wrote {count} documentation files to {dst}")
if l2_instance_path is not None:
l2_path = Path(l2_instance_path).resolve()
click.echo("")
click.echo(
"L2 instance bound: " + str(l2_path) + "\n"
"To render against this instance, run:\n"
f" QS_DOCS_L2_INSTANCE={l2_path} mkdocs build -f {dst}/mkdocs.yml"
)
@docs.command("screenshot")
@click.option(
"--app",
type=click.Choice(sorted(SCREENSHOT_APPS.keys())),
default=None,
help="Single app to capture. Mutually exclusive with --all.",
)
@click.option(
"--all", "all_apps", is_flag=True,
help="Capture all 4 apps. Output goes to <DIR>/<app-slug>/.",
)
@click.option(
"-o", "--output", type=click.Path(), required=True,
help="Target directory; per-app subdirs created under it.",
)
@click.option(
"-c", "--config", "config_path",
type=click.Path(exists=True), default=None,
help="Config YAML (default: env vars).",
)
@click.option(
"--l2", "l2_instance_path",
type=click.Path(exists=True), default=None,
help=(
"L2 institution YAML override. Defaults to each app's built-in "
"default (spec_example for most). Pass when capturing a deploy "
"against a non-default L2 (e.g. your own institution's YAML)."
),
)
@click.option(
"--viewport", "viewport_text", default="1280x900",
show_default=True,
help="Browser viewport WxH (default 1280x900).",
)
@click.option(
"--skip-warmup", is_flag=True,
help="Skip the F12 SELECT 1 cluster warmup (use when DB is hot).",
)
@click.option(
"--headless/--no-headless", default=True, show_default=True,
help="Run browser headless (default) or visible (debug).",
)
@click.option(
"--initial-settle-ms", type=int, default=10_000, show_default=True,
help="Settle delay after dashboard chrome appears, before first capture.",
)
@click.option(
"--per-sheet-settle-ms", type=int, default=8_000, show_default=True,
help="Settle delay after each sheet-tab click, before capture.",
)
@click.option(
"--date-from", "date_from", default=None,
help=(
"YYYY-MM-DD override for each app's `*DateStart` parameter(s). "
"Use to span the seed's anchor date when the dashboard's default "
"rolling-window control doesn't reach it."
),
)
@click.option(
"--date-to", "date_to", default=None,
help=(
"YYYY-MM-DD override for each app's `*DateEnd` parameter(s) "
"(L1 also applies it to the Daily Statement single-day picker)."
),
)
def docs_screenshot(
app: str | None,
all_apps: bool,
output: str,
config_path: str | None,
l2_instance_path: str | None,
viewport_text: str,
skip_warmup: bool,
headless: bool,
initial_settle_ms: int,
per_sheet_settle_ms: int,
date_from: str | None,
date_to: str | None,
) -> None:
"""Capture per-sheet PNG screenshots from deployed dashboards.
Walks the requested app's tree via WebKit and writes one full-page
PNG per sheet to ``<output>/<app-slug>/<sheet_id>.png``. Single CLI
surface for every app (replaces the per-app capture scripts that
used to live under ``scripts/``).
Requires the dashboards already deployed (``json apply --execute``).
The handbook + walkthrough pages embed these screenshots by
relative path under ``docs/walkthroughs/screenshots/<app>/``.
"""
if app is None and not all_apps:
raise click.UsageError("Specify --app <name> or --all.")
if app is not None and all_apps:
raise click.UsageError("Pass either --app or --all, not both.")
# After the guard above, exactly one of `app` / `all_apps` is set.
apps_to_capture: list[str] = (
sorted(SCREENSHOT_APPS.keys()) if all_apps else [app] # type: ignore[list-item]: app is not None per the guard
)
cfg = load_config(config_path)
if not cfg.aws_account_id or not cfg.aws_region:
raise click.ClickException(
"Config missing aws_account_id or aws_region — "
"screenshots need them to generate an embed URL."
)
if not skip_warmup and not cfg.demo_database_url:
raise click.ClickException(
"demo_database_url not set; pass --skip-warmup to bypass "
"the cluster warmup step."
)
viewport = parse_viewport(viewport_text)
# Validate date overrides up-front so a typo doesn't surface mid-walk.
from datetime import date as _date
if date_from is not None:
try:
_date.fromisoformat(date_from)
except ValueError as exc:
raise click.BadParameter(
f"--date-from must be YYYY-MM-DD; got {date_from!r} ({exc})"
)
if date_to is not None:
try:
_date.fromisoformat(date_to)
except ValueError as exc:
raise click.BadParameter(
f"--date-to must be YYYY-MM-DD; got {date_to!r} ({exc})"
)
l2_instance = None
if l2_instance_path is not None:
from recon_gen.common.l2 import load_instance
l2_instance = load_instance(Path(l2_instance_path))
if not skip_warmup:
# Demo URL is guaranteed non-None here — the guard above raises
# ClickException when skip_warmup is False AND demo_database_url is unset.
assert cfg.demo_database_url is not None
click.echo(
f"-> Warming DB ({cfg.demo_database_url.split('@')[-1]}, "
f"SELECT 1)...", nl=False,
)
_warm_db_for_screenshots(cfg.demo_database_url)
click.echo(" OK")
from recon_gen.common.browser.helpers import (
generate_dashboard_embed_url,
)
from recon_gen.common.browser.screenshot import capture_deployed_app
output_root = Path(output)
output_root.mkdir(parents=True, exist_ok=True)
grand_total = 0
for slug in apps_to_capture:
click.echo(f"== {slug} ==")
app_obj = _build_app_for_screenshots(slug, cfg, l2_instance)
# Dashboard ID convention: cfg.prefixed(<dashboard_id_suffix>) —
# Z.C: the cfg arrives fully-populated with cfg.deployment_name,
# so app_obj.cfg and the outer cfg agree on the namespace.
dashboard_suffix = app_obj.dashboard.dashboard_id_suffix
dashboard_id = app_obj.cfg.prefixed(dashboard_suffix)
click.echo(
f"-> embed URL for {dashboard_id}...", nl=False,
)
url = generate_dashboard_embed_url(
aws_account_id=cfg.aws_account_id,
aws_region=cfg.aws_region,
dashboard_id=dashboard_id,
)
click.echo(" OK")
# Write to the short-slug subdir (l1/, l2ft/, inv/, exec/).
_, _, output_subdir = SCREENSHOT_APPS[slug]
out_dir = output_root / output_subdir
url_params: dict[str, str] = {}
if date_from is not None:
for pname in _APP_DATE_PARAMS[slug]["from"]:
url_params[pname] = date_from
if date_to is not None:
for pname in _APP_DATE_PARAMS[slug]["to"]:
url_params[pname] = date_to
if url_params:
click.echo(
f"-> URL date params: "
+ ", ".join(f"{k}={v}" for k, v in url_params.items())
)
click.echo(f"-> capturing {len(app_obj.analysis.sheets)} sheets at "
f"{viewport[0]}x{viewport[1]} into {out_dir}/")
results = capture_deployed_app(
app_obj,
embed_url=url,
output_dir=out_dir,
viewport=viewport,
initial_settle_ms=initial_settle_ms,
per_sheet_settle_ms=per_sheet_settle_ms,
headless=headless,
url_params=url_params or None,
)
for sheet, path in results.items():
click.echo(f" {sheet.name:30s} -> {path.name}")
grand_total += len(results)
click.echo("")
click.echo(
f"Captured {grand_total} screenshots across "
f"{len(apps_to_capture)} app(s) at {output_root}/"
)