diff --git a/.github/workflows/sdk-extras-check.yml b/.github/workflows/sdk-extras-check.yml new file mode 100644 index 0000000..1756393 --- /dev/null +++ b/.github/workflows/sdk-extras-check.yml @@ -0,0 +1,50 @@ +name: SDK install extras + +# Asserts that every documented `pip install "tracebloc[...]"` line names an +# extra that actually exists in the release its version floor resolves to. +# +# Catches the failure mode from backend#1858: tools-help/tracebloc.mdx +# documented `tracebloc[boosting]` and `tracebloc[survival]` for eight +# published releases after both were removed in 0.10.0. +# +# This is the one class of docs error no other check here can see: +# - Mintlify validates links and MDX; a fenced code block is opaque to it. +# - `pip` does not fail on an unknown extra. It warns, installs the core +# package, and exits 0 — so the reader's install "succeeds" and then dies +# later as an ImportError, far from the command that caused it. +# +# Runs on a schedule as well as on push, because the docs can rot without the +# docs changing: a floating `>=` floor silently re-points at each new release, +# so an extra removed upstream breaks a page nobody edited. +# +# No step interpolates event data into a shell command; the only expression +# used is github.ref, in the concurrency key. + +on: + push: + branches: [develop, main] + pull_request: + paths: + - '**.mdx' + - '**.md' + - 'scripts/check-sdk-extras.py' + - '.github/workflows/sdk-extras-check.yml' + schedule: + - cron: '30 6 * * 1' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: sdk-extras-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + + - name: Validate documented extras against PyPI + run: python3 scripts/check-sdk-extras.py diff --git a/Makefile b/Makefile index 9caa190..6cc4fbf 100644 --- a/Makefile +++ b/Makefile @@ -22,7 +22,7 @@ help: @echo "tracebloc/docs — make targets" @echo @echo " check mint broken-links (~2 s) — run this before every push" - @echo " check-all the same; see below for why" + @echo " check-all check + the SDK install-extras gate (needs network)" @echo " setup npm i -g mint" @echo @echo " dev mint dev — local preview on http://localhost:3000" @@ -31,25 +31,32 @@ help: # # MEASURED at 1.8 s on the current tree, and green. # -# check-all is the same set, and that is honest rather than lazy. This -# repo has no test suite and no content gate in CI: the only automated -# checks on a docs PR are the org-shared ones (gitleaks + house-rules, -# which need the shared checker from tracebloc/.github and are not -# reproducible from a working tree), and preview-page-coverage.yml — -# which probes the RENDERED site over HTTP after Mintlify has deployed, -# so by construction it cannot run against local files. +# This repo has no test suite. The automated checks on a docs PR are the +# org-shared ones (gitleaks + house-rules, which need the shared checker +# from tracebloc/.github and are not reproducible from a working tree), +# preview-page-coverage.yml — which probes the RENDERED site over HTTP +# after Mintlify has deployed, so by construction it cannot run against +# local files — and sdk-extras-check.yml, which CAN run locally and is +# wired into check-all below. # -# `mint broken-links` is therefore the whole of what a person can -# usefully check here before pushing. It is also the check that would -# have caught the class of problem preview-page-coverage.yml exists to -# catch, one step earlier. +# `mint broken-links` is what catches a bad link before pushing. It is +# also the check that would have caught the class of problem +# preview-page-coverage.yml exists to catch, one step earlier. .PHONY: check check: guard-mint $(MINT) broken-links +# check-all adds the one gate that needs network: every documented +# `pip install "tracebloc[...]"` line must name an extra that really +# exists in the release its floor resolves to (backend#1858). Kept out of +# `check` so the fast path stays offline and under the 60 s budget. .PHONY: check-all -check-all: check - @echo "==> check-all: green (this repo has no separate slow tier — see the Makefile comment)" +check-all: check check-sdk-extras + @echo "==> check-all: green" + +.PHONY: check-sdk-extras +check-sdk-extras: + python3 scripts/check-sdk-extras.py # setup: the Mintlify CLI, exactly as CONTRIBUTING.md prescribes. No # pre-commit / pre-push hook is installed here — that is a later step of diff --git a/join-use-case/start-training.mdx b/join-use-case/start-training.mdx index 7963246..5f8f3b4 100644 --- a/join-use-case/start-training.mdx +++ b/join-use-case/start-training.mdx @@ -28,20 +28,24 @@ Then, install the [Anaconda](https://www.anaconda.com/docs/getting-started/anaco Create a new environment, name it for example "tracebloc": ```bash -conda create -n tracebloc python=3.9 +conda create -n tracebloc python=3.12 conda activate tracebloc ``` +The SDK requires Python 3.11 or 3.12. On an older Python, no release satisfies +the `>=0.18.1` requirement below, so `pip` stops with a `No matching distribution +found` error instead of installing anything. + Then, install requirements: ```bash python -m pip install --upgrade pip # Install with the extra that matches your framework: -pip install "tracebloc[pytorch]>=0.8.1" -# pip install "tracebloc[tensorflow]>=0.8.1" -# pip install "tracebloc[sklearn]>=0.8.1" -# pip install "tracebloc[all]>=0.8.1" # legacy behaviour — all frameworks +pip install "tracebloc[pytorch]>=0.18.1" +# pip install "tracebloc[tensorflow]>=0.18.1" +# pip install "tracebloc[sklearn]>=0.18.1" +# pip install "tracebloc[all]>=0.18.1" # all frameworks ``` ## Install and Launch Jupyter Notebook diff --git a/scripts/check-sdk-extras.py b/scripts/check-sdk-extras.py new file mode 100755 index 0000000..cb9f1ad --- /dev/null +++ b/scripts/check-sdk-extras.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +"""Validate every documented `pip install "tracebloc[...]"` line against PyPI. + +Why this exists (backend#1858) +------------------------------ +`tools-help/tracebloc.mdx` documented `tracebloc[boosting]` and +`tracebloc[survival]` for eight published releases after both extras were +removed in 0.10.0. Nothing caught it, because this is the one class of docs +error that no docs tool can see: + + * Mintlify validates links and MDX. A fenced code block is opaque to it. + * `pip` does NOT fail on an unknown extra. It prints + "WARNING: tracebloc X does not provide the extra 'boosting'", installs + the core package, and exits 0. The user's build appears to succeed and + then dies later as an ImportError, far from the command that caused it. + +So a wrong extra in the docs silently mis-installs software. This script +closes that gap by resolving each documented extra against the published +`Provides-Extra` metadata for the version the documented floor selects. + +It also checks the version floor itself, because the floor is what made the +original bug silent: `>=0.8.1` floats forward to a release that no longer has +the extras, while still being satisfiable by an ancient release on an old +Python. + +Usage: + python3 scripts/check-sdk-extras.py # scan the repo + python3 scripts/check-sdk-extras.py FILE... # scan specific files + +Exits 0 if every documented extra exists, 1 otherwise. Requires network +access to pypi.org. +""" + +from __future__ import annotations + +import json +import re +import sys +import urllib.error +import urllib.request +from pathlib import Path + +PACKAGE = "tracebloc" +PYPI_URL = f"https://pypi.org/pypi/{PACKAGE}/json" + +# Matches: tracebloc[a,b]>=1.2.3 / tracebloc[a] / tracebloc[a]==1.2.3 +# Captures the extras list and, when present, the version specifier. +SPEC_RE = re.compile( + r"\b" + PACKAGE + r"\[([^\]]+)\]\s*(?:(==|>=|~=|>)\s*([0-9][0-9A-Za-z.*+!-]*))?" +) + +DOC_SUFFIXES = {".mdx", ".md"} +SKIP_DIRS = {".git", "node_modules", ".venv", "images"} + + +def fetch_metadata() -> dict: + try: + with urllib.request.urlopen(PYPI_URL, timeout=30) as resp: + return json.load(resp) + except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc: + sys.exit(f"error: could not read {PYPI_URL}: {exc}") + + +def parse_version(value: str) -> tuple: + """Coarse numeric version key. Good enough to order this package's tags.""" + parts = [] + for chunk in value.split("."): + digits = re.match(r"\d+", chunk) + parts.append(int(digits.group()) if digits else 0) + return tuple(parts) + + +def resolve_version(floor: str | None, operator: str | None, releases: list[str]) -> str: + """Which published version does this documented specifier actually select? + + `pip` picks the NEWEST version satisfying the specifier, so that — not the + floor itself — is the version whose extras the reader ends up with. + """ + if floor is None: + return max(releases, key=parse_version) + if operator == "==": + return floor + candidates = [r for r in releases if parse_version(r) >= parse_version(floor)] + if not candidates: + return floor + return max(candidates, key=parse_version) + + +def iter_doc_files(roots: list[str]) -> list[Path]: + if roots: + return [Path(r) for r in roots] + found: list[Path] = [] + for path in Path(".").rglob("*"): + if any(part in SKIP_DIRS for part in path.parts): + continue + if path.is_file() and path.suffix in DOC_SUFFIXES: + found.append(path) + return sorted(found) + + +def main(argv: list[str]) -> int: + meta = fetch_metadata() + releases = sorted(meta["releases"].keys(), key=parse_version) + latest = meta["info"]["version"] + + # Provides-Extra per version needs a per-version fetch; cache it. + extras_cache: dict[str, set[str]] = { + latest: set(meta["info"].get("provides_extra") or []) + } + + def extras_for(version: str) -> set[str] | None: + if version in extras_cache: + return extras_cache[version] + url = f"https://pypi.org/pypi/{PACKAGE}/{version}/json" + try: + with urllib.request.urlopen(url, timeout=30) as resp: + data = json.load(resp) + except (urllib.error.URLError, TimeoutError, json.JSONDecodeError): + extras_cache[version] = None + return None + extras_cache[version] = set(data["info"].get("provides_extra") or []) + return extras_cache[version] + + failures: list[str] = [] + checked = 0 + + for path in iter_doc_files(argv): + try: + text = path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + continue + + for lineno, line in enumerate(text.splitlines(), start=1): + for match in SPEC_RE.finditer(line): + extras_raw, operator, floor = match.groups() + extras = [e.strip() for e in extras_raw.split(",") if e.strip()] + checked += 1 + + if floor is not None and floor not in releases: + failures.append( + f"{path}:{lineno}: version {floor} is not published on PyPI" + ) + continue + + version = resolve_version(floor, operator, releases) + available = extras_for(version) + if available is None: + failures.append( + f"{path}:{lineno}: could not read metadata for {PACKAGE} {version}" + ) + continue + + for extra in extras: + if extra not in available: + failures.append( + f"{path}:{lineno}: {PACKAGE}[{extra}] does not exist in " + f"{version} (the version '{operator or ''}{floor or 'latest'}' " + f"resolves to). Available: {', '.join(sorted(available))}" + ) + + print(f"Checked {checked} documented '{PACKAGE}[...]' spec(s); latest release is {latest}.") + + if failures: + print(f"\n{len(failures)} problem(s) found:\n", file=sys.stderr) + for failure in failures: + print(f" {failure}", file=sys.stderr) + print( + "\nNote: pip warns and exits 0 on an unknown extra, so a wrong extra here " + "silently installs the core SDK only.", + file=sys.stderr, + ) + return 1 + + print("All documented extras exist in the versions they resolve to.") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/tools-help/tracebloc.mdx b/tools-help/tracebloc.mdx index 230879c..2729f4a 100644 --- a/tools-help/tracebloc.mdx +++ b/tools-help/tracebloc.mdx @@ -11,17 +11,23 @@ description: "Python library for uploading models, linking them with datasets, c ## Installation +Requires Python 3.11 or 3.12. + Pick the extra that matches your ML framework — the default install ships the core SDK only (~140 MB, ~30 sec) instead of every framework (~8 GB): ```bash -pip install "tracebloc[pytorch]>=0.8.1" # most users -# pip install "tracebloc[tensorflow]>=0.8.1" # TensorFlow -# pip install "tracebloc[sklearn]>=0.8.1" # scikit-learn only -# pip install "tracebloc[boosting]>=0.8.1" # XGBoost / CatBoost / LightGBM -# pip install "tracebloc[survival]>=0.8.1" # lifelines / scikit-survival -# pip install "tracebloc[all]>=0.8.1" # everything +pip install "tracebloc[pytorch]>=0.18.1" # most users +# pip install "tracebloc[tensorflow]>=0.18.1" # TensorFlow +# pip install "tracebloc[sklearn]>=0.18.1" # scikit-learn, incl. XGBoost / CatBoost / LightGBM +# pip install "tracebloc[xgboost]>=0.18.1" # one boosting backend only (or: catboost, lightgbm) +# pip install "tracebloc[lifelines,scikit-survival]>=0.18.1" # survival analysis +# pip install "tracebloc[all]>=0.18.1" # everything ``` + + The umbrella `[boosting]` and `[survival]` extras were removed in 0.10.0, along with `[huggingface]` (the Hugging Face stack now ships inside `[pytorch]`). Use the per-library extras above. `pip` does not fail on an unknown extra — it prints a warning, installs the core SDK only, and exits 0, so the mistake surfaces later as an `ImportError`. + + ## Key Features - Upload model files and pretrained weights