Files
palladium/core/bootstrap.py
Robert Helewka e88449d15a Migrate Genesys CX Cloud TEI study to the pattern; retire Streamlit app
studies/202512_GenesysCX -> studies/202512_TEI_Genesys_CX_Cloud,
rebuilt as pattern Variant 4 (TEI composite reproduction):

- teicalc/ self-contained engine: Forrester's tables as the never-edited
  verbatim anchor (incl. the p.14 typo note and the $0 AI-token line),
  generic model/scenarios/staging carried over from the Amazon Connect
  study, ClientDrivers overlay (agents / weekly interactions / revenue,
  flat composite so no growth re-base) with ai_tokens_annual as a direct
  input for the token line the published study models at $0
- one deliverable notebook (business_case.ipynb): widget-pair sidebar
  drivers incl. the AI-token price, published-vs-overlay KPI columns,
  cash-flow/waterfall/scenario charts, verification gate, backstage JSON
  data appendix
- gate + tests reproduce the published totals within $2: NPV $10.8M /
  ROI 266% (engine $10,783,466 / 265.79%; payback 3.3 months, not
  headlined in the PDF); 29 study tests, headless nbconvert green,
  stage simulation leak-free, exports carry the appendix
- old Athena workflow (00_provision..04_export, config.py, seed_data.py,
  PALLADIUM_GENESYSCX_* keys, ATHENA_EXPECTED reconciliation) deleted;
  git history preserves it

With the last legacy study migrated, the retirement lands too:
- app/ (Streamlit UI) and core/notebook_helpers deleted; nothing else
  imported them
- streamlit stripped from pyproject extras, requirements.txt, Makefile;
  .env.example reduced to the Athena keys; 00_setup.ipynb and
  core/bootstrap.py repointed at the pattern studies
- root README reworked: self-contained studies + slim core/ Athena
  toolkit (tei_client, calculations, export, cli)

All suites green: Genesys 29, Amazon Connect 27, CTM 55, template 7,
root 58.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 16:38:51 -04:00

186 lines
6.0 KiB
Python

"""
Palladium notebook bootstrap — one import to set everything up.
From *any* notebook in the repo (root, ``studies/<slug>/notebooks/``, …)::
from core.bootstrap import init
pal = init() # loads .env, builds client, tests it
pal.client.list_reports()
(Pattern studies under ``studies/`` are self-contained — they carry their
own engine and venv and never import ``core``. The legacy ``study=``
parameter loaded a study's ``config.py``/``seed_data.py``; those modules
were retired with the study migrations, so ``init()`` is now purely the
Athena connection bootstrap.)
If ``core`` itself can't be imported (fresh kernel, notebook cwd deep in the
tree), put this two-liner first — it is the only path juggling left anywhere::
import sys, pathlib
sys.path.insert(0, str(next(p for p in pathlib.Path.cwd().parents
if (p / "pyproject.toml").exists())))
Better: ``pip install -e .`` once (``make setup`` does this) and even that
two-liner is unnecessary.
Credentials live in ``<repo root>/.env`` (gitignored)::
ATHENA_BASE_URL=https://athena.ouranos.helu.ca
ATHENA_API_KEY=...
``save_credentials()`` writes that file for you — 00_setup.ipynb uses it with
``getpass`` so the key never lands in notebook output.
"""
from __future__ import annotations
import importlib
import os
import sys
from dataclasses import dataclass, field
from pathlib import Path
from types import ModuleType
from typing import Any
from dotenv import load_dotenv
__all__ = ["init", "find_root", "save_credentials", "update_env", "Palladium"]
_ROOT_MARKERS = ("pyproject.toml", ".git")
def find_root(start: Path | None = None) -> Path:
"""Locate the repo root by walking up until pyproject.toml/.git is found."""
candidates = [start] if start else [Path.cwd(), Path(__file__).resolve().parent]
for cand in candidates:
for p in [cand, *cand.parents]:
if any((p / m).exists() for m in _ROOT_MARKERS):
return p
return Path.cwd() # pragma: no cover — degenerate fallback
def _ensure_importable(root: Path) -> None:
if str(root) not in sys.path:
sys.path.insert(0, str(root))
def update_env(root: Path | None = None, **pairs: str) -> Path:
"""
Write (or update) keys in ``<root>/.env``, preserving all other lines.
Also updates ``os.environ`` so the values take effect in the running
kernel immediately. Returns the path to the .env file.
"""
root = root or find_root()
env_path = root / ".env"
updates = {k: str(v) for k, v in pairs.items()}
lines: list[str] = []
if env_path.exists():
lines = env_path.read_text().splitlines()
seen: set[str] = set()
for i, line in enumerate(lines):
key = line.split("=", 1)[0].strip().lstrip("# ").strip()
if key in updates:
lines[i] = f"{key}={updates[key]}"
seen.add(key)
for key, val in updates.items():
if key not in seen:
lines.append(f"{key}={val}")
env_path.write_text("\n".join(lines) + "\n")
os.environ.update(updates)
return env_path
def save_credentials(
api_key: str,
base_url: str = "https://athena.ouranos.helu.ca",
root: Path | None = None,
) -> Path:
"""Write (or update) ``<root>/.env`` with Athena credentials."""
return update_env(
root, ATHENA_BASE_URL=base_url.rstrip("/"), ATHENA_API_KEY=api_key
)
@dataclass
class Palladium:
"""Everything a notebook session needs, in one object."""
root: Path
client: Any = None
config: ModuleType | None = None
seed_data: ModuleType | None = None
connection: dict = field(default_factory=dict)
def __repr__(self) -> str:
status = self.connection.get("status", "not tested")
study = getattr(self.config, "STUDY_SLUG", None)
return (
f"Palladium(root={self.root.name!r}, athena={status!r}"
+ (f", study={study!r}" if study else "")
+ ")"
)
def init(
study: str | None = None,
connect: bool = True,
quiet: bool = False,
) -> Palladium:
"""
One-call notebook setup.
1. Finds the repo root and makes ``core``/``studies`` importable.
2. Loads ``<root>/.env``.
3. Builds a :class:`TEIClient` and tests the connection (``connect=True``).
4. Optionally imports a study's ``config`` and ``seed_data`` modules.
Returns a :class:`Palladium` namespace: ``pal.client``, ``pal.config``,
``pal.seed_data``, ``pal.root``, ``pal.connection``.
"""
root = find_root()
_ensure_importable(root)
load_dotenv(root / ".env")
pal = Palladium(root=root)
if study:
pal.config = importlib.import_module(f"studies.{study}.config")
try:
pal.seed_data = importlib.import_module(f"studies.{study}.seed_data")
except ModuleNotFoundError:
pal.seed_data = None
if connect:
from core.tei_client import TEIClient
try:
pal.client = TEIClient()
pal.connection = pal.client.test_connection()
except ValueError as e: # missing credentials
pal.connection = {"status": "unconfigured", "detail": str(e)}
if not quiet:
c = pal.connection
if c.get("status") == "ok":
print(
f"✅ Athena connected — {c['base_url']} "
f"({c.get('reports_found', '?')} report templates visible)"
)
elif c.get("status") == "unconfigured":
print(
"⚠️ No credentials. Run 00_setup.ipynb, or:\n"
" from core.bootstrap import save_credentials\n"
" save_credentials(api_key='')"
)
else:
print(
f"❌ Athena connection failed "
f"({c.get('error_code')}): {c.get('detail')}"
)
if not quiet and study and pal.config is not None:
print(f"📁 Study: {study}")
return pal