# Recipe — Jupyter / IPython notebooks

> **Audience:** data scientists and analysts using mgf-common
> from a Jupyter kernel (JupyterLab, classic Jupyter, VS Code
> notebooks, IPython REPL).
> **No optional extra** — the bare `pip install mgf-common`
> is enough.

## Why notebooks need their own shape

Notebooks are neither apps nor libraries:

- They have no `__main__` entry point, so the package-metadata
  identity auto-derivation that `bootstrap()` uses for plain
  scripts doesn't fire.
- They reload modules and re-run cells; idempotent setup matters.
- The kernel process outlives any single notebook session, and
  multiple notebooks may share a kernel — be careful with
  process-global state.
- You usually want pretty console output (text formatter), not
  JSON, while iterating. JSON-shape comes back when the notebook
  graduates into a scheduled job.

This recipe covers the canonical shape for these constraints.

## At a glance

```python
# Cell 1 — once at the top of the notebook
import mgf.common
from mgf.common.settings import MgfSettings

ctx = mgf.common.bootstrap(
    app_name="my-analysis",
    app_version="0.1.0",
    settings=MgfSettings.testing(),  # WARNING-level + JSON off + OTel off
).__enter__()
```

```python
# Subsequent cells
from mgf.common.observability import get_logger, operation_span

LOG = get_logger("notebook.section_2")

with operation_span("load-and-clean"):
    df = pd.read_parquet("data.parquet")
    LOG.info("loaded", extra={"rows": len(df)})
```

That's it. Identity is set, logging emits to stderr (visible
inline in Jupyter), and `operation_span` traces are no-ops since
OTel is off — no kernel slowdown.

## The four moves

### 1. Bootstrap explicitly — never auto-derive

In a script, `bootstrap()` with no args reads the launching
package's `__main__.__spec__` to derive identity. In a notebook,
that resolves to the kernel package (`ipykernel`), which is wrong.
Always pass `app_name` and `app_version` explicitly:

```python
ctx = mgf.common.bootstrap(
    app_name="customer-churn-analysis",
    app_version="2026-04-30",
).__enter__()
```

`app_version` can be anything stable — the date the notebook was
checked in is a fine choice for one-off analyses.

### 2. Use `MgfSettings.testing()` while iterating

The default settings preset wires a JSON formatter pointed at
stderr — readable in batch logs but a wall of unreadable text in
a Jupyter cell output. `MgfSettings.testing()` is the
notebook-friendly preset:

- WARNING-level (cuts noise from upstream libraries)
- OTel disabled (no spans, no exporter overhead)
- Crash sinks limited to local
- TEXT format (auto-detected from TTY; you can override)

Promote to the production preset when the notebook becomes a
scheduled job.

### 3. Manage the bootstrap context manually

`bootstrap()` is a context manager. Notebooks don't have a `with`
block that spans the whole session, so reach for `__enter__()` at
the top of cell 1 and `__exit__(None, None, None)` at the bottom
of the last cell (or via a kernel-shutdown handler — see below).

```python
# Cell 1
ctx = mgf.common.bootstrap(app_name="my-app", app_version="0.1").__enter__()
```

```python
# Last cell, or before kernel shutdown
ctx.__exit__(None, None, None)
```

Why `__enter__/__exit__`? Because the notebook isn't a `with`
block — the bootstrap state has to live across cells.

For long sessions, register the shutdown via `atexit` so
restarting the kernel cleans up:

```python
import atexit
atexit.register(lambda: ctx.__exit__(None, None, None))
```

### 4. Idempotent re-bootstrap on cell re-runs

If you re-run cell 1 (common during exploration), `bootstrap()`
detects an existing global context, audits the replacement, and
sets up a new one. Old log handlers are detached cleanly. No
manual cleanup needed.

The audit-warning is helpful — it tells you when state is being
replaced. To silence it during heavy iteration:

```python
import logging
logging.getLogger("mgf.common._lifecycle").setLevel(logging.ERROR)
```

(Restore to `WARNING` before the notebook ships.)

## Common patterns

### Capturing logs for assertion in tests-cells

```python
from mgf.common import bootstrap_for_test

with bootstrap_for_test(capture_logs=True, capture_spans=True) as ctx:
    do_some_work()
    # ctx.captured_logs is a list of LogRecord; ctx.captured_spans
    # is a list of OTel spans (when [observability] is installed).
    assert any("expected" in r.msg for r in ctx.captured_logs)
```

`bootstrap_for_test` is a separate helper from `bootstrap()` — it
sets up ephemeral state that tears down at exit, and is safe to
re-enter without warnings. Use it in cells that test logic
without touching the notebook-level bootstrap.

### Switching to JSON output for production runs

When the notebook becomes a scheduled job (papermill, Airflow,
Dagster), flip to JSON output. The simplest move: pass settings
via env vars at runtime; the notebook code stays unchanged:

```bash
$ MGF_LOGGING_FORMAT=json papermill my_notebook.ipynb out.ipynb
```

Or override `MgfSettings` directly when the env says it's prod:

```python
import os
from mgf.common.settings import LoggingSettings, MgfSettings

is_prod = os.getenv("ENV") == "prod"
settings = MgfSettings(
    logging=LoggingSettings(format="json" if is_prod else "text"),
)
ctx = mgf.common.bootstrap(
    app_name="my-app",
    app_version="0.1",
    settings=settings,
).__enter__()
```

### Running an HTTP call from a cell

The `[http]` extra works the same as in any program; the only
notebook-specific note is that `AsyncHttpClient` MUST be `aclose`-d
when you're done — Jupyter doesn't tear down event loops between
cells:

```python
from mgf.common.http import AsyncHttpClient

async def fetch():
    client = AsyncHttpClient(base_url="https://api.example.com")
    try:
        response = await client.get("/users")
        return response.json()
    finally:
        await client.aclose()
```

```python
import asyncio
data = asyncio.get_event_loop().run_until_complete(fetch())
```

For SQL work, pair with `mgf.common.db` ([db] extra) the same way.

### Pretty stack traces with crash reporter

The crash reporter's local sink writes to
`$XDG_STATE_HOME/<app_name>/crash/` — a path that's stable across
notebook sessions. After an exception, you can inspect the
written report file from a follow-up cell:

```python
from pathlib import Path
import os

state = Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local/state"))
crashes = sorted((state / "my-app" / "crash").glob("*.json"), reverse=True)
print(crashes[0].read_text()[:500])
```

## Anti-patterns

- **Don't call `bootstrap()` in every cell.** Once at the top is
  enough. Re-running cell 1 is fine (idempotent), but adding
  `bootstrap()` to a cell that runs every time you re-execute
  the notebook adds ~10ms of audit-log overhead per cell.
- **Don't use `bootstrap_for_test` for the notebook-level
  bootstrap.** Its settings teardown is aggressive — it
  un-configures logging on context exit, which surprises
  later cells. Use it only inside `with` blocks for
  cell-scoped tests.
- **Don't let `verify_tls=False` leak into committed cells.** The
  `AsyncHttpClient` warning fires every construction; leaving it
  in committed cells means the warning appears in every notebook
  rerun, which trains everyone to ignore it. Disable TLS verify
  only in throwaway cells; never commit.
- **Don't reuse a kernel across mismatched bootstraps.** If
  notebook A bootstraps as `app_name="a"` and notebook B uses
  the same kernel and bootstraps as `app_name="b"`, you get
  one audit-warning + a clean replace. Ergonomically fine, but
  log files end up split — restart the kernel between unrelated
  notebooks.

## See also

- `docs/public/recipes/script.md` — the closely-related "one-off
  script" shape; many of the same trade-offs.
- `docs/public/recipes/testing.md` — `bootstrap_for_test` deep
  dive; pytest fixtures.
- `docs/public/01_lifecycle.md` — `bootstrap()` semantics +
  `AppContext.reload()` + idempotency rules.
- `docs/public/04_settings.md` — preset matrix
  (`MgfSettings.production()` / `.testing()` / `.development()`).
