feat(notebooks): quality gates — headless integration test, FE smoke gate, dedup issue auto-filer
Some checks failed
CI / lint (push) Successful in 32s
CI / notebooks-smoke (push) Failing after 1m39s
Deploy / notebooks (push) Successful in 6m26s
Deploy / zotero (push) Has been skipped
Deploy / docs (push) Has been skipped
Deploy / api (push) Has been skipped
Deploy / mc (push) Has been skipped
Infra CI / notebooks (push) Successful in 48s
Infra CI / zotero (push) Successful in 27s
Infra CI / docs (push) Successful in 14s
Infra CI / api (push) Successful in 24s
Infra CI / mc (push) Successful in 13s
CI / test (push) Failing after 16m28s
Deploy / report (push) Successful in 16s
Some checks failed
CI / lint (push) Successful in 32s
CI / notebooks-smoke (push) Failing after 1m39s
Deploy / notebooks (push) Successful in 6m26s
Deploy / zotero (push) Has been skipped
Deploy / docs (push) Has been skipped
Deploy / api (push) Has been skipped
Deploy / mc (push) Has been skipped
Infra CI / notebooks (push) Successful in 48s
Infra CI / zotero (push) Successful in 27s
Infra CI / docs (push) Successful in 14s
Infra CI / api (push) Successful in 24s
Infra CI / mc (push) Successful in 13s
CI / test (push) Failing after 16m28s
Deploy / report (push) Successful in 16s
Three gates so notebook breakage can't ship or linger silently again (spec: docs/superpowers/specs/2026-07-09-notebook-quality-gates-design.md): 1. nb_integration.py: runs notebooks headless via 'marimo export session', parses the JSON snapshots for cell errors, emits a report, exits 1 on unexpected failures. New ci.yml notebooks-smoke job runs the data-independent [ci_smoke] set (infra/marimo/nb-tests.toml) on every push; new nightly notebooks-integration.yml runs the full set inside the prod container against real data, filing failures as issues. 2. nb_fe_smoke.py: headless-browser gate that loads the editor and fails on any console/page error — the test that would have blocked the 'd is not a constructor' bundle. Wired into infra-ci.yml after the notebooks image build (all traffic over the docker socket; -v bind mounts silently arrive empty in CI). Verified: exit 0 on the fixed image and live prod, exit 1 on a synthetic crashing page. 3. nb_issue_filer.py + nb-watcher compose sidecar: one issue per error signature (notebook + ename + normalized message), rate-limited recurrence comments, auto-close after 24h quiet — the watcher is the single closing authority. Tails container logs via the docker socket (stdlib unix-socket HTTP, stream demux) and parses live session snapshots. First production tick filed 9 real deduplicated issues (#546-#554: a real skin_subs_explorer bug, missing pyzotero/trino, nessie/api connectivity) under the new 'notebooks' label. Also: notebooks.Dockerfile now lets corepack honor marimo's pinned packageManager instead of 'pnpm@latest' — the last floating input to the frontend build after the lockfile fix. Tests: 6 new unit-test groups (snapshot parsing, signature stability, dedup decisions); full suite green including notebook-layout policy (config placed in infra/marimo/, not notebooks/).
This commit is contained in:
@@ -64,3 +64,36 @@ jobs:
|
|||||||
--run "${{ github.run_number }}" \
|
--run "${{ github.run_number }}" \
|
||||||
--sha "${{ github.sha }}" \
|
--sha "${{ github.sha }}" \
|
||||||
--ref "${{ github.ref }}" || true
|
--ref "${{ github.ref }}" || true
|
||||||
|
|
||||||
|
notebooks-smoke:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: https://github.com/actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up uv
|
||||||
|
run: curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||||
|
env:
|
||||||
|
UV_INSTALL_DIR: /usr/local/bin
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: uv sync --dev
|
||||||
|
|
||||||
|
- name: Run data-independent notebooks headless
|
||||||
|
# Executes the [ci_smoke] set from notebooks/nb-tests.toml via
|
||||||
|
# `marimo export session` and fails on any cell error. Runs on
|
||||||
|
# every push (not path-gated): notebooks import src/ modules, so
|
||||||
|
# src changes can break them too.
|
||||||
|
run: uv run python dev/scripts/nb_integration.py --set ci-smoke
|
||||||
|
|
||||||
|
- name: File failure issue
|
||||||
|
if: failure()
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
|
||||||
|
run: |
|
||||||
|
uv sync --no-dev --quiet 2>/dev/null || true
|
||||||
|
uv run python -m api.diag.ci \
|
||||||
|
--workflow "CI" --job "notebooks-smoke" \
|
||||||
|
--run "${{ github.run_number }}" \
|
||||||
|
--sha "${{ github.sha }}" \
|
||||||
|
--ref "${{ github.ref }}" || true
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ jobs:
|
|||||||
- name: Build notebooks
|
- name: Build notebooks
|
||||||
run: docker build -f infra/images/notebooks.Dockerfile -t local/notebooks:build .
|
run: docker build -f infra/images/notebooks.Dockerfile -t local/notebooks:build .
|
||||||
|
|
||||||
|
- name: Frontend smoke gate
|
||||||
|
run: python3 dev/scripts/nb_fe_smoke.py --image local/notebooks:build --network ci
|
||||||
|
|
||||||
- name: File failure issue
|
- name: File failure issue
|
||||||
if: failure()
|
if: failure()
|
||||||
env:
|
env:
|
||||||
|
|||||||
52
.gitea/workflows/notebooks-integration.yml
Normal file
52
.gitea/workflows/notebooks-integration.yml
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# DO NOT EDIT — generated by gen_config.py from stack.toml
|
||||||
|
# Re-generate: uv run python dev/scripts/gen_config.py
|
||||||
|
|
||||||
|
name: Notebooks Integration
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
schedule:
|
||||||
|
- cron: "30 3 * * *"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
notebooks-integration:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: https://github.com/actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Run full notebook set in prod container
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
docker exec notebooks mkdir -p /tmp/nbtest
|
||||||
|
docker cp dev/scripts/nb_integration.py notebooks:/tmp/nbtest/
|
||||||
|
docker cp dev/scripts/nb_issue_filer.py notebooks:/tmp/nbtest/
|
||||||
|
docker cp infra/marimo/nb-tests.toml notebooks:/tmp/nbtest/
|
||||||
|
docker exec \
|
||||||
|
-e GITEA_TOKEN \
|
||||||
|
-e GITEA_API_BASE=http://git:3000/api/v1 \
|
||||||
|
notebooks \
|
||||||
|
uv run --project /home/kert/workspace python /tmp/nbtest/nb_integration.py \
|
||||||
|
--set all --file-issues \
|
||||||
|
--nb-dir /home/kert/notebooks \
|
||||||
|
--config /tmp/nbtest/nb-tests.toml \
|
||||||
|
--report /tmp/nbtest/report.json
|
||||||
|
docker cp notebooks:/tmp/nbtest/report.json nb-integration-report.json
|
||||||
|
cat nb-integration-report.json
|
||||||
|
|
||||||
|
- name: Frontend smoke against live service
|
||||||
|
run: python3 dev/scripts/nb_fe_smoke.py --url http://notebooks:2718 --network gateway
|
||||||
|
|
||||||
|
- name: File failure issue
|
||||||
|
if: failure()
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
|
||||||
|
run: |
|
||||||
|
uv sync --no-dev --quiet 2>/dev/null || true
|
||||||
|
uv run python -m api.diag.ci \
|
||||||
|
--workflow "Notebooks Integration" --job "notebooks-integration" \
|
||||||
|
--run "${{ github.run_number }}" \
|
||||||
|
--sha "${{ github.sha }}" \
|
||||||
|
--ref "${{ github.ref }}" || true
|
||||||
38
compose.yml
38
compose.yml
@@ -288,6 +288,44 @@ services:
|
|||||||
- "promtail=true"
|
- "promtail=true"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# Watches the notebooks service for errors (container logs via the
|
||||||
|
# docker socket + __marimo__/session snapshots) and files deduplicated,
|
||||||
|
# auto-closing Gitea issues via dev/scripts/nb_issue_filer.py. See
|
||||||
|
# docs/superpowers/specs/2026-07-09-notebook-quality-gates-design.md.
|
||||||
|
nb-watcher:
|
||||||
|
image: python:3.13-alpine
|
||||||
|
container_name: nb-watcher
|
||||||
|
# `gateway` to reach git:3000 for the issue API.
|
||||||
|
networks:
|
||||||
|
- gateway
|
||||||
|
environment:
|
||||||
|
- PYTHONUNBUFFERED=1
|
||||||
|
- GITEA_TOKEN=${GITEA_TOKEN}
|
||||||
|
- GITEA_API_BASE=http://git:3000/api/v1
|
||||||
|
- NB_POLL_S=${NB_POLL_S:-60}
|
||||||
|
volumes:
|
||||||
|
- ./dev/scripts:/scripts:ro
|
||||||
|
- ./notebooks:/notebooks:ro
|
||||||
|
- ./.state:/state
|
||||||
|
- ${DOCKER_SOCK:-/run/user/1000/docker.sock}:/var/run/docker.sock:ro
|
||||||
|
command: python /scripts/nb_watcher.py
|
||||||
|
depends_on:
|
||||||
|
notebooks:
|
||||||
|
condition: service_started
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
- "CMD-SHELL"
|
||||||
|
- "test -f /tmp/heartbeat && test $$(( $$(date +%s) - $$(stat -c %Y /tmp/heartbeat) )) -lt $$(( $${NB_POLL_S:-60} * 5 ))"
|
||||||
|
interval: 60s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 120s
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
labels:
|
||||||
|
- "promtail=true"
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
zotero:
|
zotero:
|
||||||
image: ${IMAGE_PREFIX:-fhirworx}/zotero:${HEAVY_TAG:-latest}
|
image: ${IMAGE_PREFIX:-fhirworx}/zotero:${HEAVY_TAG:-latest}
|
||||||
pull_policy: if_not_present
|
pull_policy: if_not_present
|
||||||
|
|||||||
@@ -185,6 +185,25 @@ jobs:
|
|||||||
run: uv run pytest tests/ -x --cov=src --cov-report=term-missing --cov-fail-under={coverage_threshold} -q -n auto
|
run: uv run pytest tests/ -x --cov=src --cov-report=term-missing --cov-fail-under={coverage_threshold} -q -n auto
|
||||||
|
|
||||||
{_failure_step("CI", "test")}
|
{_failure_step("CI", "test")}
|
||||||
|
|
||||||
|
notebooks-smoke:
|
||||||
|
runs-on: {runner}
|
||||||
|
steps:
|
||||||
|
{_checkout_step()}
|
||||||
|
|
||||||
|
{_setup_uv_step(uv_version)}
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: uv sync --dev
|
||||||
|
|
||||||
|
- name: Run data-independent notebooks headless
|
||||||
|
# Executes the [ci_smoke] set from notebooks/nb-tests.toml via
|
||||||
|
# `marimo export session` and fails on any cell error. Runs on
|
||||||
|
# every push (not path-gated): notebooks import src/ modules, so
|
||||||
|
# src changes can break them too.
|
||||||
|
run: uv run python dev/scripts/nb_integration.py --set ci-smoke
|
||||||
|
|
||||||
|
{_failure_step("CI", "notebooks-smoke")}
|
||||||
"""
|
"""
|
||||||
return (".gitea/workflows/ci.yml", content)
|
return (".gitea/workflows/ci.yml", content)
|
||||||
|
|
||||||
@@ -438,6 +457,17 @@ def _gen_infra_ci(
|
|||||||
if not img.get("hadolint", True):
|
if not img.get("hadolint", True):
|
||||||
continue
|
continue
|
||||||
name = img["name"]
|
name = img["name"]
|
||||||
|
# The notebooks image gets a frontend smoke gate: boot the freshly
|
||||||
|
# built image and load the editor in a headless browser, failing on
|
||||||
|
# any console/page error. Guards against runtime-broken frontend
|
||||||
|
# bundles that compile cleanly (the 2026-07-09 "d is not a
|
||||||
|
# constructor" incident shipped through a green build).
|
||||||
|
fe_smoke_step = ""
|
||||||
|
if name == "notebooks":
|
||||||
|
fe_smoke_step = f"""
|
||||||
|
|
||||||
|
- name: Frontend smoke gate
|
||||||
|
run: python3 dev/scripts/nb_fe_smoke.py --image local/{name}:build --network ci"""
|
||||||
jobs_parts.append(f"""\
|
jobs_parts.append(f"""\
|
||||||
{name}:
|
{name}:
|
||||||
runs-on: {runner}
|
runs-on: {runner}
|
||||||
@@ -451,7 +481,7 @@ def _gen_infra_ci(
|
|||||||
|
|
||||||
{_setup_buildx_step()}
|
{_setup_buildx_step()}
|
||||||
|
|
||||||
{_build_push_step(img, f"ci-test-{name}", "", "", load_only=True)}
|
{_build_push_step(img, f"ci-test-{name}", "", "", load_only=True)}{fe_smoke_step}
|
||||||
|
|
||||||
{_failure_step("Infra CI", name)}""")
|
{_failure_step("Infra CI", name)}""")
|
||||||
|
|
||||||
@@ -475,6 +505,60 @@ jobs:
|
|||||||
return (".gitea/workflows/infra-ci.yml", content)
|
return (".gitea/workflows/infra-ci.yml", content)
|
||||||
|
|
||||||
|
|
||||||
|
def _gen_notebooks_integration(runner: str, **_kw: object) -> tuple[str, str]:
|
||||||
|
"""Nightly full-set notebook run inside the production container.
|
||||||
|
|
||||||
|
The full notebook set needs real data (aco.duckdb, ./data mounts) that
|
||||||
|
only the prod container has, so the scripts are docker-cp'd in and run
|
||||||
|
there. Failures are routed through nb_issue_filer (dedup + auto-close),
|
||||||
|
not exit codes — a red nightly run should page via the issue tracker,
|
||||||
|
not accumulate api.diag.ci duplicates. 03:30 sits before the 06:00
|
||||||
|
pkg-supply-chain run and outside interactive hours (duckdb lock, #508).
|
||||||
|
"""
|
||||||
|
content = f"""\
|
||||||
|
{_HEADER}
|
||||||
|
name: Notebooks Integration
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
schedule:
|
||||||
|
- cron: "30 3 * * *"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
notebooks-integration:
|
||||||
|
runs-on: {runner}
|
||||||
|
steps:
|
||||||
|
{_checkout_step()}
|
||||||
|
|
||||||
|
- name: Run full notebook set in prod container
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{{{ secrets.DEPLOY_TOKEN }}}}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
docker exec notebooks mkdir -p /tmp/nbtest
|
||||||
|
docker cp dev/scripts/nb_integration.py notebooks:/tmp/nbtest/
|
||||||
|
docker cp dev/scripts/nb_issue_filer.py notebooks:/tmp/nbtest/
|
||||||
|
docker cp infra/marimo/nb-tests.toml notebooks:/tmp/nbtest/
|
||||||
|
docker exec \\
|
||||||
|
-e GITEA_TOKEN \\
|
||||||
|
-e GITEA_API_BASE=http://git:3000/api/v1 \\
|
||||||
|
notebooks \\
|
||||||
|
uv run --project /home/kert/workspace python /tmp/nbtest/nb_integration.py \\
|
||||||
|
--set all --file-issues \\
|
||||||
|
--nb-dir /home/kert/notebooks \\
|
||||||
|
--config /tmp/nbtest/nb-tests.toml \\
|
||||||
|
--report /tmp/nbtest/report.json
|
||||||
|
docker cp notebooks:/tmp/nbtest/report.json nb-integration-report.json
|
||||||
|
cat nb-integration-report.json
|
||||||
|
|
||||||
|
- name: Frontend smoke against live service
|
||||||
|
run: python3 dev/scripts/nb_fe_smoke.py --url http://notebooks:2718 --network gateway
|
||||||
|
|
||||||
|
{_failure_step("Notebooks Integration", "notebooks-integration")}
|
||||||
|
"""
|
||||||
|
return (".gitea/workflows/notebooks-integration.yml", content)
|
||||||
|
|
||||||
|
|
||||||
def _gen_release(runner: str, uv_version: str, **_kw: object) -> tuple[str, str]:
|
def _gen_release(runner: str, uv_version: str, **_kw: object) -> tuple[str, str]:
|
||||||
content = f"""\
|
content = f"""\
|
||||||
{_HEADER}
|
{_HEADER}
|
||||||
@@ -540,6 +624,7 @@ def emit(
|
|||||||
_gen_harden,
|
_gen_harden,
|
||||||
_gen_rebuild_all,
|
_gen_rebuild_all,
|
||||||
_gen_infra_ci,
|
_gen_infra_ci,
|
||||||
|
_gen_notebooks_integration,
|
||||||
_gen_release,
|
_gen_release,
|
||||||
):
|
):
|
||||||
path, content = gen_fn(**common) # type: ignore[arg-type]
|
path, content = gen_fn(**common) # type: ignore[arg-type]
|
||||||
|
|||||||
256
dev/scripts/nb_fe_smoke.py
Normal file
256
dev/scripts/nb_fe_smoke.py
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
"""Frontend smoke test for the notebooks (marimo) image/service.
|
||||||
|
|
||||||
|
Loads the editor in a headless browser and fails on any console error or
|
||||||
|
page error. This is the gate that would have caught the 2026-07-09 incident:
|
||||||
|
a frontend bundle that compiled cleanly but threw "d is not a constructor"
|
||||||
|
on every notebook open, while the container healthcheck and home page stayed
|
||||||
|
green — no Python-level test can see that class of breakage.
|
||||||
|
|
||||||
|
Two modes:
|
||||||
|
|
||||||
|
# Gate a freshly built image (CI, after `docker buildx --load`):
|
||||||
|
python dev/scripts/nb_fe_smoke.py --image ci-test-notebooks --network ci
|
||||||
|
|
||||||
|
# Probe a running service (post-deploy, nightly):
|
||||||
|
python dev/scripts/nb_fe_smoke.py --url http://notebooks:2718 --network gateway
|
||||||
|
|
||||||
|
Everything crosses the docker socket (docker cp / stdin), never bind mounts:
|
||||||
|
in CI the job's filesystem lives in a volume the host daemon can't -v mount,
|
||||||
|
so mounted paths silently arrive empty (the trap that broke the apt mirror).
|
||||||
|
|
||||||
|
Stdlib only.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PLAYWRIGHT_IMAGE = "mcr.microsoft.com/playwright/python:v1.61.0-noble"
|
||||||
|
PLAYWRIGHT_PKG = "playwright==1.61.0"
|
||||||
|
READY_TIMEOUT_S = 120
|
||||||
|
|
||||||
|
# Dependency-free notebook shipped into the throwaway container. Markdown
|
||||||
|
# only, so it renders identically under any runtime config (the bare image
|
||||||
|
# has no marimo.toml, and marimo's default auto_instantiate=true would run
|
||||||
|
# code cells).
|
||||||
|
PROBE_NB = """\
|
||||||
|
import marimo
|
||||||
|
|
||||||
|
app = marimo.App()
|
||||||
|
|
||||||
|
@app.cell
|
||||||
|
def _():
|
||||||
|
import marimo as mo
|
||||||
|
mo.md("fe-smoke-canary-cell")
|
||||||
|
return
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run()
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Runs inside the playwright container; URL substituted via argv.
|
||||||
|
PROBE_SCRIPT = """
|
||||||
|
import json, sys
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
|
||||||
|
url = sys.argv[1]
|
||||||
|
events = []
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.chromium.launch()
|
||||||
|
page = browser.new_page()
|
||||||
|
page.on("console", lambda m: events.append({"type": m.type, "text": m.text}))
|
||||||
|
page.on("pageerror", lambda e: events.append({"type": "pageerror", "text": str(e)}))
|
||||||
|
page.goto(url, wait_until="networkidle", timeout=60000)
|
||||||
|
page.wait_for_timeout(6000)
|
||||||
|
body = page.inner_text("body")
|
||||||
|
browser.close()
|
||||||
|
print("FE_SMOKE_RESULT " + json.dumps({"events": events, "body": body[:3000]}))
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _run(cmd: list[str], **kw) -> subprocess.CompletedProcess:
|
||||||
|
return subprocess.run(cmd, capture_output=True, text=True, timeout=300, **kw)
|
||||||
|
|
||||||
|
|
||||||
|
def wait_ready(url: str, network: str) -> bool:
|
||||||
|
"""Poll the target URL from a curl container on the same network."""
|
||||||
|
deadline = time.time() + READY_TIMEOUT_S
|
||||||
|
while time.time() < deadline:
|
||||||
|
r = _run(
|
||||||
|
[
|
||||||
|
"docker",
|
||||||
|
"run",
|
||||||
|
"--rm",
|
||||||
|
"--network",
|
||||||
|
network,
|
||||||
|
"curlimages/curl:8.11.1",
|
||||||
|
"-s",
|
||||||
|
"-o",
|
||||||
|
"/dev/null",
|
||||||
|
"-w",
|
||||||
|
"%{http_code}",
|
||||||
|
"--max-time",
|
||||||
|
"5",
|
||||||
|
url,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
if r.stdout.strip() == "200":
|
||||||
|
return True
|
||||||
|
time.sleep(3)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def probe(url: str, network: str) -> dict:
|
||||||
|
"""Load `url` headlessly; return {events, body}."""
|
||||||
|
shell = (
|
||||||
|
f"pip install -q {PLAYWRIGHT_PKG} 2>/dev/null; "
|
||||||
|
f"python3 - \"$0\" <<'PYEOF'\n{PROBE_SCRIPT}\nPYEOF"
|
||||||
|
)
|
||||||
|
r = subprocess.run(
|
||||||
|
[
|
||||||
|
"docker",
|
||||||
|
"run",
|
||||||
|
"--rm",
|
||||||
|
"-i",
|
||||||
|
"--network",
|
||||||
|
network,
|
||||||
|
"--entrypoint",
|
||||||
|
"bash",
|
||||||
|
PLAYWRIGHT_IMAGE,
|
||||||
|
"-c",
|
||||||
|
shell,
|
||||||
|
url,
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=600,
|
||||||
|
)
|
||||||
|
for line in r.stdout.splitlines():
|
||||||
|
if line.startswith("FE_SMOKE_RESULT "):
|
||||||
|
return json.loads(line[len("FE_SMOKE_RESULT ") :])
|
||||||
|
raise RuntimeError(
|
||||||
|
f"probe produced no result (rc={r.returncode}):\n"
|
||||||
|
f"{r.stdout[-1000:]}\n{r.stderr[-1000:]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def check(result: dict, *, expect_text: str | None) -> list[str]:
|
||||||
|
problems = [
|
||||||
|
f"[{e['type']}] {e['text'][:300]}"
|
||||||
|
for e in result["events"]
|
||||||
|
if e["type"] in ("error", "pageerror")
|
||||||
|
]
|
||||||
|
if expect_text and expect_text not in result["body"]:
|
||||||
|
problems.append(
|
||||||
|
f"expected editor content {expect_text!r} not found in page body "
|
||||||
|
f"(got: {result['body'][:200]!r})"
|
||||||
|
)
|
||||||
|
return problems
|
||||||
|
|
||||||
|
|
||||||
|
def smoke_image(image: str, network: str) -> int:
|
||||||
|
"""Boot a throwaway container from `image`, probe '/', probe the editor."""
|
||||||
|
name = f"nb-fe-smoke-{uuid.uuid4().hex[:8]}"
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
nb_dir = Path(td) / "notebooks"
|
||||||
|
nb_dir.mkdir()
|
||||||
|
(nb_dir / "fe_smoke_probe.py").write_text(PROBE_NB)
|
||||||
|
try:
|
||||||
|
r = _run(["docker", "create", "--name", name, "--network", network, image])
|
||||||
|
if r.returncode != 0:
|
||||||
|
print(f"docker create failed: {r.stderr[:500]}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
# docker cp streams the client's file over the socket — works
|
||||||
|
# from inside CI where -v bind mounts cannot.
|
||||||
|
_run(["docker", "cp", str(nb_dir), f"{name}:/home/kert/"])
|
||||||
|
_run(["docker", "start", name])
|
||||||
|
_run(
|
||||||
|
[
|
||||||
|
"docker",
|
||||||
|
"exec",
|
||||||
|
"-u",
|
||||||
|
"0",
|
||||||
|
name,
|
||||||
|
"chmod",
|
||||||
|
"-R",
|
||||||
|
"a+rwX",
|
||||||
|
"/home/kert/notebooks",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
base = f"http://{name}:2718"
|
||||||
|
if not wait_ready(f"{base}/", network):
|
||||||
|
logs = _run(["docker", "logs", "--tail", "30", name])
|
||||||
|
print(
|
||||||
|
f"service never became ready at {base}\n{logs.stdout}{logs.stderr}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 2
|
||||||
|
return _verdict(
|
||||||
|
base,
|
||||||
|
network,
|
||||||
|
"?file=fe_smoke_probe.py",
|
||||||
|
expect_text="fe-smoke-canary-cell",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
_run(["docker", "rm", "-f", name])
|
||||||
|
|
||||||
|
|
||||||
|
def smoke_url(url: str, network: str, notebook: str | None) -> int:
|
||||||
|
base = url.rstrip("/")
|
||||||
|
if not wait_ready(f"{base}/", network):
|
||||||
|
print(f"service not responding at {base}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
query = f"?file={notebook}" if notebook else ""
|
||||||
|
# Live service: don't assert specific content (notebook set varies),
|
||||||
|
# only require an error-free load.
|
||||||
|
return _verdict(base, network, query, expect_text=None)
|
||||||
|
|
||||||
|
|
||||||
|
def _verdict(
|
||||||
|
base: str, network: str, editor_query: str, expect_text: str | None
|
||||||
|
) -> int:
|
||||||
|
failed = False
|
||||||
|
for label, target, expect in (
|
||||||
|
("home", f"{base}/", None),
|
||||||
|
("editor", f"{base}/{editor_query}", expect_text),
|
||||||
|
):
|
||||||
|
result = probe(target, network)
|
||||||
|
problems = check(result, expect_text=expect)
|
||||||
|
if problems:
|
||||||
|
failed = True
|
||||||
|
print(f"✗ {label} {target}")
|
||||||
|
for p in problems:
|
||||||
|
print(f" {p}")
|
||||||
|
else:
|
||||||
|
print(f"✓ {label} {target} — no console/page errors")
|
||||||
|
return 1 if failed else 0
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
target = parser.add_mutually_exclusive_group(required=True)
|
||||||
|
target.add_argument("--image", help="image tag to boot and gate")
|
||||||
|
target.add_argument("--url", help="running service base URL to probe")
|
||||||
|
parser.add_argument(
|
||||||
|
"--network", required=True, help="docker network shared with the target"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--notebook", default="sample.py", help="notebook to open in --url mode"
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.image:
|
||||||
|
return smoke_image(args.image, args.network)
|
||||||
|
return smoke_url(args.url, args.network, args.notebook)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
246
dev/scripts/nb_integration.py
Normal file
246
dev/scripts/nb_integration.py
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
"""Headless notebook integration test.
|
||||||
|
|
||||||
|
Runs `marimo export session` over a set of notebooks (executing them and
|
||||||
|
writing JSON session snapshots), parses each snapshot for cell errors, and
|
||||||
|
emits a consolidated JSON report. Exits 1 if any notebook outside the
|
||||||
|
expected-failures allowlist errored.
|
||||||
|
|
||||||
|
Why: the pytest suite only exercises src/ — nothing ran the notebooks
|
||||||
|
themselves, so kernel-level breakage (dead imports, schema drift, service
|
||||||
|
API changes) only surfaced when a human opened the notebook.
|
||||||
|
|
||||||
|
Config: infra/marimo/nb-tests.toml
|
||||||
|
[ci_smoke]
|
||||||
|
notebooks = ["sample.py"] # data-independent, runs in CI
|
||||||
|
[expected_failures]
|
||||||
|
"acodb_explorer.py" = "reason" # reported but non-fatal
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
uv run python dev/scripts/nb_integration.py --set ci-smoke
|
||||||
|
uv run python dev/scripts/nb_integration.py --set all --file-issues
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import html
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import tomllib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# In the repo this file sits at dev/scripts/, two levels below the root.
|
||||||
|
# Deployed contexts (nb-watcher mounts it at /scripts, the nightly run
|
||||||
|
# docker-cps it to /tmp/nbtest) are shallower — fall back to "/" there;
|
||||||
|
# those contexts always override paths via --nb-dir / env anyway.
|
||||||
|
_parents = Path(__file__).resolve().parents
|
||||||
|
ROOT = _parents[2] if len(_parents) > 2 else Path("/")
|
||||||
|
NB_DIR = ROOT / "notebooks"
|
||||||
|
# Config lives in infra/marimo/ (notebooks/ holds only .py content — see
|
||||||
|
# tests/test_notebook_layout.py). Overridable via --config for contexts
|
||||||
|
# where the repo layout doesn't exist (nightly docker exec).
|
||||||
|
CONFIG_PATH = ROOT / "infra" / "marimo" / "nb-tests.toml"
|
||||||
|
REPORT_PATH = ROOT / "data" / "nb-integration-report.json"
|
||||||
|
TIMEOUT_S = 600
|
||||||
|
|
||||||
|
|
||||||
|
# ── snapshot parsing (pure) ──────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_html(text: str) -> str:
|
||||||
|
return html.unescape(re.sub(r"<[^>]+>", "", text))
|
||||||
|
|
||||||
|
|
||||||
|
def parse_snapshot(path: Path) -> list[dict]:
|
||||||
|
"""Extract cell errors from a __marimo__/session/<nb>.py.json snapshot."""
|
||||||
|
if not path.exists():
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"cell": "",
|
||||||
|
"ename": "snapshot-missing",
|
||||||
|
"evalue": f"no session snapshot at {path.name}",
|
||||||
|
"detail": "",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
snap = json.loads(path.read_text())
|
||||||
|
except (OSError, json.JSONDecodeError) as e:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"cell": "",
|
||||||
|
"ename": "snapshot-unreadable",
|
||||||
|
"evalue": str(e)[:200],
|
||||||
|
"detail": "",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
errors: list[dict] = []
|
||||||
|
for cell in snap.get("cells", []):
|
||||||
|
for out in cell.get("outputs", []):
|
||||||
|
if out.get("type") != "error":
|
||||||
|
continue
|
||||||
|
detail = ""
|
||||||
|
for con in cell.get("console", []):
|
||||||
|
if con.get("name") == "stderr":
|
||||||
|
detail += _strip_html(con.get("text", "")) + "\n"
|
||||||
|
errors.append(
|
||||||
|
{
|
||||||
|
"cell": cell.get("id", ""),
|
||||||
|
"ename": out.get("ename", "error"),
|
||||||
|
"evalue": out.get("evalue", ""),
|
||||||
|
"detail": detail.strip(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def classify(notebook: str, errors: list[dict], expected: dict[str, str]) -> str:
|
||||||
|
if not errors:
|
||||||
|
return "pass"
|
||||||
|
return "expected-fail" if notebook in expected else "fail"
|
||||||
|
|
||||||
|
|
||||||
|
def exit_code(results: dict[str, dict]) -> int:
|
||||||
|
return 1 if any(r["status"] == "fail" for r in results.values()) else 0
|
||||||
|
|
||||||
|
|
||||||
|
# ── execution ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(path: Path) -> dict:
|
||||||
|
if not path.exists():
|
||||||
|
return {"ci_smoke": {"notebooks": []}, "expected_failures": {}}
|
||||||
|
return tomllib.loads(path.read_text())
|
||||||
|
|
||||||
|
|
||||||
|
def select_notebooks(which: str, cfg: dict) -> list[str]:
|
||||||
|
if which == "ci-smoke":
|
||||||
|
return list(cfg.get("ci_smoke", {}).get("notebooks", []))
|
||||||
|
return sorted(
|
||||||
|
p.name for p in NB_DIR.glob("*.py") if not p.name.startswith((".", "__"))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run_notebook(name: str) -> tuple[int, str]:
|
||||||
|
"""Execute one notebook via marimo export session; snapshot lands in
|
||||||
|
notebooks/__marimo__/session/<name>.json regardless of exit code."""
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(
|
||||||
|
[
|
||||||
|
"marimo",
|
||||||
|
"export",
|
||||||
|
"session",
|
||||||
|
name,
|
||||||
|
"--no-sandbox",
|
||||||
|
"--force-overwrite",
|
||||||
|
"--continue-on-error",
|
||||||
|
],
|
||||||
|
cwd=NB_DIR,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=TIMEOUT_S,
|
||||||
|
)
|
||||||
|
return proc.returncode, (proc.stderr or "")[-1500:]
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return -1, f"timed out after {TIMEOUT_S}s"
|
||||||
|
except FileNotFoundError:
|
||||||
|
return -2, "marimo CLI not found on PATH"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument(
|
||||||
|
"--set", dest="which", choices=["ci-smoke", "all"], default="ci-smoke"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--file-issues",
|
||||||
|
action="store_true",
|
||||||
|
help="report failures via nb_issue_filer (dedup) "
|
||||||
|
"and sweep resolved ones, instead of only exiting 1",
|
||||||
|
)
|
||||||
|
parser.add_argument("--report", default=str(REPORT_PATH))
|
||||||
|
parser.add_argument("--nb-dir", help="notebooks directory (default: repo)")
|
||||||
|
parser.add_argument("--config", default=str(CONFIG_PATH), help="nb-tests.toml path")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.nb_dir:
|
||||||
|
global NB_DIR
|
||||||
|
NB_DIR = Path(args.nb_dir).resolve()
|
||||||
|
|
||||||
|
cfg = load_config(Path(args.config))
|
||||||
|
expected = {k: str(v) for k, v in cfg.get("expected_failures", {}).items()}
|
||||||
|
notebooks = select_notebooks(args.which, cfg)
|
||||||
|
if not notebooks:
|
||||||
|
print(f"no notebooks selected for --set {args.which}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
results: dict[str, dict] = {}
|
||||||
|
for name in notebooks:
|
||||||
|
print(f"→ {name}", flush=True)
|
||||||
|
rc, stderr_tail = run_notebook(name)
|
||||||
|
if rc == -1:
|
||||||
|
errors = [
|
||||||
|
{"cell": "", "ename": "timeout", "evalue": stderr_tail, "detail": ""}
|
||||||
|
]
|
||||||
|
elif rc == -2:
|
||||||
|
print(stderr_tail, file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
else:
|
||||||
|
errors = parse_snapshot(NB_DIR / "__marimo__" / "session" / f"{name}.json")
|
||||||
|
status = classify(name, errors, expected)
|
||||||
|
results[name] = {"status": status, "errors": errors}
|
||||||
|
tag = {"pass": "ok", "fail": "FAIL", "expected-fail": "expected-fail"}[status]
|
||||||
|
print(
|
||||||
|
f" {tag}"
|
||||||
|
+ (
|
||||||
|
f" — {errors[0]['ename']}: {errors[0]['evalue'][:120]}"
|
||||||
|
if errors
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
report = {
|
||||||
|
"generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||||
|
"set": args.which,
|
||||||
|
"marimo_set_size": len(notebooks),
|
||||||
|
"results": results,
|
||||||
|
}
|
||||||
|
report_path = Path(args.report)
|
||||||
|
report_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
report_path.write_text(json.dumps(report, indent=2) + "\n")
|
||||||
|
print(f"report → {report_path}")
|
||||||
|
|
||||||
|
counts: dict[str, int] = {}
|
||||||
|
for r in results.values():
|
||||||
|
counts[r["status"]] = counts.get(r["status"], 0) + 1
|
||||||
|
print("summary:", ", ".join(f"{k}={v}" for k, v in sorted(counts.items())))
|
||||||
|
|
||||||
|
if args.file_issues:
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
import nb_issue_filer # noqa: PLC0415 — sibling script, lazy by design
|
||||||
|
|
||||||
|
findings = [
|
||||||
|
{"notebook": nb, **e}
|
||||||
|
for nb, r in results.items()
|
||||||
|
if r["status"] in ("fail", "expected-fail")
|
||||||
|
for e in r["errors"]
|
||||||
|
]
|
||||||
|
source = f"nb-integration/{args.which}"
|
||||||
|
if findings:
|
||||||
|
nb_issue_filer.cmd_report(findings, source)
|
||||||
|
else:
|
||||||
|
print("no findings — nothing to file")
|
||||||
|
# No sweep here: closing is the nb-watcher's job (24h staleness).
|
||||||
|
# Sweeping with only this run's signatures would close issues for
|
||||||
|
# errors this run can't reproduce (e.g. server-log-only errors the
|
||||||
|
# watcher filed).
|
||||||
|
return 0 # issue filing is the failure channel for scheduled runs
|
||||||
|
|
||||||
|
return exit_code(results)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
294
dev/scripts/nb_issue_filer.py
Normal file
294
dev/scripts/nb_issue_filer.py
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
"""Dedup/auto-close Gitea issue filer for notebook errors.
|
||||||
|
|
||||||
|
One open issue per error signature (notebook + ename + normalized message).
|
||||||
|
Recurrences add a rate-limited comment instead of a new issue; signatures
|
||||||
|
that stop occurring get auto-closed. This replaces the api.diag.ci
|
||||||
|
one-issue-per-run pattern that accumulated 23 duplicates for a single
|
||||||
|
workflow.
|
||||||
|
|
||||||
|
Stdlib only — runs on a bare python image (nb-watcher sidecar), in CI, and
|
||||||
|
on the host.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
# file/refresh issues for findings on stdin (JSON list, see FINDING)
|
||||||
|
python nb_issue_filer.py report --source nightly-integration < findings.json
|
||||||
|
|
||||||
|
# close open nb issues whose signature is NOT in the active set
|
||||||
|
python nb_issue_filer.py sweep --active-sigs sig1,sig2 --source nightly-integration
|
||||||
|
# or close signatures unseen since a state file's cutoff
|
||||||
|
python nb_issue_filer.py sweep --state data/nb-watcher-state.json --max-age-h 24
|
||||||
|
|
||||||
|
FINDING = {"notebook": "x.py", "ename": "exception", "evalue": "...",
|
||||||
|
"detail": "traceback/console excerpt"}
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Repo root when run in-tree; "/" in shallow deployed contexts (sidecar
|
||||||
|
# mount, docker cp) where ROOT is only a .env-fallback location anyway.
|
||||||
|
_parents = Path(__file__).resolve().parents
|
||||||
|
ROOT = _parents[2] if len(_parents) > 2 else Path("/")
|
||||||
|
|
||||||
|
API_BASE = os.environ.get("GITEA_API_BASE", "https://git.fhirworx.io/api/v1")
|
||||||
|
OWNER_REPO = os.environ.get("GITEA_OWNER_REPO", "homelab/stack")
|
||||||
|
LABEL_NAME = os.environ.get("NB_ISSUE_LABEL", "notebooks")
|
||||||
|
MARKER = "nb-sig:"
|
||||||
|
COOLDOWN_S = int(os.environ.get("NB_ISSUE_COOLDOWN_S", str(6 * 3600)))
|
||||||
|
|
||||||
|
|
||||||
|
# ── pure logic ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_evalue(evalue: str) -> str:
|
||||||
|
"""Collapse volatile details so recurring errors hash identically."""
|
||||||
|
s = evalue.strip()
|
||||||
|
s = re.sub(r"0x[0-9a-fA-F]+", "0xN", s) # addresses
|
||||||
|
s = re.sub(r"(/[\w.\-]+)+", "/PATH", s) # absolute paths
|
||||||
|
s = re.sub(r"\d+(\.\d+)?", "N", s) # counts, line numbers, durations
|
||||||
|
s = re.sub(r"\s+", " ", s)
|
||||||
|
return s.lower()[:300]
|
||||||
|
|
||||||
|
|
||||||
|
def signature(notebook: str, ename: str, evalue: str) -> str:
|
||||||
|
key = f"{notebook}|{ename}|{normalize_evalue(evalue)}"
|
||||||
|
return hashlib.sha1(key.encode()).hexdigest()[:12]
|
||||||
|
|
||||||
|
|
||||||
|
def decide(
|
||||||
|
existing: dict | None, last_comment_age_s: float | None, cooldown_s: int
|
||||||
|
) -> str:
|
||||||
|
"""create | comment | skip for one observed signature."""
|
||||||
|
if existing is None:
|
||||||
|
return "create"
|
||||||
|
if last_comment_age_s is None or last_comment_age_s >= cooldown_s:
|
||||||
|
return "comment"
|
||||||
|
return "skip"
|
||||||
|
|
||||||
|
|
||||||
|
def build_body(
|
||||||
|
*, notebook: str, ename: str, evalue: str, source: str, detail: str, sig: str
|
||||||
|
) -> str:
|
||||||
|
detail = detail.strip()[:4000]
|
||||||
|
return (
|
||||||
|
f"Auto-filed by the notebook error filer (`{MARKER}{sig}` — do not edit "
|
||||||
|
f"this marker; dedup and auto-close key on it).\n\n"
|
||||||
|
f"- **Notebook:** `{notebook}`\n"
|
||||||
|
f"- **Error:** `{ename}`: {evalue[:500]}\n"
|
||||||
|
f"- **Source:** {source}\n\n"
|
||||||
|
f"```\n{detail or '(no traceback captured)'}\n```\n\n"
|
||||||
|
f"This issue is closed automatically when the error stops occurring."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_sig(body: str) -> str | None:
|
||||||
|
m = re.search(rf"{MARKER}([0-9a-f]{{12}})", body or "")
|
||||||
|
return m.group(1) if m else None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Gitea API (stdlib) ───────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _token() -> str:
|
||||||
|
tok = os.environ.get("GITEA_TOKEN", "")
|
||||||
|
if not tok:
|
||||||
|
env_file = ROOT / ".env"
|
||||||
|
if env_file.exists():
|
||||||
|
for line in env_file.read_text().splitlines():
|
||||||
|
if line.startswith("GITEA_TOKEN="):
|
||||||
|
tok = line.split("=", 1)[1].strip().strip('"').strip("'")
|
||||||
|
if not tok:
|
||||||
|
print("ERROR: GITEA_TOKEN not set and not found in .env", file=sys.stderr)
|
||||||
|
raise SystemExit(2)
|
||||||
|
return tok
|
||||||
|
|
||||||
|
|
||||||
|
def api(method: str, path: str, data: dict | None = None) -> tuple[int, object]:
|
||||||
|
url = f"{API_BASE}/{path}"
|
||||||
|
body = json.dumps(data).encode() if data is not None else None
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
data=body,
|
||||||
|
method=method,
|
||||||
|
headers={
|
||||||
|
"Authorization": f"token {_token()}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||||
|
raw = resp.read().decode()
|
||||||
|
return resp.status, (json.loads(raw) if raw else None)
|
||||||
|
except urllib.error.HTTPError as e: # noqa: PERF203 — single call site
|
||||||
|
return e.code, e.read().decode()[:300]
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
return 0, str(e)
|
||||||
|
|
||||||
|
|
||||||
|
def _open_nb_issues() -> list[dict]:
|
||||||
|
"""All open issues carrying our marker, keyed lookup done by caller."""
|
||||||
|
issues: list[dict] = []
|
||||||
|
page = 1
|
||||||
|
while True:
|
||||||
|
status, batch = api(
|
||||||
|
"GET",
|
||||||
|
f"repos/{OWNER_REPO}/issues?state=open&type=issues"
|
||||||
|
f"&q={urllib.parse.quote(MARKER)}&page={page}&limit=50",
|
||||||
|
)
|
||||||
|
if status != 200 or not isinstance(batch, list) or not batch:
|
||||||
|
break
|
||||||
|
issues.extend(batch)
|
||||||
|
if len(batch) < 50:
|
||||||
|
break
|
||||||
|
page += 1
|
||||||
|
return [i for i in issues if extract_sig(i.get("body", ""))]
|
||||||
|
|
||||||
|
|
||||||
|
def _label_id() -> int | None:
|
||||||
|
status, labels = api("GET", f"repos/{OWNER_REPO}/labels?limit=50")
|
||||||
|
if status == 200 and isinstance(labels, list):
|
||||||
|
for lab in labels:
|
||||||
|
if lab.get("name") == LABEL_NAME:
|
||||||
|
return lab.get("id")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _last_comment_age_s(issue: dict) -> float | None:
|
||||||
|
ts = issue.get("updated_at")
|
||||||
|
if not ts:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
then = time.mktime(time.strptime(ts[:19], "%Y-%m-%dT%H:%M:%S"))
|
||||||
|
return max(0.0, time.time() - time.timezone - (then - time.timezone))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ── commands ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_report(findings: list[dict], source: str) -> int:
|
||||||
|
open_by_sig = {extract_sig(i["body"]): i for i in _open_nb_issues()}
|
||||||
|
label = _label_id()
|
||||||
|
seen: set[str] = set()
|
||||||
|
failures = 0
|
||||||
|
|
||||||
|
for f in findings:
|
||||||
|
sig = signature(f["notebook"], f["ename"], f.get("evalue", ""))
|
||||||
|
if sig in seen: # same error many times in one run → one action
|
||||||
|
continue
|
||||||
|
seen.add(sig)
|
||||||
|
existing = open_by_sig.get(sig)
|
||||||
|
age = _last_comment_age_s(existing) if existing else None
|
||||||
|
action = decide(existing, age, COOLDOWN_S)
|
||||||
|
|
||||||
|
if action == "create":
|
||||||
|
payload: dict = {
|
||||||
|
"title": f"[nb] {f['notebook']}: {f['ename']}: "
|
||||||
|
f"{f.get('evalue', '')[:120]}",
|
||||||
|
"body": build_body(
|
||||||
|
notebook=f["notebook"],
|
||||||
|
ename=f["ename"],
|
||||||
|
evalue=f.get("evalue", ""),
|
||||||
|
source=source,
|
||||||
|
detail=f.get("detail", ""),
|
||||||
|
sig=sig,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
if label:
|
||||||
|
payload["labels"] = [label]
|
||||||
|
status, resp = api("POST", f"repos/{OWNER_REPO}/issues", payload)
|
||||||
|
if status == 201 and isinstance(resp, dict):
|
||||||
|
print(f" created #{resp.get('number')} [{sig}] {f['notebook']}")
|
||||||
|
else:
|
||||||
|
print(f" CREATE FAILED ({status}) [{sig}]: {resp}", file=sys.stderr)
|
||||||
|
failures += 1
|
||||||
|
elif action == "comment":
|
||||||
|
n = existing["number"]
|
||||||
|
status, _ = api(
|
||||||
|
"POST",
|
||||||
|
f"repos/{OWNER_REPO}/issues/{n}/comments",
|
||||||
|
{
|
||||||
|
"body": f"Still occurring via {source} at "
|
||||||
|
f"{time.strftime('%Y-%m-%d %H:%M UTC', time.gmtime())}."
|
||||||
|
},
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f" commented #{n} [{sig}]"
|
||||||
|
if status == 201
|
||||||
|
else f" COMMENT FAILED ({status}) #{n}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print(f" skip [{sig}] (within cooldown)")
|
||||||
|
|
||||||
|
return 1 if failures else 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_sweep(active_sigs: set[str], source: str) -> int:
|
||||||
|
closed = 0
|
||||||
|
for issue in _open_nb_issues():
|
||||||
|
sig = extract_sig(issue["body"])
|
||||||
|
if sig in active_sigs:
|
||||||
|
continue
|
||||||
|
n = issue["number"]
|
||||||
|
api(
|
||||||
|
"POST",
|
||||||
|
f"repos/{OWNER_REPO}/issues/{n}/comments",
|
||||||
|
{"body": f"No longer occurring ({source}); auto-closing."},
|
||||||
|
)
|
||||||
|
status, _ = api("PATCH", f"repos/{OWNER_REPO}/issues/{n}", {"state": "closed"})
|
||||||
|
if status in (200, 201):
|
||||||
|
print(f" closed #{n} [{sig}]")
|
||||||
|
closed += 1
|
||||||
|
else:
|
||||||
|
print(f" CLOSE FAILED ({status}) #{n}", file=sys.stderr)
|
||||||
|
print(f"sweep: closed {closed}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||||
|
|
||||||
|
p_report = sub.add_parser("report", help="file/refresh issues for findings")
|
||||||
|
p_report.add_argument("--source", required=True)
|
||||||
|
p_report.add_argument("--findings", help="path to findings JSON (default: stdin)")
|
||||||
|
|
||||||
|
p_sweep = sub.add_parser("sweep", help="close issues for inactive signatures")
|
||||||
|
p_sweep.add_argument("--source", required=True)
|
||||||
|
p_sweep.add_argument(
|
||||||
|
"--active-sigs", default="", help="comma-separated signatures still failing"
|
||||||
|
)
|
||||||
|
p_sweep.add_argument("--state", help="watcher state JSON: {sig: last_seen_epoch}")
|
||||||
|
p_sweep.add_argument("--max-age-h", type=float, default=24.0)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.cmd == "report":
|
||||||
|
raw = Path(args.findings).read_text() if args.findings else sys.stdin.read()
|
||||||
|
findings = json.loads(raw) if raw.strip() else []
|
||||||
|
if not findings:
|
||||||
|
print("no findings — nothing to file")
|
||||||
|
return 0
|
||||||
|
return cmd_report(findings, args.source)
|
||||||
|
|
||||||
|
active: set[str] = {s for s in args.active_sigs.split(",") if s}
|
||||||
|
if args.state and Path(args.state).exists():
|
||||||
|
state = json.loads(Path(args.state).read_text())
|
||||||
|
cutoff = time.time() - args.max_age_h * 3600
|
||||||
|
active |= {sig for sig, ts in state.items() if ts >= cutoff}
|
||||||
|
return cmd_sweep(active, args.source)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
235
dev/scripts/nb_watcher.py
Normal file
235
dev/scripts/nb_watcher.py
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
"""Watch the running notebooks (marimo) service for errors and auto-file
|
||||||
|
deduplicated Gitea issues.
|
||||||
|
|
||||||
|
Two streams, one signature space (shared with nb_integration.py):
|
||||||
|
|
||||||
|
1. Container stdout/stderr via the docker socket (server errors, kernel
|
||||||
|
tracebacks) — read with stdlib http.client over the unix socket, no
|
||||||
|
docker CLI needed.
|
||||||
|
2. Session snapshots at <notebooks>/__marimo__/session/*.json (cell-level
|
||||||
|
errors from live sessions and nightly integration runs alike).
|
||||||
|
|
||||||
|
Findings go through nb_issue_filer (create/comment with cooldown). This
|
||||||
|
watcher is the single closing authority: signatures unseen for
|
||||||
|
NB_CLOSE_AFTER_H hours are swept closed. State (last-seen per signature,
|
||||||
|
log cursor) persists in NB_STATE_FILE.
|
||||||
|
|
||||||
|
Runs as the `nb-watcher` compose sidecar with dev/scripts mounted at
|
||||||
|
/scripts and the notebooks dir at /notebooks (both ro).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import http.client
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
import nb_integration # noqa: E402 — sibling: parse_snapshot
|
||||||
|
import nb_issue_filer # noqa: E402 — sibling: cmd_report/cmd_sweep/signature
|
||||||
|
|
||||||
|
DOCKER_SOCK = os.environ.get("DOCKER_SOCK", "/var/run/docker.sock")
|
||||||
|
CONTAINER = os.environ.get("NB_CONTAINER", "notebooks")
|
||||||
|
NB_DIR = Path(os.environ.get("NB_DIR", "/notebooks"))
|
||||||
|
STATE_FILE = Path(os.environ.get("NB_STATE_FILE", "/state/nb-watcher-state.json"))
|
||||||
|
HEARTBEAT = Path(os.environ.get("NB_HEARTBEAT", "/tmp/heartbeat"))
|
||||||
|
POLL_S = int(os.environ.get("NB_POLL_S", "60"))
|
||||||
|
SWEEP_EVERY_S = int(os.environ.get("NB_SWEEP_EVERY_S", "3600"))
|
||||||
|
CLOSE_AFTER_H = float(os.environ.get("NB_CLOSE_AFTER_H", "24"))
|
||||||
|
SOURCE = "nb-watcher"
|
||||||
|
|
||||||
|
_TRACEBACK_START = "Traceback (most recent call last):"
|
||||||
|
|
||||||
|
|
||||||
|
# ── docker logs via unix socket ──────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class _UnixConn(http.client.HTTPConnection):
|
||||||
|
def __init__(self, path: str):
|
||||||
|
super().__init__("localhost", timeout=30)
|
||||||
|
self._unix_path = path
|
||||||
|
|
||||||
|
def connect(self) -> None:
|
||||||
|
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||||
|
s.settimeout(30)
|
||||||
|
s.connect(self._unix_path)
|
||||||
|
self.sock = s
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_logs(since_epoch: int) -> str:
|
||||||
|
"""Return decoded log text since `since_epoch` (demuxing the docker
|
||||||
|
multiplexed stream format when present)."""
|
||||||
|
conn = _UnixConn(DOCKER_SOCK)
|
||||||
|
try:
|
||||||
|
conn.request(
|
||||||
|
"GET",
|
||||||
|
f"/containers/{CONTAINER}/logs?stdout=1&stderr=1&since={since_epoch}",
|
||||||
|
)
|
||||||
|
resp = conn.getresponse()
|
||||||
|
raw = resp.read()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
if not raw:
|
||||||
|
return ""
|
||||||
|
# Multiplexed frames: [stream:1][pad:3][len:4 BE][payload]. TTY
|
||||||
|
# containers emit raw text instead — detect by header shape.
|
||||||
|
if raw[0] in (0, 1, 2) and raw[1:4] == b"\x00\x00\x00":
|
||||||
|
out = bytearray()
|
||||||
|
i = 0
|
||||||
|
while i + 8 <= len(raw):
|
||||||
|
size = int.from_bytes(raw[i + 4 : i + 8], "big")
|
||||||
|
out += raw[i + 8 : i + 8 + size]
|
||||||
|
i += 8 + size
|
||||||
|
return out.decode(errors="replace")
|
||||||
|
return raw.decode(errors="replace")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_log_errors(text: str) -> list[dict]:
|
||||||
|
"""Extract tracebacks (and their final ExcType: message) from log text."""
|
||||||
|
findings: list[dict] = []
|
||||||
|
lines = text.splitlines()
|
||||||
|
i = 0
|
||||||
|
while i < len(lines):
|
||||||
|
if _TRACEBACK_START in lines[i]:
|
||||||
|
block = [lines[i]]
|
||||||
|
i += 1
|
||||||
|
while i < len(lines) and (
|
||||||
|
lines[i].startswith((" ", "\t")) or not lines[i].strip()
|
||||||
|
):
|
||||||
|
block.append(lines[i])
|
||||||
|
i += 1
|
||||||
|
# final "ExcType: message" line belongs to the traceback
|
||||||
|
tail = lines[i] if i < len(lines) else ""
|
||||||
|
m = re.match(
|
||||||
|
r"^([A-Za-z_][\w.]*(?:Error|Exception|Interrupt|Warning))\b:?\s*(.*)",
|
||||||
|
tail.strip(),
|
||||||
|
)
|
||||||
|
if m:
|
||||||
|
block.append(tail)
|
||||||
|
i += 1
|
||||||
|
ename, evalue = m.group(1), m.group(2) or "(no message)"
|
||||||
|
else:
|
||||||
|
ename, evalue = "traceback", block[-1].strip()[:200] or "(unknown)"
|
||||||
|
findings.append(
|
||||||
|
{
|
||||||
|
"notebook": "(service)",
|
||||||
|
"ename": ename,
|
||||||
|
"evalue": evalue,
|
||||||
|
"detail": "\n".join(block)[-3500:],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
i += 1
|
||||||
|
return findings
|
||||||
|
|
||||||
|
|
||||||
|
# ── session snapshots ────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def scan_snapshots(since_mtime: float) -> tuple[list[dict], float]:
|
||||||
|
findings: list[dict] = []
|
||||||
|
newest = since_mtime
|
||||||
|
session_dir = NB_DIR / "__marimo__" / "session"
|
||||||
|
if not session_dir.is_dir():
|
||||||
|
return findings, newest
|
||||||
|
for snap in session_dir.glob("*.json"):
|
||||||
|
try:
|
||||||
|
mtime = snap.stat().st_mtime
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
if mtime <= since_mtime:
|
||||||
|
continue
|
||||||
|
newest = max(newest, mtime)
|
||||||
|
notebook = snap.name.removesuffix(".json")
|
||||||
|
for err in nb_integration.parse_snapshot(snap):
|
||||||
|
if err["ename"] in ("snapshot-missing", "snapshot-unreadable"):
|
||||||
|
continue
|
||||||
|
findings.append({"notebook": notebook, **err})
|
||||||
|
return findings, newest
|
||||||
|
|
||||||
|
|
||||||
|
# ── state ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def load_state() -> dict:
|
||||||
|
if STATE_FILE.exists():
|
||||||
|
try:
|
||||||
|
return json.loads(STATE_FILE.read_text())
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
pass
|
||||||
|
return {"sigs": {}, "log_cursor": int(time.time()), "snap_cursor": 0.0}
|
||||||
|
|
||||||
|
|
||||||
|
def save_state(state: dict) -> None:
|
||||||
|
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = STATE_FILE.with_suffix(".tmp")
|
||||||
|
tmp.write_text(json.dumps(state))
|
||||||
|
tmp.replace(STATE_FILE)
|
||||||
|
|
||||||
|
|
||||||
|
# ── main loop ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def tick(state: dict) -> list[dict]:
|
||||||
|
"""One poll: gather findings from both streams, advance cursors."""
|
||||||
|
findings: list[dict] = []
|
||||||
|
|
||||||
|
cursor = state.get("log_cursor", int(time.time()))
|
||||||
|
now = int(time.time())
|
||||||
|
try:
|
||||||
|
findings += parse_log_errors(fetch_logs(cursor))
|
||||||
|
state["log_cursor"] = now
|
||||||
|
except (OSError, http.client.HTTPException) as e:
|
||||||
|
print(f"log fetch failed (will retry): {e}", file=sys.stderr)
|
||||||
|
|
||||||
|
snaps, newest = scan_snapshots(state.get("snap_cursor", 0.0))
|
||||||
|
findings += snaps
|
||||||
|
state["snap_cursor"] = newest
|
||||||
|
|
||||||
|
for f in findings:
|
||||||
|
sig = nb_issue_filer.signature(f["notebook"], f["ename"], f["evalue"])
|
||||||
|
state.setdefault("sigs", {})[sig] = time.time()
|
||||||
|
return findings
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
state = load_state()
|
||||||
|
last_sweep = 0.0
|
||||||
|
print(
|
||||||
|
f"nb-watcher: container={CONTAINER} nb_dir={NB_DIR} "
|
||||||
|
f"poll={POLL_S}s close_after={CLOSE_AFTER_H}h"
|
||||||
|
)
|
||||||
|
while True:
|
||||||
|
findings = tick(state)
|
||||||
|
if findings:
|
||||||
|
print(f"{len(findings)} finding(s)")
|
||||||
|
try:
|
||||||
|
nb_issue_filer.cmd_report(findings, SOURCE)
|
||||||
|
except Exception as e: # noqa: BLE001 — keep the watcher alive
|
||||||
|
print(f"filer error (continuing): {e}", file=sys.stderr)
|
||||||
|
|
||||||
|
if time.time() - last_sweep >= SWEEP_EVERY_S:
|
||||||
|
cutoff = time.time() - CLOSE_AFTER_H * 3600
|
||||||
|
active = {s for s, ts in state.get("sigs", {}).items() if ts >= cutoff}
|
||||||
|
try:
|
||||||
|
nb_issue_filer.cmd_sweep(active, SOURCE)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
print(f"sweep error (continuing): {e}", file=sys.stderr)
|
||||||
|
last_sweep = time.time()
|
||||||
|
# drop long-stale sigs so state doesn't grow unbounded
|
||||||
|
state["sigs"] = {
|
||||||
|
s: ts for s, ts in state.get("sigs", {}).items() if ts >= cutoff
|
||||||
|
}
|
||||||
|
|
||||||
|
save_state(state)
|
||||||
|
HEARTBEAT.touch()
|
||||||
|
time.sleep(POLL_S)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -62,8 +62,10 @@ Wired in two places:
|
|||||||
build — boots a throwaway container from the just-built image on the CI
|
build — boots a throwaway container from the just-built image on the CI
|
||||||
docker network, probes it, tears it down. A bundle that crashes the editor
|
docker network, probes it, tears it down. A bundle that crashes the editor
|
||||||
can no longer ship.
|
can no longer ship.
|
||||||
- **Post-deploy check:** `deploy.yml` notebooks job probes the live service
|
- **Live-service check:** the nightly workflow probes the running service.
|
||||||
after recreate.
|
(`deploy.yml` only builds/pushes images — container recreation is a manual
|
||||||
|
`docker compose up -d` — so there is no in-workflow "post-deploy" moment
|
||||||
|
to hook.)
|
||||||
|
|
||||||
### 3. `dev/scripts/nb_issue_filer.py` + `nb-watcher` sidecar — dedup/auto-close filer
|
### 3. `dev/scripts/nb_issue_filer.py` + `nb-watcher` sidecar — dedup/auto-close filer
|
||||||
|
|
||||||
|
|||||||
@@ -26,8 +26,11 @@ WORKDIR /src
|
|||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends git ca-certificates python3 jq \
|
&& apt-get install -y --no-install-recommends git ca-certificates python3 jq \
|
||||||
&& rm -rf /var/lib/apt/lists/* \
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
&& corepack enable \
|
&& corepack enable
|
||||||
&& corepack prepare pnpm@latest --activate
|
# No `corepack prepare pnpm@latest`: the corepack shim resolves the exact
|
||||||
|
# pnpm version from marimo's package.json `packageManager` field at first
|
||||||
|
# use, so the package manager can't float between builds (the lockfile fix
|
||||||
|
# in apply-overlay.sh covers the dependency graph; this covers the tool).
|
||||||
|
|
||||||
# Shallow clone marimo at the pinned tag.
|
# Shallow clone marimo at the pinned tag.
|
||||||
RUN git clone --depth 1 --branch ${MARIMO_VERSION} --filter=blob:none \
|
RUN git clone --depth 1 --branch ${MARIMO_VERSION} --filter=blob:none \
|
||||||
|
|||||||
16
infra/marimo/nb-tests.toml
Normal file
16
infra/marimo/nb-tests.toml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# Notebook integration-test config — consumed by dev/scripts/nb_integration.py.
|
||||||
|
#
|
||||||
|
# [ci_smoke].notebooks: run in CI on every relevant push. Must execute clean
|
||||||
|
# from a bare `uv sync --dev` checkout: no compose services, no ./data, no
|
||||||
|
# GPU. (sample.py is excluded because it imports vega_datasets, which only
|
||||||
|
# the notebooks-container venv carries.)
|
||||||
|
#
|
||||||
|
# [expected_failures]: notebook -> reason. Errors are still reported (and
|
||||||
|
# auto-filed with dedup on scheduled runs) but don't fail the run.
|
||||||
|
|
||||||
|
[ci_smoke]
|
||||||
|
notebooks = ["_template.py", "sql_generator.py"]
|
||||||
|
|
||||||
|
[expected_failures]
|
||||||
|
"acodb_explorer.py" = "aco.duckdb single-writer lock until #508-514 lands"
|
||||||
|
"gpu_test.py" = "requires GPU; nightly run inside the container may race other GPU users"
|
||||||
112
tests/dev/test_nb_integration.py
Normal file
112
tests/dev/test_nb_integration.py
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
"""Tests for dev/scripts/nb_integration.py — session snapshot parsing and
|
||||||
|
pass/fail classification.
|
||||||
|
|
||||||
|
The snapshot fixture mirrors the real shape marimo 0.23.13 writes to
|
||||||
|
__marimo__/session/<nb>.py.json (verified empirically): cell errors appear
|
||||||
|
as outputs with type "error" plus ename/evalue.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_SCRIPT = Path(__file__).resolve().parents[2] / "dev" / "scripts" / "nb_integration.py"
|
||||||
|
_spec = importlib.util.spec_from_file_location("_nb_integration", _SCRIPT)
|
||||||
|
assert _spec and _spec.loader
|
||||||
|
nbi = importlib.util.module_from_spec(_spec)
|
||||||
|
sys.modules["_nb_integration"] = nbi
|
||||||
|
_spec.loader.exec_module(nbi)
|
||||||
|
|
||||||
|
|
||||||
|
SNAPSHOT_OK = {
|
||||||
|
"version": "1",
|
||||||
|
"metadata": {"marimo_version": "0.23.13"},
|
||||||
|
"cells": [
|
||||||
|
{
|
||||||
|
"id": "Hbol",
|
||||||
|
"outputs": [{"type": "data", "data": {"text/html": "<pre>2</pre>"}}],
|
||||||
|
"console": [],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
SNAPSHOT_ERR = {
|
||||||
|
"version": "1",
|
||||||
|
"metadata": {"marimo_version": "0.23.13"},
|
||||||
|
"cells": [
|
||||||
|
{
|
||||||
|
"id": "Hbol",
|
||||||
|
"outputs": [{"type": "data", "data": {"text/html": "<pre>2</pre>"}}],
|
||||||
|
"console": [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "MJUe",
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"type": "error",
|
||||||
|
"ename": "exception",
|
||||||
|
"evalue": "intentional failure 2",
|
||||||
|
"traceback": None,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"console": [
|
||||||
|
{
|
||||||
|
"type": "stream",
|
||||||
|
"name": "stderr",
|
||||||
|
"text": "<pre>ValueError: intentional failure 2</pre>",
|
||||||
|
"mimetype": "application/vnd.marimo+traceback",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _write(tmp_path: Path, name: str, snap: dict) -> Path:
|
||||||
|
p = tmp_path / name
|
||||||
|
p.write_text(json.dumps(snap))
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_snapshot_clean(tmp_path):
|
||||||
|
errors = nbi.parse_snapshot(_write(tmp_path, "ok.py.json", SNAPSHOT_OK))
|
||||||
|
assert errors == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_snapshot_extracts_errors_with_console_detail(tmp_path):
|
||||||
|
errors = nbi.parse_snapshot(_write(tmp_path, "err.py.json", SNAPSHOT_ERR))
|
||||||
|
assert len(errors) == 1
|
||||||
|
e = errors[0]
|
||||||
|
assert e["cell"] == "MJUe"
|
||||||
|
assert e["ename"] == "exception"
|
||||||
|
assert e["evalue"] == "intentional failure 2"
|
||||||
|
assert "intentional failure" in e["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_snapshot_missing_file_reports_export_error(tmp_path):
|
||||||
|
errors = nbi.parse_snapshot(tmp_path / "never-written.py.json")
|
||||||
|
assert len(errors) == 1
|
||||||
|
assert errors[0]["ename"] == "snapshot-missing"
|
||||||
|
|
||||||
|
|
||||||
|
def test_classify_pass_fail_and_expected():
|
||||||
|
expected = {"known_bad.py": "duckdb lock (#508)"}
|
||||||
|
assert nbi.classify("clean.py", [], expected) == "pass"
|
||||||
|
err = [{"cell": "x", "ename": "exception", "evalue": "y", "detail": ""}]
|
||||||
|
assert nbi.classify("clean.py", err, expected) == "fail"
|
||||||
|
assert nbi.classify("known_bad.py", err, expected) == "expected-fail"
|
||||||
|
# an expected-failure notebook that passes should surface as pass
|
||||||
|
assert nbi.classify("known_bad.py", [], expected) == "pass"
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_exit_code():
|
||||||
|
results = {
|
||||||
|
"a.py": {"status": "pass", "errors": []},
|
||||||
|
"b.py": {"status": "expected-fail", "errors": [{"ename": "e"}]},
|
||||||
|
}
|
||||||
|
assert nbi.exit_code(results) == 0
|
||||||
|
results["c.py"] = {"status": "fail", "errors": [{"ename": "e"}]}
|
||||||
|
assert nbi.exit_code(results) == 1
|
||||||
123
tests/dev/test_nb_issue_filer.py
Normal file
123
tests/dev/test_nb_issue_filer.py
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
"""Tests for dev/scripts/nb_issue_filer.py — signature stability and
|
||||||
|
dedup/auto-close decisions.
|
||||||
|
|
||||||
|
The filer exists because api.diag.ci files one issue per failure and never
|
||||||
|
closes them (23 duplicates accumulated for pkg-supply-chain alone). The
|
||||||
|
invariants under test: identical failures collapse to one signature across
|
||||||
|
runs, volatile details (paths, line numbers, addresses, counts) don't
|
||||||
|
change the signature, and the decide/close logic never files a duplicate
|
||||||
|
for an already-open signature.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_SCRIPT = Path(__file__).resolve().parents[2] / "dev" / "scripts" / "nb_issue_filer.py"
|
||||||
|
_spec = importlib.util.spec_from_file_location("_nb_issue_filer", _SCRIPT)
|
||||||
|
assert _spec and _spec.loader
|
||||||
|
filer = importlib.util.module_from_spec(_spec)
|
||||||
|
sys.modules["_nb_issue_filer"] = filer
|
||||||
|
_spec.loader.exec_module(filer)
|
||||||
|
|
||||||
|
|
||||||
|
# ── signature ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_signature_stable_for_identical_error():
|
||||||
|
a = filer.signature("acodb_explorer.py", "exception", "division by zero")
|
||||||
|
b = filer.signature("acodb_explorer.py", "exception", "division by zero")
|
||||||
|
assert a == b
|
||||||
|
|
||||||
|
|
||||||
|
def test_signature_differs_across_notebooks_and_errors():
|
||||||
|
base = filer.signature("a.py", "exception", "boom")
|
||||||
|
assert filer.signature("b.py", "exception", "boom") != base
|
||||||
|
assert filer.signature("a.py", "interruption", "boom") != base
|
||||||
|
assert filer.signature("a.py", "exception", "other") != base
|
||||||
|
|
||||||
|
|
||||||
|
def test_signature_ignores_volatile_details():
|
||||||
|
a = filer.signature(
|
||||||
|
"nb.py",
|
||||||
|
"exception",
|
||||||
|
'IO Error: could not open "/tmp/marimo_2487488/cell_MJUe.py" at 0x7f3a2c1b'
|
||||||
|
" (attempt 3 of 5)",
|
||||||
|
)
|
||||||
|
b = filer.signature(
|
||||||
|
"nb.py",
|
||||||
|
"exception",
|
||||||
|
'IO Error: could not open "/tmp/marimo_9911223/cell_MJUe.py" at 0x559e00aa'
|
||||||
|
" (attempt 4 of 5)",
|
||||||
|
)
|
||||||
|
assert a == b
|
||||||
|
|
||||||
|
|
||||||
|
def test_signature_is_short_hex():
|
||||||
|
sig = filer.signature("nb.py", "exception", "x")
|
||||||
|
assert len(sig) == 12
|
||||||
|
int(sig, 16) # parses as hex
|
||||||
|
|
||||||
|
|
||||||
|
# ── normalize ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_collapses_paths_hex_and_numbers():
|
||||||
|
s = filer.normalize_evalue(
|
||||||
|
"failed /home/kert/notebooks/x.py line 42 addr 0xDEADBEEF took 3.14s"
|
||||||
|
)
|
||||||
|
assert "0x" not in s.lower() or "0xN" in s
|
||||||
|
assert "/home/kert" not in s
|
||||||
|
assert "42" not in s
|
||||||
|
assert "3.14" not in s
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_keeps_error_identity():
|
||||||
|
s = filer.normalize_evalue("Could not remove file: No such file or directory")
|
||||||
|
assert "no such file or directory" in s.lower()
|
||||||
|
|
||||||
|
|
||||||
|
# ── decide ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_decide_creates_when_no_open_issue():
|
||||||
|
action = filer.decide(existing=None, last_comment_age_s=None, cooldown_s=3600)
|
||||||
|
assert action == "create"
|
||||||
|
|
||||||
|
|
||||||
|
def test_decide_comments_after_cooldown():
|
||||||
|
action = filer.decide(
|
||||||
|
existing={"number": 7}, last_comment_age_s=7200, cooldown_s=3600
|
||||||
|
)
|
||||||
|
assert action == "comment"
|
||||||
|
|
||||||
|
|
||||||
|
def test_decide_skips_within_cooldown():
|
||||||
|
action = filer.decide(
|
||||||
|
existing={"number": 7}, last_comment_age_s=60, cooldown_s=3600
|
||||||
|
)
|
||||||
|
assert action == "skip"
|
||||||
|
|
||||||
|
|
||||||
|
# ── issue body marker round-trip ─────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_marker_embeds_and_extracts_signature():
|
||||||
|
sig = filer.signature("nb.py", "exception", "boom")
|
||||||
|
body = filer.build_body(
|
||||||
|
notebook="nb.py",
|
||||||
|
ename="exception",
|
||||||
|
evalue="boom",
|
||||||
|
source="nightly-integration",
|
||||||
|
detail="Traceback ...",
|
||||||
|
sig=sig,
|
||||||
|
)
|
||||||
|
assert filer.extract_sig(body) == sig
|
||||||
|
assert "nb.py" in body
|
||||||
|
assert "nightly-integration" in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_sig_none_when_absent():
|
||||||
|
assert filer.extract_sig("no marker here") is None
|
||||||
Reference in New Issue
Block a user