Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,14 @@
`CliError`; a missing or old Node raises `NodeLocatorError`.
- `verify` drops a bundle's top-level `trustRegistry` before the CLI sees it (typedstandards#136).
- `CLI_VERSION = "0.2.0"` and `cli_version()`.
- `pin(url)`: fetches once and returns the bytes with a `queries[]` retrieval entry (`url`,
`sha256`, `bytes`, `httpStatus`, `fetchedAt`; `rowsUpdatedAt` and `datasetId` for a portal
resource); `save=` writes both.
- `badge_cell`: the verifier badge as a notebook's first cell, or a `mo.md` cell's source; no hash
and no time in the cell.
- `comparison_cell`: the spec §8.7.4 comparison cell, appended as the last cell; values must be
literals.
- `sidecar`: `<artifact file name>.record.yaml` from `view`'s output, without `package` and
`trustRegistry`.
- `show`: HTML for Jupyter (`_repr_html_`) or Marimo (`mo.Html`) from a record and its
`verify --json` result.
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ manager a non-interactive shell may have no `node` on `PATH`; load it first
`uv sync --reinstall-package typedstandards`.
- Another Python: `uv run --python 3.11 pytest`. Another Node: put it first on `PATH`, or set
`TYPEDSTANDARDS_NODE`.
- Every test runs offline: an autouse fixture in `tests/conftest.py` makes socket connect, bind
and `create_connection` raise. `pin`'s tests use `httpx.MockTransport`. `marimo` and
`nbformat` are in the dev group only, so the helpers' tests drive the real `mo.md`, `mo.Html`
and nbformat schema; the package never imports either at module load.

The checks CI runs (`.github/workflows/ci.yml`):

Expand Down
75 changes: 75 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,81 @@ pins a CLI that accepts it, `verify` drops a bundle's top-level `trustRegistry`
sees the bundle, and changes nothing else. A test pins that the CLI receives the same document
minus that one key.

## Notebook helpers

Five helpers arrange and render around the CLI. None of them computes one of the format's hashes,
reads the seed, or verifies anything itself.

```python
import io
import pandas as pd
import typedstandards as ts

# Before signing: pin each input, and add the reader's badge and the comparison cell.
content, entry = ts.pin("https://data.example.org/resource/abcd-1234.csv", licence="CC-BY-4.0")
frame = pd.read_csv(io.BytesIO(content))

ts.badge_cell(
"https://records.example.org/bundles/analysis.bundle.json",
capture_method="script-run",
notebook="analysis.ipynb",
)
ts.comparison_cell(
"analysis.ipynb",
{"rows": 1204, "mean_fare": 13.75},
recompute="recompute_key_metrics()",
captured_at="2026-10-03T12:00:00Z",
)

# Sign (record is an envelope input like the one under Use), then serve and show.
signed = ts.sign({**record, "queries": [entry]}, output_file="analysis.ipynb")
bundle = ts.view(signed, visibility="public", title="Example analysis")
ts.sidecar(bundle, "analysis.ipynb") # writes analysis.ipynb.record.yaml
ts.show(bundle) # in Jupyter; ts.show(bundle, marimo=True) in Marimo
```

- **`pin(url, *, licence=None, dataset_id=None, portal_metadata=None, save=None, ...)`** fetches
`url` once and returns `Pinned(content, entry)`: the response body, and a retrieval entry for
the record's `queries[]` with `url`, `sha256` (of the body), `bytes`, `httpStatus` and
`fetchedAt` (ISO 8601 UTC) under `arguments`. A URL shaped like an open-data portal resource
(`…/resource/<id>[.ext]` or `…/api/views/<id>/rows.<ext>`, `<id>` being `xxxx-xxxx`) gets a
second request to `<origin>/api/views/<id>`, and the entry gains the portal's `rowsUpdatedAt`
(epoch seconds) and `datasetId`. A response that is not 2xx raises. `save=` writes the bytes
to that path and the entry to `<path>.pin.json`. The SHA-256 is the one digest the package
computes: a signed assertion in `queries[]` that no check recomputes.
- **`badge_cell(bundle_url, *, capture_method, notebook=None, host=None, marimo=False)`** writes
the verifier badge, linked to `https://typedstandards.org/verify?url=<the bundle URL,
percent-encoded>` as `@typedstandards/host-core` writes it, above a two-row table (the host and
the capture method). With `notebook`, it is inserted as the notebook's first cell (id
`typedstandards-badge` on nbformat 4.5). With `marimo=True`, it returns the source of a
`mo.md(...)` cell to paste into the app. The cell is written before signing and is part of the
signed bytes, so it names no hash and no time; a URL or value holding a 64-hex string, a date or
a time is refused.
- **`comparison_cell(notebook, values, *, recompute, captured_at)`** appends the comparison cell
of spec §8.7.4 as the last cell (id `typedstandards-comparison`): the values as Python literals,
`current = <recompute>`, and a loop that prints each delta. Values are `None`, `bool`, `int`,
finite `float`, `str`, and lists and str-keyed dicts of those; anything else is refused.
- **`sidecar(view, artifact, *, directory=None)`** writes the commitment view as YAML (spec
§8.8.3) beside the artifact: every field `view` printed except the inline `package` and a
served bundle's `trustRegistry`, which are not §8.8.1 fields. The file is named
`<artifact's file name>.record.yaml`, extension kept (`analysis.ipynb.record.yaml`): the spec's
`<artifact-basename>` does not say whether the extension stays, and keeping it is the POSIX
basename and cannot collide when two artifacts share a stem (`analysis.ipynb` beside
`analysis.py`).
- **`show(record, result=None, *, role_path=("role",), marimo=False)`** renders a record (what
`view` or `sign` printed) with its `verify --json` result: the type, the role, the signer,
the hash, `createdAt`, the `vcsRef` (marked as asserted and not fetched), the status with its
reason or successor, one line per check, and a sentence saying that verification does not say
the analysis is correct. Without `result` it runs `verify`. In Jupyter it returns an object
with `_repr_html_`; with `marimo=True`, `mo.Html`. The role is a signed assertion the signer
made, read from the package's `extensions` at `role_path` (by default `extensions["role"]`, a
string or a list of strings); `show` labels it as the signer's and checks nothing about it.

The notebook helpers edit the notebook as JSON, splicing the new cell into `cells` so every
other byte of the file stays as written. `httpx` is imported only inside `pin`, PyYAML only
inside `sidecar`, and `marimo` only inside a Marimo call; `IPython`, `marimo` and `nbformat`
are not dependencies.

## Versions

Each wrapper release pins one CLI version exactly. A CLI upgrade reaches users as a wrapper release
Expand Down
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,11 @@ Homepage = "https://github.com/npstorey/typedstandards-python"
Issues = "https://github.com/npstorey/typedstandards-python/issues"

[dependency-groups]
# marimo and nbformat are development-only: the helpers' tests drive the real implementations
# (mo.md, mo.Html, nbformat's schema and writer). The package never imports either at module load.
dev = [
"marimo>=0.25.1",
"nbformat>=5.11.1",
"pytest>=8.4",
"ruff>=0.14",
]
Expand Down
44 changes: 42 additions & 2 deletions scripts/smoke_wheel.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,18 @@

TYPEDSTANDARDS_SIGNING_SEED_B64="$(openssl rand -base64 32)" <venv>/bin/python scripts/smoke_wheel.py

It checks the import, CLI_VERSION, the vendored CLI's --version, the vendored tree's licences, and
one sign-then-verify round trip (sign, view, verify) through the vendored CLI. It reads no seed.
It checks the import, CLI_VERSION, the vendored CLI's --version, the vendored tree's licences,
one sign-then-verify round trip (sign, view, verify) through the vendored CLI, and the five
helpers with the runtime dependencies the wheel declares (httpx for pin, PyYAML for sidecar),
offline: pin over httpx.MockTransport. It reads no seed.
"""

from __future__ import annotations

import json
import sys
import sysconfig
import tempfile
from pathlib import Path

import typedstandards
Expand Down Expand Up @@ -61,9 +64,46 @@ def main() -> int:
print(f"signed {signed['envelopeHash']}; verify ok={result['ok']} status={result['lifecycle']['status']}")
assert result["ok"] is True
assert result["nodeId"] == signed["envelopeHash"]
helpers(record)
print("smoke check passed")
return 0


def helpers(record: dict) -> None:
import httpx

def portal(request: httpx.Request) -> httpx.Response:
if request.url.path == "/api/views/abcd-1234":
return httpx.Response(200, json={"rowsUpdatedAt": 1790991539})
return httpx.Response(200, content=b"a,b\n1,2\n")

content, entry = typedstandards.pin(
"https://data.example.org/resource/abcd-1234.csv", transport=httpx.MockTransport(portal)
)
assert content == b"a,b\n1,2\n" and entry["arguments"]["rowsUpdatedAt"] == 1790991539, entry

with tempfile.TemporaryDirectory() as tmp:
notebook = Path(tmp) / "analysis.ipynb"
cells = [{"cell_type": "code", "execution_count": None, "id": "a", "metadata": {}, "outputs": [], "source": []}]
document = {"cells": cells, "metadata": {}, "nbformat": 4, "nbformat_minor": 5}
notebook.write_text(json.dumps(document, indent=1) + "\n", encoding="utf-8")
typedstandards.badge_cell(
"https://records.example.org/bundles/analysis.bundle.json", capture_method="script-run", notebook=notebook
)
typedstandards.comparison_cell(
notebook, {"rows": 2}, recompute="recompute()", captured_at="2026-10-03T00:00:00Z"
)
inline = {k: v for k, v in record.items() if k != "output"}
signed = typedstandards.sign({**inline, "queries": [entry]}, output_file=notebook)
bundle = typedstandards.view(signed, visibility="public")
result = typedstandards.verify(bundle)
assert result["ok"] is True
yaml_path = typedstandards.sidecar(bundle, notebook)
assert yaml_path.name == "analysis.ipynb.record.yaml", yaml_path
html = typedstandards.show(bundle, result)._repr_html_()
assert "Signed with a self-certifying key" in html
print("helpers: pin, badge_cell, comparison_cell, sidecar and show ran from the installed wheel")


if __name__ == "__main__":
sys.exit(main())
12 changes: 12 additions & 0 deletions src/typedstandards/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,14 @@

from __future__ import annotations

from ._badge import badge_cell
from ._cli import cli_entry
from ._cli import run as _run
from ._commands import attest, sign, verify, view, withdraw
from ._comparison import comparison_cell
from ._node import NODE_FLOOR, NODE_OVERRIDE, locate_node
from ._show import Shown, show
from ._sidecar import sidecar
from .errors import (
CliError,
CliNotVendoredError,
Expand All @@ -20,6 +24,7 @@
UsageError,
VerificationError,
)
from .pin import Pinned, pin

__version__ = "0.1.0.dev0"

Expand All @@ -40,14 +45,21 @@ def cli_version() -> str:
"CliNotVendoredError",
"InternalError",
"NodeLocatorError",
"Pinned",
"SeedError",
"Shown",
"UsageError",
"VerificationError",
"__version__",
"attest",
"badge_cell",
"cli_entry",
"cli_version",
"comparison_cell",
"locate_node",
"pin",
"show",
"sidecar",
"sign",
"verify",
"view",
Expand Down
155 changes: 155 additions & 0 deletions src/typedstandards/_badge.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
"""``badge_cell``: the verifier badge as a notebook's first cell (Jupyter) or a ``mo.md`` cell (Marimo).

The link and the badge follow ``@typedstandards/host-core``'s ``links.ts`` (typedstandards
``116882a``, ``packages/host-core/src/links.ts``): ``buildVerifyHref`` (:42-48) puts a hosted
bundle URL through ``?url=`` percent-encoded with ``encodeURIComponent``, and
``buildEmbedMarkdown`` (:70-79) writes a linked image whose destination is in angle brackets.
The constants are copied from :15-28. ``tests/test_badge.py`` compares this module's output with
strings captured from host-core's own functions.

The cell is written before the notebook is signed, and under ``raw-bytes/v1`` the notebook's
bytes are the record's output, so the cell can carry only facts known by name before signing:
the badge linked to the bundle URL, the host and the capture method. It never carries a hash or
a time; the verifier shows those from the signed record. Spec §8.8.4 calls such a cell "purely a
reader affordance".
"""

from __future__ import annotations

import os
import re
from urllib.parse import quote, urlsplit

from ._notebook import insert_cell, source_lines

#: host-core links.ts:16, the verifier's canonical origin.
CANONICAL_ORIGIN = "https://typedstandards.org"
#: links.ts:19.
BADGE_ASSET_PATH = "/badge/typed-standards-verify.svg"
#: links.ts:22-23.
BADGE_WIDTH = 248
BADGE_HEIGHT = 30
#: links.ts:28: it describes the action, not a verdict.
BADGE_ALT = "Verify this record with Typed Standards"

#: The characters ``encodeURIComponent`` leaves unescaped besides ASCII letters and digits.
_URI_COMPONENT_SAFE = "-_.!~*'()"

#: The badge cell's id (nbformat 4.5).
BADGE_CELL_ID = "typedstandards-badge"

_HEX64 = re.compile(r"[0-9a-fA-F]{64}")
_DATE_OR_TIME = re.compile(r"\d{4}-\d{2}-\d{2}|\d{2}:\d{2}(?::\d{2})?")
_UNSAFE_IN_TABLE = re.compile(r"[|`\\\"\x00-\x1f\x7f]")


def encode_uri_component(value: str) -> str:
"""JavaScript's ``encodeURIComponent``: UTF-8, every byte escaped but ``A-Z a-z 0-9 - _ . ! ~ * ' ( )``."""
return quote(value, safe=_URI_COMPONENT_SAFE)


def verify_href(bundle_url: str) -> str:
"""The verifier deep link for a hosted bundle: ``https://typedstandards.org/verify?url=<encoded>``."""
url = bundle_url.strip()
if not re.match(r"https?://", url, re.IGNORECASE):
raise ValueError(f"bundle_url must be an http(s) URL of a served bundle, not {bundle_url!r}")
return f"{CANONICAL_ORIGIN}/verify?url={encode_uri_component(url)}"


def badge_markdown(bundle_url: str) -> str:
"""host-core's ``buildEmbedMarkdown`` for a hosted bundle (light theme)."""
return f"[![{BADGE_ALT}]({CANONICAL_ORIGIN}{BADGE_ASSET_PATH})](<{verify_href(bundle_url)}>)"


def _check_fact(name: str, value: str) -> str:
if not isinstance(value, str) or not value.strip():
raise ValueError(f"{name} must be a non-empty string")
if _UNSAFE_IN_TABLE.search(value):
raise ValueError(
f"{name} {value!r} holds a character the cell's table cannot carry (|, `, \\, \" or a control)"
)
return value.strip()


def refuse_hash_or_time(text: str) -> str:
"""Raise ``ValueError`` when ``text`` holds a 64-hex string, a date or a time of day.

The badge cell is part of the signed bytes, written before signing: a hash in it cannot be
the record's own, and a time in it cannot be the signing time, so either would mislead.
"""
if _HEX64.search(text):
raise ValueError(
"the badge cell would carry a 64-hex string; it is written before signing, so it names no hash"
)
if _DATE_OR_TIME.search(text):
raise ValueError("the badge cell would carry a date or time; it is written before signing, so it names no time")
return text


def badge_text(bundle_url: str, *, capture_method: str, host: str | None = None) -> str:
"""The badge cell's Markdown: the badge, a two-row table (host, capture method), one sentence."""
if host is None:
host = urlsplit(bundle_url.strip()).netloc
host = _check_fact("host", host)
capture_method = _check_fact("capture_method", capture_method)
text = (
f"{badge_markdown(bundle_url)}\n"
"\n"
"| Typed Standards record | |\n"
"|---|---|\n"
f"| Host | `{host}` |\n"
f"| Capture method | `{capture_method}` |\n"
"\n"
"This cell is a reader affordance and is not authoritative: verification reads the signed record, "
"not this cell, and the verifier shows the record's signer, hash and time.\n"
)
return refuse_hash_or_time(text)


def _marimo_source(markdown: str) -> str:
"""A Marimo cell's source that renders ``markdown`` with ``mo.md``."""
if '"""' not in markdown and "\\" not in markdown and not markdown.endswith('"'):
return f'mo.md(\n """{markdown}"""\n)\n'
return f"mo.md({markdown!r})\n"


def badge_cell(
bundle_url: str,
*,
capture_method: str,
notebook: str | os.PathLike[str] | None = None,
host: str | None = None,
marimo: bool = False,
cell_id: str = BADGE_CELL_ID,
) -> str:
"""The verifier badge as a notebook cell, written before signing.

``bundle_url`` is where the record's bundle will be served (for example
``https://<host>/bundles/<name>.bundle.json``). ``capture_method`` is the record's
``captureMethod`` (for example ``script-run``). ``host`` defaults to the bundle URL's host.

Jupyter (the default): with ``notebook``, the cell is inserted as the notebook's first cell,
a markdown cell with id ``cell_id`` on nbformat 4.5 or later, and every other byte of the
file is kept. Returns the cell's Markdown. An existing cell with the same id is refused, so
a second call does not add a second badge.

Marimo (``marimo=True``): returns the source of a cell that renders the same Markdown with
``mo.md``, to paste into the app. ``notebook`` is refused, since a Marimo app is Python
source, not a notebook file.

The cell names no hash and no time: a URL or fact holding a 64-hex string, a date or a time
is refused with ``ValueError``.
"""
text = badge_text(bundle_url, capture_method=capture_method, host=host)
if marimo:
if notebook is not None:
raise ValueError("marimo=True returns a cell's source; it does not edit a notebook file")
return _marimo_source(text)
if notebook is not None:
insert_cell(
notebook,
{"cell_type": "markdown", "metadata": {}, "source": source_lines(text)},
where="first",
cell_id=cell_id,
)
return text
Loading
Loading