first commit?
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.egg-info/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
.mypy_cache/
|
||||
vendor/
|
||||
@@ -0,0 +1,11 @@
|
||||
i value honesty and criticism: it helps me learn and improve; i am never annoyed: tell me when im wrong or when i make mistakes; im doing research and need to focus on what's most likely to work, not bullshit; try to be concise, and get important stuff clear; i will ask for more details if i need them; avoid sycophancy at all costs;
|
||||
|
||||
i know python, and used to code in other languages (c, java, typescript, lua, ocaml, rust); i know databases and cloud infrastructure (docker, podman, kubernetes); im using my terminal as much as possible, so im fluent in bash and prefer cli tools over gui;
|
||||
|
||||
im a researcher in maths and ai;
|
||||
|
||||
the rules that survive are the ones that raise quality or make verification cheaper;
|
||||
|
||||
If you're not sure, say so. Don't invent things. Make your uncertainty clear.
|
||||
|
||||
Write the smallest amount of code that solves the real problem. Don’t build extra layers for code that’s used only once. Don’t use clever patterns that make the code harder to maintain later.
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
name: common-tooling
|
||||
description: CLI tools that should be present on the machine and that you should use, read this at the beginning of every session.
|
||||
---
|
||||
|
||||
## Tools
|
||||
|
||||
Tools that should be present on your machine:
|
||||
|
||||
- Search and edit: `rg`, `fd`, `sd`, `ast-grep`, `tree`, `difftastic`
|
||||
- Structured data: `dasel` (yaml, toml, json, xml, v3 syntax: `-i <fmt> -o <fmt>` , not the `-f / -r / -w` of v1 that most online examples show), `jq` (json only), `yq` (kislyuk's jq wrapper: jq syntax on yaml, not mikefarah's Go `yq`), `mlr` (csv, tsv, json records), `sqlite3` (knowledge graph)
|
||||
- HTTP, network: `xh` (prefer that to `curl`), `monolith` (freezing webpage into self-contained HTML), `websocat`, `grpcurl`
|
||||
- Running things: `watchexec`, `just`, `pre-commit`, `hyperfine` (timing)
|
||||
- Quality gates: `shellcheck` (use with every shell script), `shfmt`, `eslint`, `tokei`
|
||||
- System and debugging: `lsof`, `strace`, `procs` (instead of `ps`), `btm` (better `top`), `dust` (what is eating this directory), `duf` -> use `dust` and `duf` when managing heavy artifacts
|
||||
- Papers and bibtex export: `papis`
|
||||
|
||||
The list lives in `scripts/tools.tsv`. Run `scripts/verify-tools.sh -q` to check they are present, and alert the user if not; `scripts/install-tools.sh` installs the missing ones (pacman or apt, then cargo-binstall / GitHub releases / uv for the rest, needs sudo), so ask before running it.
|
||||
|
||||
## Feedbacks
|
||||
|
||||
Improvement needs feedback, and yours is very important. If anything related to skills, project management, or general work with the user goes wrong (or clearly works), append an entry to `feedbacks.jsonl` at the root of where you're working. Each entry has:
|
||||
|
||||
```
|
||||
at: ISO-8601 local timestamp.
|
||||
kind: "bug", "friction", "idea", "improvement", "worked"
|
||||
text: The note itself, stripped.
|
||||
about: Which skill, command or convention it concerns; `""` if unsaid.
|
||||
repo: Working directory it was written from: feedbacks will be merged automatically, write the name of the project so we know the origin after the merge; don't worry too much about possible conflicts;
|
||||
```
|
||||
|
||||
## Markdown
|
||||
|
||||
Do not wrap lines manually please. Neovim handles the wrapping and adapts to the width of the window.
|
||||
|
||||
## General coding
|
||||
|
||||
**Rule of three.** Promote a function to shared only when a _third_ project would copy it unchanged. Until then, copy-paste: duplication is cheaper than the wrong abstraction.
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env bash
|
||||
# Install every tool in tools.tsv that is not already on PATH.
|
||||
#
|
||||
# Order per tool: the distro package (pacman, or apt when apt has a candidate),
|
||||
# then the fallback column: cargo-binstall for Rust tools (prebuilt binaries,
|
||||
# no compiling), a GitHub release for uv and the Go tools, `uv tool` or
|
||||
# `npm -g` for the rest. No `curl | sh`: every download is a release asset.
|
||||
# User-level installs land in ~/.local/bin (binstall's own in ~/.cargo/bin).
|
||||
#
|
||||
# Usage: install-tools.sh [--dry-run]
|
||||
# Arch, Debian (trixie; bookworm lacks more and falls back more), Ubuntu 24.04.
|
||||
# Needs sudo for the distro half unless run as root.
|
||||
set -euo pipefail
|
||||
|
||||
here=$(cd -- "$(dirname -- "$(readlink -f -- "$0")")" && pwd)
|
||||
dry=false
|
||||
[ "${1:-}" = "--dry-run" ] && dry=true
|
||||
|
||||
run() {
|
||||
printf ' + %s\n' "$*"
|
||||
$dry || "$@"
|
||||
}
|
||||
|
||||
sudo=()
|
||||
[ "$(id -u)" -eq 0 ] || sudo=(sudo)
|
||||
|
||||
if command -v pacman >/dev/null; then
|
||||
distro=pacman
|
||||
elif command -v apt-get >/dev/null; then
|
||||
distro=apt
|
||||
else
|
||||
echo "neither pacman nor apt-get found; only fallbacks will be tried" >&2
|
||||
distro=none
|
||||
fi
|
||||
|
||||
# --- 1. Distro packages, one transaction --------------------------------------
|
||||
missing_pkgs=() # parallel arrays: package name, and the binary it provides
|
||||
missing_bins=()
|
||||
while read -r bin _ pacman apt _; do
|
||||
case $bin in '' | '#'*) continue ;; esac
|
||||
command -v -- "$bin" >/dev/null && continue
|
||||
case $distro in
|
||||
pacman) pkg=$pacman ;;
|
||||
apt) pkg=$apt ;;
|
||||
*) pkg=- ;;
|
||||
esac
|
||||
[ "$pkg" = - ] && continue
|
||||
missing_pkgs+=("$pkg")
|
||||
missing_bins+=("$bin")
|
||||
done <"$here/tools.tsv"
|
||||
|
||||
if [ "$distro" = apt ]; then
|
||||
run "${sudo[@]}" apt-get update -qq
|
||||
# Keep only what this release actually has: trixie has xh/tokei/procs,
|
||||
# bookworm and noble do not, and one unknown name fails the whole install.
|
||||
pkgs=() bins=()
|
||||
for i in "${!missing_pkgs[@]}"; do
|
||||
if apt-cache policy "${missing_pkgs[i]}" 2>/dev/null | grep -q 'Candidate: [^(]'; then
|
||||
pkgs+=("${missing_pkgs[i]}")
|
||||
bins+=("${missing_bins[i]}")
|
||||
fi
|
||||
done
|
||||
missing_pkgs=("${pkgs[@]}") missing_bins=("${bins[@]}")
|
||||
fi
|
||||
# What the distro step covers is not the fallbacks' business, even in a dry run
|
||||
# where nothing actually got installed.
|
||||
covered=" ${missing_bins[*]} "
|
||||
|
||||
if [ "${#missing_pkgs[@]}" -gt 0 ]; then
|
||||
echo "distro packages:"
|
||||
case $distro in
|
||||
pacman) run "${sudo[@]}" pacman -S --needed --noconfirm "${missing_pkgs[@]}" ;;
|
||||
apt) run "${sudo[@]}" env DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "${missing_pkgs[@]}" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
# Debian renames fd's binary to fdfind (a name clash with an old package).
|
||||
if ! command -v fd >/dev/null && command -v fdfind >/dev/null; then
|
||||
run ln -sf "$(command -v fdfind)" "$HOME/.local/bin/fd"
|
||||
fi
|
||||
|
||||
# --- 2. Fallbacks, per tool ---------------------------------------------------
|
||||
# Release assets name the architecture three ways.
|
||||
case $(uname -m) in
|
||||
x86_64) arch_rust=x86_64 arch_go=amd64 arch_tar=x86_64 ;;
|
||||
aarch64 | arm64) arch_rust=aarch64 arch_go=arm64 arch_tar=arm64 ;;
|
||||
*) arch_rust=unknown arch_go=unknown arch_tar=unknown ;;
|
||||
esac
|
||||
latest() { printf 'https://github.com/%s/releases/latest/download/%s' "$1" "$2"; }
|
||||
|
||||
# Installed, or planned earlier in this run (which is all a dry run has).
|
||||
have() {
|
||||
command -v -- "$1" >/dev/null && return
|
||||
case $covered in *" $1 "*) return 0 ;; esac
|
||||
return 1
|
||||
}
|
||||
|
||||
ensure_binstall() {
|
||||
have cargo-binstall && return
|
||||
covered="$covered cargo-binstall "
|
||||
echo "cargo-binstall (fetches prebuilt Rust binaries):"
|
||||
# musl: static, so it runs whatever glibc the machine has.
|
||||
run bash -c "curl -fsSL $(latest cargo-bins/cargo-binstall "cargo-binstall-$arch_rust-unknown-linux-musl.tgz") | tar -xz -C $HOME/.local/bin cargo-binstall"
|
||||
}
|
||||
|
||||
gh_install() {
|
||||
case $1 in
|
||||
uv) # the tarball holds uv-<target>/{uv,uvx}
|
||||
run bash -c "curl -fsSL $(latest astral-sh/uv "uv-$arch_rust-unknown-linux-gnu.tar.gz") | tar -xz --strip-components=1 -C $HOME/.local/bin"
|
||||
;;
|
||||
dasel)
|
||||
run curl -fsSL -o "$HOME/.local/bin/dasel" "$(latest TomWright/dasel "dasel_linux_$arch_go")"
|
||||
run chmod +x "$HOME/.local/bin/dasel"
|
||||
;;
|
||||
grpcurl) # the asset name embeds the version, so ask for it
|
||||
local tag=LATEST
|
||||
$dry || tag=$(curl -fsSL https://api.github.com/repos/fullstorydev/grpcurl/releases/latest |
|
||||
sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p')
|
||||
run bash -c "curl -fsSL https://github.com/fullstorydev/grpcurl/releases/download/v$tag/grpcurl_${tag}_linux_$arch_tar.tar.gz | tar -xz -C $HOME/.local/bin grpcurl"
|
||||
;;
|
||||
*) echo " no GitHub recipe for $1" >&2 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
login_path=$PATH
|
||||
export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$PATH"
|
||||
while read -r bin need _ _ fallback; do
|
||||
case $bin in '' | '#'*) continue ;; esac
|
||||
have "$bin" && continue
|
||||
[ "$fallback" = - ] && {
|
||||
echo " $bin: no fallback; install it by hand" >&2
|
||||
continue
|
||||
}
|
||||
echo "$bin (${fallback%%:*}):"
|
||||
covered="$covered$bin "
|
||||
case $fallback in
|
||||
binstall:*)
|
||||
ensure_binstall
|
||||
run cargo-binstall -y --quiet "${fallback#binstall:}"
|
||||
;;
|
||||
gh:*) gh_install "${fallback#gh:}" ;;
|
||||
uv:*)
|
||||
if have uv; then
|
||||
run uv tool install --quiet "${fallback#uv:}"
|
||||
else
|
||||
echo " needs uv, which failed to install above" >&2
|
||||
fi
|
||||
;;
|
||||
npm:*)
|
||||
if have npm; then
|
||||
run "${sudo[@]}" npm install -g --silent "${fallback#npm:}"
|
||||
else
|
||||
echo " $bin is $need and needs npm; skipped" >&2
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done <"$here/tools.tsv"
|
||||
|
||||
$dry && exit 0
|
||||
# The verify below sees this script's PATH; a login shell may not.
|
||||
for dir in "$HOME/.local/bin" "$HOME/.cargo/bin"; do
|
||||
case ":$login_path:" in *":$dir:"*) ;; *) echo "NOTE: add $dir to PATH in your shell rc" ;; esac
|
||||
done
|
||||
echo "verifying:"
|
||||
exec "$here/verify-tools.sh" -q
|
||||
@@ -0,0 +1,52 @@
|
||||
# The one list both install-tools.sh and verify-tools.sh read. Columns, tab or
|
||||
# space separated; `-` means "not from here":
|
||||
#
|
||||
# bin the command verify-tools.sh looks for on PATH
|
||||
# need core (verify fails without it) | opt (verify only warns)
|
||||
# pacman Arch package, official repos only (AUR-only tools use the fallback)
|
||||
# apt Debian/Ubuntu package, used only when apt has a candidate for it
|
||||
# fallback binstall:<crate> | gh:<name> | uv:<pkg> | npm:<pkg>
|
||||
#
|
||||
# Deliberate `-` in the apt column, checked against the trixie/bookworm/noble
|
||||
# indexes on 2026-09-25:
|
||||
# dasel apt ships v2; the skill documents v3 syntax
|
||||
# eslint apt ships 6.4 (2019)
|
||||
# typst is in no Debian release (sources.debian.org, 2026-10-10); pyroli
|
||||
# renders `math: typst` atoms with it
|
||||
# uv is in no apt release; first row, because the uv: fallbacks need it
|
||||
# yq is kislyuk's jq wrapper everywhere (pacman `yq`, apt `yq`, PyPI `yq`),
|
||||
# not mikefarah's Go yq (`go-yq`); jq syntax, and it needs jq
|
||||
#
|
||||
# bin need pacman apt fallback
|
||||
uv core uv - gh:uv
|
||||
rg core ripgrep ripgrep binstall:ripgrep
|
||||
fd core fd fd-find binstall:fd-find
|
||||
sd core sd sd binstall:sd
|
||||
ast-grep core ast-grep - binstall:ast-grep
|
||||
tree core tree tree -
|
||||
difft core difftastic - binstall:difftastic
|
||||
typst core typst - binstall:typst-cli
|
||||
dasel core - - gh:dasel
|
||||
jq core jq jq -
|
||||
yq core yq yq uv:yq
|
||||
mlr core miller miller -
|
||||
sqlite3 core sqlite sqlite3 -
|
||||
xh core xh xh binstall:xh
|
||||
monolith core monolith - binstall:monolith
|
||||
websocat core websocat - binstall:websocat
|
||||
grpcurl core - - gh:grpcurl
|
||||
watchexec core watchexec - binstall:watchexec-cli
|
||||
just core just just binstall:just
|
||||
pre-commit core pre-commit pre-commit uv:pre-commit
|
||||
hyperfine core hyperfine hyperfine binstall:hyperfine
|
||||
shellcheck core shellcheck shellcheck -
|
||||
shfmt core shfmt shfmt -
|
||||
eslint opt eslint - npm:eslint
|
||||
tokei core tokei tokei binstall:tokei
|
||||
lsof core lsof lsof -
|
||||
strace core strace strace -
|
||||
procs core procs procs binstall:procs
|
||||
btm core bottom - binstall:bottom
|
||||
dust core dust du-dust binstall:du-dust
|
||||
duf core duf duf -
|
||||
papis core - - uv:papis
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 30.
|
+32
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
# Report which tools from tools.tsv are on PATH. Exit 1 if a core one is missing.
|
||||
#
|
||||
# Usage: verify-tools.sh [-q] -q prints only what is missing
|
||||
set -euo pipefail
|
||||
|
||||
here=$(cd -- "$(dirname -- "$(readlink -f -- "$0")")" && pwd)
|
||||
quiet=false
|
||||
[ "${1:-}" = "-q" ] && quiet=true
|
||||
|
||||
status=0
|
||||
while read -r bin need _; do
|
||||
case $bin in '' | '#'*) continue ;; esac
|
||||
if command -v -- "$bin" >/dev/null; then
|
||||
$quiet || printf ' ok %s\n' "$bin"
|
||||
elif [ "$need" = core ]; then
|
||||
printf ' MISSING %s\n' "$bin"
|
||||
status=1
|
||||
else
|
||||
printf ' missing %s (optional)\n' "$bin"
|
||||
fi
|
||||
done <"$here/tools.tsv"
|
||||
|
||||
# Present is not enough for the one tool whose major versions disagree on syntax.
|
||||
if command -v dasel >/dev/null && ! dasel version 2>/dev/null | grep -q '^v\?3\.'; then
|
||||
printf ' WRONG dasel is not v3 (%s); the skill documents v3 syntax\n' \
|
||||
"$(dasel --version 2>/dev/null || dasel version 2>/dev/null)"
|
||||
status=1
|
||||
fi
|
||||
|
||||
[ "$status" = 0 ] || printf 'install the missing ones with %s/install-tools.sh\n' "$here"
|
||||
exit "$status"
|
||||
@@ -0,0 +1,74 @@
|
||||
---
|
||||
name: knowledge-and-memory
|
||||
description: Knowledge and memory system that you should follow at every session, unless explicitly discarded by the user
|
||||
---
|
||||
|
||||
Rationale: Files like `MEMORY.md`, `ARCHITECTURE.md` will rot. Memory is composed of project-wise entries, as well as general entries worth keeping for later projects. Knowledge is relational rather than linear and context is precious.
|
||||
|
||||
Solution: atomic (mostly markdown) files with yaml frontheader and links: only retrieve what you need. Plus, regular sessions will be aimed at cleaning this knowledge database, update or remove atoms, and promote general atoms to a global knowledge database -> you don't need to worry about this process, it's user based.
|
||||
|
||||
## Knowledge atoms
|
||||
|
||||
One file, one thing: `knowledge/<type>/<slug>.md`, YAML frontmatter plus ~40 lines of body. Use more lines only if necessary to describe ONE thing. The directory _is_ the type, the filename stem _is_ the id, links are `[[wikilinks]]`, and the ontology is `knowledge/schema.yaml`. If it is missing, copy the one from the `research` or `swe` skill (`references/schema.yaml`) and edit it from there: the repo's copy is the one in force.
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: The vocabulary-common direction is invisible to the softmax # The title is the claim: it's the thing we'll retrieve with fd, make it understandable, "The moment split - M = v v^T + Sigma" is a much better title than "Moment split notes".
|
||||
type: gotcha # the directory this file is in; see the schema.yaml
|
||||
status: stable # legal values depend on the type
|
||||
tags: [jlens, centring] # assembly keys, not truth — see below
|
||||
evidence: artifacts/rfm.safetensors # optional: path to the artifact that supports this atom
|
||||
confidence: high # optional: low | medium | high
|
||||
importance: 4 # optional: 1-5, for ordering a work queue
|
||||
math: typst # optional: write the math in Typst syntax, rendered by pyroli with the `typst` binary; default is LaTeX (KaTeX)
|
||||
aliases: [softmax-blind] # optional: former slugs that still resolve, use this if you ever need to change the slug
|
||||
edges: # optional: add CLEAN edges if it makes sense: the whole knowledge depends on qualitative relations. Edges are one-directional; use only relations defined in schema.yaml, never their inverses
|
||||
supports: [rfm-is-layer-local]
|
||||
depends_on: [h3-mean-covariance-split]
|
||||
---
|
||||
|
||||
**One-sentence claim, bolded.** This is the line that gets grepped and the line a report links to.
|
||||
|
||||
Then why. Derivation, mechanism, or measurement. Inline links to [[effective-rank]] and [[haar-random-null]] instead of re-explaining them.
|
||||
|
||||
|
||||
## Examples
|
||||
|
||||
Examples that can be project related: a math definition with examples from this project for instance.
|
||||
```
|
||||
|
||||
Design background for all of this, when a question about the format comes up: `references/sources.md`.
|
||||
|
||||
Tags are assembly keys, not a retrieval index. They are used to pack atoms together.
|
||||
|
||||
Other frontmatter keys are allowed (`verify:`, `provenance:`...): the schema's `requires:` can name them.
|
||||
|
||||
## Rules
|
||||
|
||||
1. **Anything derivable from the code is generated, never written.** A hand-written module table is exactly the one that rots. Invariants are not derivable, which is why they are written down and why they do not. Use bash scripts, and reference scripts / commands inside markdown instead of writing the output.
|
||||
2. Math in atoms is LaTeX (rendered by KaTeX), or Typst when the frontmatter says `math: typst`: follow the atom's flag, never mix the two in one atom. Use ASCII transcription when talking to the user in the terminal.
|
||||
3. Atoms cite sources, sources never link to atoms (we can always go back with the graph)
|
||||
|
||||
## `pyroli`: the wiki and the checks
|
||||
|
||||
`pyroli serve` renders `knowledge/` as a linked wiki (KaTeX, backlinks, full-text search) with a margin: the user selects text and annotates it, and notes append to `annotations.jsonl`, never to an atom. Read the margin notes on an atom before editing it. Install or update with `scripts/install-pyroli.sh`.
|
||||
|
||||
`pyroli check` runs, and must pass before committing atoms:
|
||||
|
||||
1. every `[[link]]` and edge target resolves, aliases included — **including links in `reports/*.py` and `experiments/*.py`**, which is where a rename actually hurts
|
||||
2. type declared, status legal for it, required fields present
|
||||
3. every edge is a declared relation and respects its domain and range
|
||||
4. the file sits in the directory its type names
|
||||
5. every `evidence:` script exists **in git**, not merely on disk
|
||||
6. no settled atom `depends_on` a dead one
|
||||
7. an atom whose evidence script was **committed** later than it → warn
|
||||
|
||||
Checks 2, 3 and 6 need `knowledge/schema.yaml`; without it, `check` says so and skips them.
|
||||
|
||||
## Repository structure when a knowledge/ folder is present
|
||||
|
||||
Templates for `AGENTS.md` and `CLAUDE.md` are in `references/templates/`. Use `README.md` for a general presentation, quickstart, workflows, non-goals, mostly user-facing. Use `AGENTS.md` for how to work in this repo, skill used, deltas from the method, update at every session if needed. Link to knowledge atoms, never rewrite. Add `CLAUDE.md` and redirect it to `AGENTS.md`. Keep `AGENTS.md` short: it should state things that aren't already elsewhere or link them. It should have the current state of the project.
|
||||
|
||||
You can have other .md files when necessary: a global `PLAN.md`, a global `TODO.md`, or a `CURRENT.md` to keep current state during compacts. When needed, use nested markdown files, for instance, you can have a global `AGENTS.md` and one `AGENTS.md` per sub folder.
|
||||
|
||||
No `SPECS.md`, no `STRUCTURE.md`
|
||||
@@ -0,0 +1,56 @@
|
||||
[project]
|
||||
name = "pyroli"
|
||||
version = "0.8.0"
|
||||
description = "Serve a knowledge/ folder as a linked, math-rendering (LaTeX or Typst) wiki with a margin, and check its links."
|
||||
requires-python = ">=3.12"
|
||||
authors = [{ name = "Sckathach", email = "thomas.winninger@telecom-sudparis.eu" }]
|
||||
dependencies = [
|
||||
"markdown-it-py>=4.2.0",
|
||||
"mdit-py-plugins>=0.6.1",
|
||||
"pyyaml>=6.0",
|
||||
"rich>=13.0",
|
||||
"starlette>=1.3.1",
|
||||
"typer>=0.12",
|
||||
"uvicorn>=0.52.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
pyroli = "pyroli.cli:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["uv_build>=0.12.0,<0.13.0"]
|
||||
build-backend = "uv_build"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
"ruff>=0.6",
|
||||
"pyrefly>=0.16",
|
||||
"httpx2>=2.13.1",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 88
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["ALL"]
|
||||
ignore = [
|
||||
"COM812", # conflicts with the formatter
|
||||
"D203", # incompatible with D211
|
||||
"D213", # incompatible with D212
|
||||
"CPY001", # no copyright headers
|
||||
# Malformed *input* raises ValueError here, including bad shapes: one
|
||||
# exception type keeps `except ValueError` sufficient at the CLI.
|
||||
"TRY004",
|
||||
]
|
||||
|
||||
[tool.ruff.lint.pydocstyle]
|
||||
convention = "google"
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"tests/*" = ["D", "S101", "PLR2004", "ANN", "INP001", "DTZ001"]
|
||||
# typer's API is boolean options with positional defaults.
|
||||
"src/pyroli/cli.py" = ["FBT001", "FBT002"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
@@ -0,0 +1,27 @@
|
||||
"""pyroli — serve a `knowledge/` folder as a wiki, and check its links.
|
||||
|
||||
Two commands: `serve` (the wiki, KaTeX, the annotation margin) and `check`
|
||||
(links, types, statuses, edges, evidence). The `knowledge-and-memory` skill in
|
||||
this repo, under `skills/`, is the format they read.
|
||||
|
||||
Importable for one thing: rendering `[[slug]]` citations as atom titles, which
|
||||
reports would otherwise each reimplement.
|
||||
|
||||
from pyroli import link_titles, load_atoms
|
||||
|
||||
atoms = load_atoms(REPO / "knowledge")
|
||||
def md(text: str):
|
||||
return mo.md(link_titles(text, atoms))
|
||||
"""
|
||||
|
||||
from importlib.metadata import version
|
||||
|
||||
from pyroli.atoms import Atom, link_titles
|
||||
from pyroli.atoms import load as load_atoms
|
||||
|
||||
# Read from the installed distribution, never hardcoded: a string in two places
|
||||
# is a string that disagrees with itself, and the number's only job is to tell
|
||||
# you which build you are looking at.
|
||||
__version__ = version("pyroli")
|
||||
|
||||
__all__ = ["Atom", "__version__", "link_titles", "load_atoms"]
|
||||
@@ -0,0 +1,238 @@
|
||||
"""Margin notes on atoms: an append-only sidecar keyed by quoted text.
|
||||
|
||||
Pure: JSONL text in, records out. Reading and appending the file is the
|
||||
caller's job — `server.py` for the browser, `cli.py` for the terminal.
|
||||
|
||||
An annotation never touches the atom it is about. That is the whole design:
|
||||
the atom stays authored, in one editor, by one person, and the margin
|
||||
accretes reactions that are refactored back into prose later — C2's
|
||||
ThreadMode and DocumentMode, kept in separate files instead of separate
|
||||
sections. Which means the wiki can be writable without `pyroli serve`
|
||||
becoming an editor.
|
||||
|
||||
Anchoring is by quoted text, not by offset, because an offset into a file
|
||||
under active edit is wrong within the hour. When the quote stops matching,
|
||||
the annotation is an **orphan** — and the orphan list is not an error state,
|
||||
it is the revision backlog: every entry is a passage you had something to say
|
||||
about and have since rewritten.
|
||||
|
||||
Invariants:
|
||||
- Append-only, one JSON object per line. Nothing rewrites a line. A note
|
||||
that no longer applies is orphaned; a note the reader removes gets a
|
||||
second line, `{"id": ..., "deleted": <time>}`, so the file still holds
|
||||
what was said and when it was withdrawn. Ids are never reused.
|
||||
- `parse` raises on a malformed line, naming the line number. A store that
|
||||
silently skips what it cannot read is a store that loses notes.
|
||||
- Matching uses `exact` alone. `prefix` and `suffix` are recorded because
|
||||
the W3C selector shape is what makes this file outlive this tool, but
|
||||
nothing here reads them yet: at a 40-line body cap a repeated quote is
|
||||
rare, and a heuristic nobody has needed is a heuristic nobody can debug.
|
||||
|
||||
Conventions:
|
||||
- Ids are `ANN-0001`, allocated by counting what is already in the file.
|
||||
Sequential because an annotation gets referred to out loud.
|
||||
"""
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import UTC, datetime
|
||||
|
||||
FILENAME = "annotations.jsonl"
|
||||
CONTEXT = 48
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True, slots=True)
|
||||
class Selector:
|
||||
"""Where in an atom a note points, as a W3C `TextQuoteSelector`.
|
||||
|
||||
Attributes:
|
||||
exact: The quoted passage, verbatim. The anchor.
|
||||
prefix: Up to `CONTEXT` characters before it, for re-anchoring.
|
||||
suffix: Up to `CONTEXT` characters after it.
|
||||
"""
|
||||
|
||||
exact: str
|
||||
prefix: str = ""
|
||||
suffix: str = ""
|
||||
|
||||
def anchored_in(self, text: str) -> bool:
|
||||
"""Whether the quoted passage is still present in a body."""
|
||||
return bool(self.exact) and self.exact in text
|
||||
|
||||
def trimmed(self) -> "Selector":
|
||||
"""The same selector with its context clipped to `CONTEXT`."""
|
||||
return Selector(
|
||||
exact=self.exact,
|
||||
prefix=self.prefix[-CONTEXT:],
|
||||
suffix=self.suffix[:CONTEXT],
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True, slots=True)
|
||||
class Annotation:
|
||||
"""One margin note on one passage of one atom.
|
||||
|
||||
Attributes:
|
||||
id: `ANN-0001`, stable and never reused.
|
||||
slug: The atom this is about.
|
||||
selector: The passage it anchors to.
|
||||
body: What you had to say.
|
||||
created: ISO-8601 UTC.
|
||||
deleted: ISO-8601 UTC when the reader removed it, else None.
|
||||
"""
|
||||
|
||||
id: str
|
||||
slug: str
|
||||
selector: Selector
|
||||
body: str
|
||||
created: str
|
||||
deleted: str | None = None
|
||||
|
||||
@property
|
||||
def exact(self) -> str:
|
||||
"""The quoted passage; the field every renderer wants."""
|
||||
return self.selector.exact
|
||||
|
||||
|
||||
def parse(text: str) -> tuple[Annotation, ...]:
|
||||
"""Read a JSONL store: every note ever written, deleted ones marked.
|
||||
|
||||
Raises:
|
||||
ValueError: If a line is not a JSON object with the expected keys, or
|
||||
deletes an id that no earlier line created.
|
||||
"""
|
||||
found: list[Annotation] = []
|
||||
for number, line in enumerate(text.splitlines(), start=1):
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
record = json.loads(line)
|
||||
except json.JSONDecodeError as exc:
|
||||
msg = f"{FILENAME}:{number}: not valid JSON ({exc.msg})"
|
||||
raise ValueError(msg) from exc
|
||||
if not isinstance(record, dict):
|
||||
msg = f"{FILENAME}:{number}: not a JSON object"
|
||||
raise ValueError(msg)
|
||||
if "deleted" in record:
|
||||
target = str(record.get("id"))
|
||||
at = next((i for i, one in enumerate(found) if one.id == target), None)
|
||||
if at is None:
|
||||
msg = f"{FILENAME}:{number}: deletes {target}, which no line created"
|
||||
raise ValueError(msg)
|
||||
found[at] = replace(found[at], deleted=str(record["deleted"]))
|
||||
continue
|
||||
selector = record.get("selector") or {}
|
||||
if not isinstance(selector, dict):
|
||||
msg = f"{FILENAME}:{number}: `selector` is not an object"
|
||||
raise ValueError(msg)
|
||||
try:
|
||||
found.append(
|
||||
Annotation(
|
||||
id=str(record["id"]),
|
||||
slug=str(record["slug"]),
|
||||
selector=Selector(
|
||||
exact=str(selector["exact"]),
|
||||
prefix=str(selector.get("prefix", "")),
|
||||
suffix=str(selector.get("suffix", "")),
|
||||
),
|
||||
body=str(record["body"]),
|
||||
created=str(record["created"]),
|
||||
)
|
||||
)
|
||||
except KeyError as exc:
|
||||
msg = f"{FILENAME}:{number}: missing key {exc.args[0]}"
|
||||
raise ValueError(msg) from exc
|
||||
return tuple(found)
|
||||
|
||||
|
||||
def serialize(annotation: Annotation) -> str:
|
||||
"""One annotation as one JSONL line, newline included."""
|
||||
return (
|
||||
json.dumps(
|
||||
{
|
||||
"id": annotation.id,
|
||||
"slug": annotation.slug,
|
||||
"selector": {
|
||||
"prefix": annotation.selector.prefix,
|
||||
"exact": annotation.selector.exact,
|
||||
"suffix": annotation.selector.suffix,
|
||||
},
|
||||
"body": annotation.body,
|
||||
"created": annotation.created,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
|
||||
|
||||
def serialize_deletion(annotation_id: str, *, now: datetime | None = None) -> str:
|
||||
"""The line that withdraws one annotation, newline included."""
|
||||
when = (now or datetime.now(UTC)).isoformat(timespec="seconds")
|
||||
return json.dumps({"id": annotation_id, "deleted": when}) + "\n"
|
||||
|
||||
|
||||
def live(annotations: tuple[Annotation, ...]) -> tuple[Annotation, ...]:
|
||||
"""The annotations nobody has deleted."""
|
||||
return tuple(one for one in annotations if one.deleted is None)
|
||||
|
||||
|
||||
def create(
|
||||
existing: tuple[Annotation, ...],
|
||||
*,
|
||||
slug: str,
|
||||
selector: Selector,
|
||||
body: str,
|
||||
now: datetime | None = None,
|
||||
) -> Annotation:
|
||||
"""Build the next annotation, allocating an id after the ones already there.
|
||||
|
||||
Raises:
|
||||
ValueError: If the quote or the note is empty. An annotation with no
|
||||
anchor is orphaned the moment it is written, and one with no body
|
||||
is a highlight — which the atom's own bolded claim already is.
|
||||
"""
|
||||
if not selector.exact.strip():
|
||||
msg = "an annotation needs a quoted passage to anchor to"
|
||||
raise ValueError(msg)
|
||||
if not body.strip():
|
||||
msg = "an annotation needs something to say"
|
||||
raise ValueError(msg)
|
||||
return Annotation(
|
||||
id=f"ANN-{_next_number(existing):04d}",
|
||||
slug=slug,
|
||||
selector=selector.trimmed(),
|
||||
body=body.strip(),
|
||||
created=(now or datetime.now(UTC)).isoformat(timespec="seconds"),
|
||||
)
|
||||
|
||||
|
||||
def _next_number(existing: tuple[Annotation, ...]) -> int:
|
||||
numbers = [
|
||||
int(one.id.removeprefix("ANN-"))
|
||||
for one in existing
|
||||
if one.id.startswith("ANN-") and one.id.removeprefix("ANN-").isdigit()
|
||||
]
|
||||
return max(numbers, default=0) + 1
|
||||
|
||||
|
||||
def on(annotations: tuple[Annotation, ...], slug: str) -> tuple[Annotation, ...]:
|
||||
"""Every live annotation about one atom, oldest first."""
|
||||
return tuple(one for one in live(annotations) if one.slug == slug)
|
||||
|
||||
|
||||
def orphans(
|
||||
annotations: tuple[Annotation, ...],
|
||||
bodies: dict[str, str],
|
||||
) -> tuple[Annotation, ...]:
|
||||
"""Annotations whose quote no longer appears in the atom they are about.
|
||||
|
||||
Also orphaned: an annotation on an atom that no longer exists. Both mean
|
||||
the same thing to a reader — something was said about text that is gone —
|
||||
and both belong in the same queue.
|
||||
"""
|
||||
return tuple(
|
||||
one
|
||||
for one in live(annotations)
|
||||
if one.slug not in bodies or not one.selector.anchored_in(bodies[one.slug])
|
||||
)
|
||||
@@ -0,0 +1,296 @@
|
||||
"""Parse knowledge atoms and resolve the `[[wikilinks]]` that cite them.
|
||||
|
||||
An atom is one markdown file under the knowledge directory: YAML frontmatter
|
||||
plus a body. The filename stem is the id, and the containing directory is the
|
||||
node type — so `ls knowledge/` prints the ontology without running anything.
|
||||
|
||||
The vocabulary this parses against lives in `schema.py`; nothing here knows
|
||||
which types exist.
|
||||
|
||||
Invariants:
|
||||
- `parse` raises on malformed frontmatter rather than returning a partial
|
||||
atom: an unparseable atom is a bug in the file, not a check failure.
|
||||
- `link_titles` raises on a link with no atom, which is what makes a
|
||||
citation a check.
|
||||
- An `alias` resolves like a slug everywhere links are followed. Renaming
|
||||
is therefore a redirect, not a broken graph — which is why identity can
|
||||
stay a readable slug instead of an opaque id.
|
||||
|
||||
Conventions:
|
||||
- Slugs are lowercase, digits and hyphens; `WIKILINK` is the one regex
|
||||
that defines that, and reports and experiments are matched with it too.
|
||||
- A `[[slug]]` inside backticks is not a citation. `cited` and `rewrite`
|
||||
are the two functions that know this; anything scanning bodies with the
|
||||
bare `WIKILINK` regex will disagree with `pyroli check`.
|
||||
"""
|
||||
|
||||
import re
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
WIKILINK = re.compile(r"\[\[([a-z0-9\-]+)\]\]")
|
||||
FRONTMATTER = re.compile(r"\A---\n(.*?)\n---\n(.*)\Z", re.DOTALL)
|
||||
|
||||
# A wikilink, unless it is inside code — where it is a *mention* of the syntax,
|
||||
# not a citation. Writing about the link format is what an atom in a repo about
|
||||
# the link format does constantly, and it had no escape before this.
|
||||
_CODE = r"```.*?```|``[^`]*``|`[^`\n]*`"
|
||||
CITATION = re.compile(rf"(?P<code>{_CODE})|\[\[(?P<slug>[a-z0-9\-]+)\]\]", re.DOTALL)
|
||||
|
||||
|
||||
def cited(text: str) -> frozenset[str]:
|
||||
"""Slugs cited by a body, ignoring any `[[slug]]` inside code."""
|
||||
return frozenset(
|
||||
match.group("slug") for match in CITATION.finditer(text) if match.group("slug")
|
||||
)
|
||||
|
||||
|
||||
def rewrite(text: str, replace: "Callable[[str], str]") -> str:
|
||||
"""Rewrite each cited slug, leaving code spans and fences untouched."""
|
||||
|
||||
def one(match: re.Match[str]) -> str:
|
||||
slug = match.group("slug")
|
||||
return match.group(0) if slug is None else replace(slug)
|
||||
|
||||
return CITATION.sub(one, text)
|
||||
|
||||
|
||||
CONFIDENCE = ("low", "medium", "high")
|
||||
IMPORTANCE = range(1, 6)
|
||||
|
||||
# The keys this module gives meaning to. Any other key is kept verbatim in
|
||||
# `Atom.extra`: frontmatter is open-world, because a repo that needs a field
|
||||
# (`verify:`, `provenance:`) must not have to patch the tool to add it. The
|
||||
# schema's `requires:` can name any key, known or not.
|
||||
_KNOWN_KEYS = frozenset(
|
||||
{
|
||||
"title",
|
||||
"type",
|
||||
"status",
|
||||
"tags",
|
||||
"aliases",
|
||||
"confidence",
|
||||
"importance",
|
||||
"evidence",
|
||||
"edges",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True, slots=True)
|
||||
class Atom:
|
||||
"""One knowledge file, parsed.
|
||||
|
||||
Attributes:
|
||||
slug: Filename stem; the id used by `[[wikilinks]]` and `edges`.
|
||||
title: Frontmatter title, rendered in place of the slug when cited.
|
||||
type: Node type; must be declared in the schema, and must match the
|
||||
containing directory.
|
||||
status: A status legal for that type, per the schema.
|
||||
tags: Assembly keys, not a retrieval index.
|
||||
aliases: Former slugs that still resolve here. Renaming leaves one.
|
||||
confidence: How much this is believed: `low`, `medium` or `high`.
|
||||
importance: 1-5, gwern-style, for ordering a work queue.
|
||||
evidence: Repo-relative script and artifact paths backing a claim.
|
||||
edges: (relation, target slug) pairs, checked against the schema.
|
||||
links: Slugs cited by the body.
|
||||
body: Markdown body, frontmatter stripped.
|
||||
extra: Every frontmatter key this module does not interpret.
|
||||
"""
|
||||
|
||||
slug: str
|
||||
path: Path
|
||||
title: str
|
||||
type: str
|
||||
status: str | None
|
||||
tags: tuple[str, ...]
|
||||
aliases: tuple[str, ...]
|
||||
confidence: str | None
|
||||
importance: int | None
|
||||
evidence: tuple[str, ...]
|
||||
edges: tuple[tuple[str, str], ...]
|
||||
links: frozenset[str]
|
||||
body: str
|
||||
extra: Mapping[str, object]
|
||||
|
||||
def has(self, key: str) -> bool:
|
||||
"""Whether frontmatter carries a non-empty `key`, known or not."""
|
||||
value = self.extra[key] if key in self.extra else getattr(self, key, None)
|
||||
return bool(value)
|
||||
|
||||
|
||||
def parse(path: Path, text: str) -> Atom:
|
||||
"""Parse one knowledge file.
|
||||
|
||||
Raises:
|
||||
ValueError: If the frontmatter is missing, unparseable, or misshapen.
|
||||
"""
|
||||
match = FRONTMATTER.match(text)
|
||||
if match is None:
|
||||
msg = f"{path.name}: missing or malformed YAML frontmatter"
|
||||
raise ValueError(msg)
|
||||
try:
|
||||
meta = yaml.safe_load(match.group(1))
|
||||
except yaml.YAMLError as exc:
|
||||
# A title starting with a backtick is the way this actually happens,
|
||||
# and pyyaml's own error is a 40-line traceback naming no file.
|
||||
msg = f"{path.name}: frontmatter is not valid YAML ({exc.args[-1]})"
|
||||
raise ValueError(msg) from exc
|
||||
if not isinstance(meta, dict):
|
||||
msg = f"{path.name}: frontmatter is not a mapping"
|
||||
raise ValueError(msg)
|
||||
body = match.group(2)
|
||||
|
||||
raw_edges = meta.get("edges") or {}
|
||||
if not isinstance(raw_edges, dict):
|
||||
msg = f"{path.name}: `edges` must be a mapping of relation -> [slug]"
|
||||
raise ValueError(msg)
|
||||
|
||||
importance = meta.get("importance")
|
||||
if importance is not None and not isinstance(importance, int):
|
||||
msg = f"{path.name}: `importance` must be an integer 1-5"
|
||||
raise ValueError(msg)
|
||||
|
||||
return Atom(
|
||||
slug=path.stem,
|
||||
path=path,
|
||||
title=str(meta.get("title", "")),
|
||||
type=str(meta.get("type", "")),
|
||||
status=meta.get("status"),
|
||||
tags=tuple(meta.get("tags") or ()),
|
||||
aliases=tuple(meta.get("aliases") or ()),
|
||||
confidence=meta.get("confidence"),
|
||||
importance=importance,
|
||||
evidence=tuple(meta.get("evidence") or ()),
|
||||
edges=tuple(
|
||||
(relation, target)
|
||||
for relation, targets in raw_edges.items()
|
||||
for target in (targets or ())
|
||||
),
|
||||
links=cited(body),
|
||||
body=body.strip(),
|
||||
extra={key: value for key, value in meta.items() if key not in _KNOWN_KEYS},
|
||||
)
|
||||
|
||||
|
||||
def scan(knowledge_dir: Path) -> tuple[dict[str, Atom], tuple[Path, ...]]:
|
||||
"""Parse every atom under a knowledge directory; also return the strays.
|
||||
|
||||
Walks subdirectories: a flat `knowledge/` and a knowledge base filed by
|
||||
topic are the same thing to every reader here, and the second is what a
|
||||
base outgrowing one directory becomes. Dot-directories are skipped, which
|
||||
is what keeps a `.zk/` template out of the atom set.
|
||||
|
||||
A markdown file with **no frontmatter at all** is a stray, not an atom: a
|
||||
reading note, a list of links, something not digested yet. It is returned
|
||||
separately so `check` can name it, rather than raised on, because one
|
||||
undigested file must not break every command in a base being migrated.
|
||||
Frontmatter that is *present and broken* still raises — that is an atom
|
||||
with a bug in it, which is a different thing.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If the directory does not exist.
|
||||
ValueError: If an atom is malformed, or two files share a stem. The
|
||||
stem is the id `[[links]]` resolve against, so filing the same
|
||||
slug twice is ambiguous.
|
||||
"""
|
||||
if not knowledge_dir.is_dir():
|
||||
msg = f"no knowledge directory at {knowledge_dir}"
|
||||
raise FileNotFoundError(msg)
|
||||
found: dict[str, Atom] = {}
|
||||
strays: list[Path] = []
|
||||
for path in sorted(knowledge_dir.rglob("*.md")):
|
||||
relative = path.relative_to(knowledge_dir)
|
||||
if any(part.startswith(".") for part in relative.parts):
|
||||
continue
|
||||
text = path.read_text(encoding="utf-8")
|
||||
if FRONTMATTER.match(text) is None:
|
||||
strays.append(relative)
|
||||
continue
|
||||
if path.stem in found:
|
||||
first = found[path.stem].path.relative_to(knowledge_dir)
|
||||
msg = f"duplicate slug '{path.stem}': {first} and {relative}"
|
||||
raise ValueError(msg)
|
||||
found[path.stem] = parse(path, text)
|
||||
return found, tuple(strays)
|
||||
|
||||
|
||||
def load(knowledge_dir: Path) -> dict[str, Atom]:
|
||||
"""Every atom under a knowledge directory, keyed by slug. See `scan`."""
|
||||
return scan(knowledge_dir)[0]
|
||||
|
||||
|
||||
def resolvable(atoms: Mapping[str, Atom]) -> Mapping[str, Atom]:
|
||||
"""Every atom keyed by its slug *and* by each of its aliases.
|
||||
|
||||
This is what makes a rename a redirect. The returned mapping is for
|
||||
following links only — iterate `atoms.values()`, never this, or an atom
|
||||
with two aliases counts three times.
|
||||
|
||||
Raises:
|
||||
ValueError: If an alias shadows a real slug or another alias. A link
|
||||
that could go two places is worse than a dead one.
|
||||
"""
|
||||
found = dict(atoms)
|
||||
for atom in atoms.values():
|
||||
for alias in atom.aliases:
|
||||
if alias in found:
|
||||
msg = f"{atom.path.name}: alias '{alias}' is already taken"
|
||||
raise ValueError(msg)
|
||||
found[alias] = atom
|
||||
return found
|
||||
|
||||
|
||||
def link_titles(text: str, atoms: Mapping[str, Atom], base: str = "knowledge") -> str:
|
||||
"""Rewrite each `[[slug]]` to a markdown link carrying the atom's title.
|
||||
|
||||
This is the `md()` helper the reports duplicate. Cite a slug nobody wrote
|
||||
and rendering fails, which is the point. An alias resolves like a slug and
|
||||
renders the target's canonical path.
|
||||
|
||||
Raises:
|
||||
KeyError: If a cited slug has no atom.
|
||||
"""
|
||||
known = resolvable(atoms)
|
||||
|
||||
def replace(slug: str) -> str:
|
||||
atom = known.get(slug)
|
||||
if atom is None:
|
||||
msg = f"[[{slug}]] does not resolve to an atom"
|
||||
raise KeyError(msg)
|
||||
return f"[{atom.title}]({base}/{atom.slug}.md)"
|
||||
|
||||
return rewrite(text, replace)
|
||||
|
||||
|
||||
def select(
|
||||
atoms: Mapping[str, Atom],
|
||||
*,
|
||||
tag: str | None = None,
|
||||
node_type: str | None = None,
|
||||
order: tuple[str, ...] = (),
|
||||
) -> tuple[Atom, ...]:
|
||||
"""Atoms matching every given filter, in schema-declaration order.
|
||||
|
||||
`order` is the schema's node-type order, which puts definitions before
|
||||
claims because that is how the schema is written. Anything not named by it
|
||||
sorts last, so an unknown type is visible rather than silently first.
|
||||
"""
|
||||
chosen = [
|
||||
atom
|
||||
for atom in atoms.values()
|
||||
if (tag is None or tag in atom.tags)
|
||||
and (node_type is None or atom.type == node_type)
|
||||
]
|
||||
return tuple(
|
||||
sorted(
|
||||
chosen,
|
||||
key=lambda a: (
|
||||
order.index(a.type) if a.type in order else len(order),
|
||||
a.slug,
|
||||
),
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,340 @@
|
||||
"""The knowledge-graph checks of the `knowledge-and-memory` skill.
|
||||
|
||||
Pure: atoms and git facts in, `Issue` list out. Collecting those facts is
|
||||
`git.py`'s job and printing them is `views/checks.py`'s.
|
||||
|
||||
Invariants:
|
||||
- Every check returns issues; none raises, none exits. Severity is the
|
||||
only signal, and only `error` is meant to fail a gate.
|
||||
- Checks never touch the filesystem or git; `evidence` and `staleness`
|
||||
take the facts they need as arguments.
|
||||
|
||||
Conventions:
|
||||
- `check` names the rule, `slug` names the atom or the citing source, and
|
||||
`detail` is one line, safe to print in a table.
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from os.path import relpath
|
||||
from pathlib import Path
|
||||
|
||||
from pyroli.atoms import CONFIDENCE, IMPORTANCE, Atom, cited
|
||||
from pyroli.schema import Schema
|
||||
|
||||
Severity = str
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True, slots=True)
|
||||
class Issue:
|
||||
"""One check failure.
|
||||
|
||||
Attributes:
|
||||
severity: `error` (fails the gate) or `warn`.
|
||||
check: Rule name, e.g. `link`, `evidence`, `propagation`.
|
||||
slug: Atom slug, or the repo-relative path of a citing source.
|
||||
detail: One-line explanation.
|
||||
"""
|
||||
|
||||
severity: Severity
|
||||
check: str
|
||||
slug: str
|
||||
detail: str
|
||||
|
||||
|
||||
def links(atoms: Mapping[str, Atom], known: Mapping[str, Atom]) -> list[Issue]:
|
||||
"""Check 1: every `[[link]]` and every edge target resolves to an atom.
|
||||
|
||||
`known` is `atoms.resolvable(atoms)` — slugs plus aliases — so a link to a
|
||||
former name is not a failure.
|
||||
"""
|
||||
issues: list[Issue] = []
|
||||
for atom in atoms.values():
|
||||
issues += [
|
||||
Issue(
|
||||
severity="error",
|
||||
check="link",
|
||||
slug=atom.slug,
|
||||
detail=f"[[{target}]] does not resolve",
|
||||
)
|
||||
for target in sorted(atom.links)
|
||||
if target not in known
|
||||
]
|
||||
issues += [
|
||||
Issue(
|
||||
severity="error",
|
||||
check="link",
|
||||
slug=atom.slug,
|
||||
detail=f"edge {relation} -> {target} does not resolve",
|
||||
)
|
||||
for relation, target in atom.edges
|
||||
if target not in known
|
||||
]
|
||||
return issues
|
||||
|
||||
|
||||
def citations(atoms: Mapping[str, Atom], sources: Mapping[str, str]) -> list[Issue]:
|
||||
"""Check 1b: every `[[link]]` in a citing source resolves.
|
||||
|
||||
A report that links a renamed atom renders a dead link; an experiment
|
||||
docstring that pre-registers against one is worse, because the claim it
|
||||
names is what the run is supposed to move.
|
||||
"""
|
||||
return [
|
||||
Issue(
|
||||
severity="error",
|
||||
check="citation",
|
||||
slug=name,
|
||||
detail=f"[[{target}]] does not resolve",
|
||||
)
|
||||
for name, text in sorted(sources.items())
|
||||
for target in sorted(cited(text))
|
||||
if target not in atoms
|
||||
]
|
||||
|
||||
|
||||
def evidence(
|
||||
atoms: Mapping[str, Atom],
|
||||
repo: Path,
|
||||
tracked: frozenset[str],
|
||||
) -> list[Issue]:
|
||||
"""Check 2: evidence paths exist; scripts are git-tracked, artifacts need not be."""
|
||||
issues: list[Issue] = []
|
||||
for atom in atoms.values():
|
||||
for ref in atom.evidence:
|
||||
if not (repo / ref).exists():
|
||||
issues.append(
|
||||
Issue(
|
||||
severity="error",
|
||||
check="evidence",
|
||||
slug=atom.slug,
|
||||
detail=f"{ref} does not exist",
|
||||
)
|
||||
)
|
||||
elif ref.endswith(".py") and ref not in tracked:
|
||||
issues.append(
|
||||
Issue(
|
||||
severity="error",
|
||||
check="evidence",
|
||||
slug=atom.slug,
|
||||
detail=f"{ref} exists but is not tracked by git",
|
||||
)
|
||||
)
|
||||
return issues
|
||||
|
||||
|
||||
def propagation(atoms: Mapping[str, Atom], schema: Schema) -> list[Issue]:
|
||||
"""Check 3: a settled atom must not `depends_on` a dead one.
|
||||
|
||||
Which statuses count as settled or dead is declared in the schema, not
|
||||
here. A status meaning neither propagates nothing, which is what keeps an
|
||||
`open` claim free to build on a refuted one — that is the work, not a bug.
|
||||
"""
|
||||
classes = schema.status_classes
|
||||
return [
|
||||
Issue(
|
||||
severity="error",
|
||||
check="propagation",
|
||||
slug=atom.slug,
|
||||
detail=(
|
||||
f"{atom.status}, but depends_on {atoms[target].status} [[{target}]]"
|
||||
),
|
||||
)
|
||||
for atom in atoms.values()
|
||||
if classes.get(atom.status or "") == "settled"
|
||||
for relation, target in atom.edges
|
||||
if relation == "depends_on"
|
||||
and target in atoms
|
||||
and classes.get(atoms[target].status or "") == "dead"
|
||||
]
|
||||
|
||||
|
||||
def staleness(
|
||||
atoms: Mapping[str, Atom],
|
||||
committed: Mapping[str, int],
|
||||
repo: Path = Path(),
|
||||
) -> list[Issue]:
|
||||
"""Check 4: an atom whose evidence script was committed later may be stale.
|
||||
|
||||
Uses git commit time, not mtime: `ruff format` touches every file and
|
||||
would otherwise flood this check with false positives.
|
||||
|
||||
`repo` is what turns an atom's path into the key git uses. It is path
|
||||
arithmetic only — `relpath` reads no filesystem — so this stays pure.
|
||||
"""
|
||||
issues: list[Issue] = []
|
||||
for atom in atoms.values():
|
||||
atom_time = committed.get(Path(relpath(atom.path, repo)).as_posix())
|
||||
if atom_time is None: # uncommitted atom: nothing to compare against
|
||||
continue
|
||||
issues += [
|
||||
Issue(
|
||||
severity="warn",
|
||||
check="stale",
|
||||
slug=atom.slug,
|
||||
detail=f"{ref} was committed after this atom — recheck it",
|
||||
)
|
||||
for ref in atom.evidence
|
||||
if ref.endswith(".py") and committed.get(ref, 0) > atom_time
|
||||
]
|
||||
return issues
|
||||
|
||||
|
||||
def hygiene(atoms: Mapping[str, Atom], schema: Schema | None) -> list[Issue]:
|
||||
"""Format discipline: a title, and what the schema requires per type."""
|
||||
issues: list[Issue] = []
|
||||
for atom in atoms.values():
|
||||
if not atom.title:
|
||||
issues.append(
|
||||
Issue(
|
||||
severity="error",
|
||||
check="title",
|
||||
slug=atom.slug,
|
||||
detail="no title — the title is the claim, not decoration",
|
||||
)
|
||||
)
|
||||
if schema is not None:
|
||||
issues += _type_issues(atom, schema)
|
||||
issues += _scale_issues(atom)
|
||||
return issues
|
||||
|
||||
|
||||
def _type_issues(atom: Atom, schema: Schema) -> list[Issue]:
|
||||
"""Type declared, status legal for it, required fields present."""
|
||||
|
||||
def issue(severity: str, check: str, detail: str) -> Issue:
|
||||
return Issue(severity=severity, check=check, slug=atom.slug, detail=detail)
|
||||
|
||||
node_type = schema.node_types.get(atom.type)
|
||||
if node_type is None:
|
||||
known = ", ".join(schema.node_types)
|
||||
return [issue("error", "type", f"unknown type {atom.type!r}; known: {known}")]
|
||||
issues: list[Issue] = []
|
||||
if atom.status is None:
|
||||
issues.append(issue("error", "status", f"{atom.type} must carry a status"))
|
||||
elif atom.status not in node_type.statuses:
|
||||
legal = ", ".join(node_type.statuses)
|
||||
issues.append(
|
||||
issue(
|
||||
"error", "status", f"{atom.status!r} not legal for {atom.type}; {legal}"
|
||||
)
|
||||
)
|
||||
issues += [
|
||||
issue("error", "required", f"{atom.type} must carry `{field}`")
|
||||
for field in node_type.requires
|
||||
if not atom.has(field)
|
||||
]
|
||||
return issues
|
||||
|
||||
|
||||
def _scale_issues(atom: Atom) -> list[Issue]:
|
||||
"""`confidence` and `importance` are free to omit, not free to invent."""
|
||||
issues: list[Issue] = []
|
||||
if atom.confidence is not None and atom.confidence not in CONFIDENCE:
|
||||
issues.append(
|
||||
Issue(
|
||||
severity="error",
|
||||
check="confidence",
|
||||
slug=atom.slug,
|
||||
detail=f"{atom.confidence!r} is not one of {', '.join(CONFIDENCE)}",
|
||||
)
|
||||
)
|
||||
if atom.importance is not None and atom.importance not in IMPORTANCE:
|
||||
issues.append(
|
||||
Issue(
|
||||
severity="error",
|
||||
check="importance",
|
||||
slug=atom.slug,
|
||||
detail=f"importance {atom.importance} is not 1-5",
|
||||
)
|
||||
)
|
||||
return issues
|
||||
|
||||
|
||||
def filing(atoms: Mapping[str, Atom], knowledge: Path) -> list[Issue]:
|
||||
"""The containing directory must be the node type.
|
||||
|
||||
This is what makes `ls knowledge/` a readable ontology and `fd . knowledge/claim`
|
||||
a working query, with no tool and no frontmatter parsing involved. A file
|
||||
in the wrong directory is a lie told to every tool that is not this one.
|
||||
"""
|
||||
return [
|
||||
Issue(
|
||||
severity="error",
|
||||
check="filing",
|
||||
slug=atom.slug,
|
||||
detail=(
|
||||
f"type is {atom.type!r} but the file is in "
|
||||
f"{atom.path.parent.relative_to(knowledge).as_posix() or '.'}/"
|
||||
),
|
||||
)
|
||||
for atom in atoms.values()
|
||||
if atom.type and atom.path.parent.name != atom.type
|
||||
]
|
||||
|
||||
|
||||
def relations(atoms: Mapping[str, Atom], schema: Schema) -> list[Issue]:
|
||||
"""Every edge names a declared relation and respects its domain and range.
|
||||
|
||||
Closed-world on purpose: an edge the schema does not declare is an error
|
||||
naming the file, not an inference that some new relation must exist. That
|
||||
difference is the whole reason this is forty lines of Python and not a
|
||||
reasoner.
|
||||
"""
|
||||
backwards = {inverse: name for name, inverse in schema.inverses().items()}
|
||||
issues: list[Issue] = []
|
||||
for atom in atoms.values():
|
||||
for relation, target in atom.edges:
|
||||
declared = schema.relation_types.get(relation)
|
||||
if declared is None:
|
||||
# Writing the inverse by hand is the mistake worth naming: the
|
||||
# indexer materializes it, so doing it here states the edge
|
||||
# twice and lets the two copies disagree.
|
||||
forward = backwards.get(relation)
|
||||
detail = (
|
||||
f"`{relation}` is the inverse of `{forward}` and is "
|
||||
f"generated — write `{forward}: [{atom.slug}]` on [[{target}]]"
|
||||
if forward
|
||||
else f"undeclared relation `{relation}`"
|
||||
)
|
||||
issues.append(
|
||||
Issue(
|
||||
severity="error",
|
||||
check="relation",
|
||||
slug=atom.slug,
|
||||
detail=detail,
|
||||
)
|
||||
)
|
||||
elif target in atoms and not declared.accepts(
|
||||
source=atom.type, target=atoms[target].type
|
||||
):
|
||||
issues.append(
|
||||
Issue(
|
||||
severity="error",
|
||||
check="relation",
|
||||
slug=atom.slug,
|
||||
detail=(
|
||||
f"`{relation}` does not go {atom.type} -> "
|
||||
f"{atoms[target].type} ([[{target}]])"
|
||||
),
|
||||
)
|
||||
)
|
||||
return issues
|
||||
|
||||
|
||||
def strays(paths: Sequence[Path]) -> list[Issue]:
|
||||
"""Markdown under the knowledge directory that carries no frontmatter.
|
||||
|
||||
A warning, not an error: in a base being built by digesting raw notes, a
|
||||
stray is work in progress. It is reported so that state stays visible
|
||||
instead of quietly becoming permanent.
|
||||
"""
|
||||
return [
|
||||
Issue(
|
||||
severity="warn",
|
||||
check="stray",
|
||||
slug=path.as_posix(),
|
||||
detail="markdown with no frontmatter — not an atom",
|
||||
)
|
||||
for path in sorted(paths)
|
||||
]
|
||||
@@ -0,0 +1,178 @@
|
||||
"""Command-line entry: `serve` the wiki, `check` the graph.
|
||||
|
||||
Impure by design, with `server.py` the only modules that are: everything
|
||||
`check` prints comes from the pure renderer in `views/checks.py`.
|
||||
|
||||
Conventions:
|
||||
- `--repo` (default: cwd) roots everything. Where atoms, citing sources
|
||||
and annotations live is `pyroli.toml`'s business — see `config.py`.
|
||||
- Exit code 1 means a check failed or an input did not resolve. `main` is
|
||||
the single place that turns an exception into that exit code, and it
|
||||
catches only the three types the parsers raise on bad input — a bug
|
||||
still gets its traceback.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
|
||||
from pyroli import __version__ as pyroli_version
|
||||
from pyroli import atoms, checks, config, git
|
||||
from pyroli import schema as schema_mod
|
||||
from pyroli.views import checks as checks_view
|
||||
|
||||
app = typer.Typer(
|
||||
add_completion=False,
|
||||
no_args_is_help=True,
|
||||
help="Serve a knowledge/ folder as a wiki, and check its links.",
|
||||
)
|
||||
# `highlight=False`: rich's auto-colouring of numbers and paths picks colours
|
||||
# that vanish on some dark themes. Only explicit markup is coloured.
|
||||
console = Console(highlight=False)
|
||||
|
||||
|
||||
def _version(value: bool) -> None:
|
||||
if value:
|
||||
console.print(f"pyroli {pyroli_version}")
|
||||
raise typer.Exit
|
||||
|
||||
|
||||
@app.callback()
|
||||
def _root(
|
||||
version: Annotated[
|
||||
bool,
|
||||
typer.Option(
|
||||
"--version",
|
||||
callback=_version,
|
||||
is_eager=True,
|
||||
help="Print the installed version and exit.",
|
||||
),
|
||||
] = False,
|
||||
) -> None:
|
||||
"""Serve a knowledge/ folder as a wiki, and check its links.
|
||||
|
||||
`--version` answers one question: is the `pyroli` on `PATH` the one just
|
||||
edited? It is not, until `scripts/install-pyroli.sh` runs again.
|
||||
The wiki prints the same number in its footer for the same reason.
|
||||
"""
|
||||
|
||||
|
||||
Repo = Annotated[Path, typer.Option("--repo", help="Repository root.")]
|
||||
|
||||
|
||||
def _read_sources(repo: Path, patterns: tuple[str, ...]) -> dict[str, str]:
|
||||
return {
|
||||
str(path.relative_to(repo)): path.read_text(encoding="utf-8")
|
||||
for pattern in patterns
|
||||
for path in sorted(repo.glob(pattern))
|
||||
}
|
||||
|
||||
|
||||
def _git_checks(repo: Path, known: dict[str, atoms.Atom]) -> list[checks.Issue]:
|
||||
"""Evidence and staleness, or one loud warning saying they did not run.
|
||||
|
||||
Treating "no git" as "nothing is tracked" would fail every evidence check;
|
||||
treating it as "everything is fine" would pass them all. It is neither, so
|
||||
it is reported as its own issue.
|
||||
"""
|
||||
if not git.is_repo(repo):
|
||||
return [
|
||||
checks.Issue(
|
||||
severity="warn",
|
||||
check="git",
|
||||
slug=repo.resolve().name,
|
||||
detail="not a git repository — evidence and staleness unchecked",
|
||||
)
|
||||
]
|
||||
return [
|
||||
*checks.evidence(known, repo, git.tracked(repo)),
|
||||
*checks.staleness(known, git.commit_times(repo), repo),
|
||||
]
|
||||
|
||||
|
||||
def _schema_checks(
|
||||
known: dict[str, atoms.Atom], scheme: schema_mod.Schema | None, repo: Path
|
||||
) -> list[checks.Issue]:
|
||||
if scheme is None:
|
||||
return [
|
||||
checks.Issue(
|
||||
severity="warn",
|
||||
check="schema",
|
||||
slug=repo.resolve().name,
|
||||
detail="no knowledge/schema.yaml — types, statuses, edges unchecked",
|
||||
)
|
||||
]
|
||||
return [*checks.propagation(known, scheme), *checks.relations(known, scheme)]
|
||||
|
||||
|
||||
@app.command()
|
||||
def check(
|
||||
repo: Repo = Path(),
|
||||
strict: Annotated[bool, typer.Option(help="Treat warnings as failures.")] = False,
|
||||
) -> None:
|
||||
"""Run every knowledge-graph check over the repo."""
|
||||
cfg = config.load(repo)
|
||||
knowledge = repo / cfg.knowledge
|
||||
scheme = schema_mod.load(repo, knowledge)
|
||||
known, undigested = atoms.scan(knowledge)
|
||||
sources = _read_sources(repo, cfg.citing)
|
||||
issues = [
|
||||
*checks.links(known, atoms.resolvable(known)),
|
||||
*checks.citations(known, sources),
|
||||
*checks.hygiene(known, scheme),
|
||||
*checks.filing(known, knowledge),
|
||||
*checks.strays(undigested),
|
||||
*_schema_checks(known, scheme, repo),
|
||||
*_git_checks(repo, known),
|
||||
]
|
||||
cited = frozenset(slug for text in sources.values() for slug in atoms.cited(text))
|
||||
console.print(checks_view.render(known, issues, sources, cited))
|
||||
|
||||
errors = [issue for issue in issues if issue.severity == "error"]
|
||||
warnings = [issue for issue in issues if issue.severity == "warn"]
|
||||
console.print(f"\n{len(errors)} errors, {len(warnings)} warnings")
|
||||
if errors or (strict and warnings):
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@app.command()
|
||||
def serve(
|
||||
repo: Repo = Path(),
|
||||
host: Annotated[str, typer.Option(help="Bind address.")] = "127.0.0.1",
|
||||
port: Annotated[int, typer.Option(help="Port.")] = 8080,
|
||||
tag: Annotated[
|
||||
str | None, typer.Option(help="Show only atoms carrying this tag.")
|
||||
] = None,
|
||||
home: Annotated[
|
||||
str | None, typer.Option(help="Atom slug to render as the front page.")
|
||||
] = None,
|
||||
) -> None:
|
||||
"""Serve atoms as a linked, searchable, LaTeX-rendering wiki.
|
||||
|
||||
`--tag` and `--home` scope the entry point, not the graph: every atom stays
|
||||
reachable by link. A base with a dozen unrelated subjects in it is unusable
|
||||
as one flat index, and the subject being worked on is the only one wanted.
|
||||
"""
|
||||
import uvicorn # noqa: PLC0415 — starts a server; not wanted at import time
|
||||
|
||||
from pyroli.server import app as build # noqa: PLC0415
|
||||
|
||||
scope = f" [{tag or home}]" if tag or home else ""
|
||||
console.print(f"serving {repo.resolve()}{scope} at http://{host}:{port}")
|
||||
built = build(repo.resolve(), tag=tag, home=home)
|
||||
uvicorn.run(built, host=host, port=port, log_level="warning")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Run the app, reporting bad input as one red line and exit code 1."""
|
||||
try:
|
||||
app()
|
||||
except (ValueError, FileNotFoundError, RuntimeError) as exc:
|
||||
console.print(f"[red]error:[/red] {exc}")
|
||||
raise SystemExit(1) from None
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Per-repository configuration: where atoms, citing scripts and notes live.
|
||||
|
||||
Pure: text in, dataclass out. `load` is the one line that reads a file.
|
||||
|
||||
The defaults *are* the research-repo layout, so a repo with no `pyroli.toml`
|
||||
behaves exactly as it did before this module existed. The file exists for the
|
||||
repos that are not shaped like that — a knowledge base whose atoms sit at the
|
||||
root and that has no `experiments/` at all.
|
||||
|
||||
Invariants:
|
||||
- An unknown key raises `ValueError` naming the file. A typo that silently
|
||||
keeps the default is a configuration that lies about what it configures.
|
||||
- Every path is repo-relative. Nothing here resolves `~` or an absolute
|
||||
path: `--repo` is the only way to point the tool somewhere else, and one
|
||||
mechanism for that is enough.
|
||||
|
||||
Conventions:
|
||||
- `knowledge` is a directory, walked recursively; the rest are globs
|
||||
evaluated against the repo root.
|
||||
"""
|
||||
|
||||
import tomllib
|
||||
from dataclasses import dataclass, fields
|
||||
from pathlib import Path
|
||||
|
||||
FILENAME = "pyroli.toml"
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True, slots=True)
|
||||
class Config:
|
||||
"""Where one repository keeps the things `pyroli` reads.
|
||||
|
||||
Attributes:
|
||||
knowledge: Directory holding atoms, walked recursively.
|
||||
citing: Globs for sources that cite atoms by `[[slug]]`.
|
||||
annotations: Append-only JSONL file of margin notes on atoms.
|
||||
"""
|
||||
|
||||
knowledge: str = "knowledge"
|
||||
citing: tuple[str, ...] = ("reports/*.py", "experiments/*.py")
|
||||
annotations: str = "annotations.jsonl"
|
||||
|
||||
|
||||
def parse(text: str, name: str = FILENAME) -> Config:
|
||||
"""Parse a `pyroli.toml`.
|
||||
|
||||
Raises:
|
||||
ValueError: If the TOML is malformed, a key is unknown, or a value has
|
||||
the wrong shape.
|
||||
"""
|
||||
try:
|
||||
data = tomllib.loads(text)
|
||||
except tomllib.TOMLDecodeError as exc:
|
||||
msg = f"{name}: not valid TOML ({exc})"
|
||||
raise ValueError(msg) from exc
|
||||
|
||||
known = {field.name for field in fields(Config)}
|
||||
unknown = sorted(set(data) - known)
|
||||
if unknown:
|
||||
msg = (
|
||||
f"{name}: unknown key(s) {', '.join(unknown)}; "
|
||||
f"known: {', '.join(sorted(known))}"
|
||||
)
|
||||
raise ValueError(msg)
|
||||
|
||||
return Config(
|
||||
knowledge=_directory(data, "knowledge", name),
|
||||
citing=_globs(data, "citing", name),
|
||||
annotations=_directory(data, "annotations", name),
|
||||
)
|
||||
|
||||
|
||||
_DEFAULTS = Config()
|
||||
|
||||
|
||||
def _directory(data: dict[str, object], key: str, name: str) -> str:
|
||||
value = data.get(key, getattr(_DEFAULTS, key))
|
||||
if not isinstance(value, str):
|
||||
msg = f"{name}: `{key}` must be a directory path string"
|
||||
raise ValueError(msg)
|
||||
return value
|
||||
|
||||
|
||||
def _globs(data: dict[str, object], key: str, name: str) -> tuple[str, ...]:
|
||||
value = data.get(key, getattr(_DEFAULTS, key))
|
||||
if isinstance(value, tuple):
|
||||
return value
|
||||
if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
|
||||
msg = f"{name}: `{key}` must be a list of glob strings"
|
||||
raise ValueError(msg)
|
||||
return tuple(value)
|
||||
|
||||
|
||||
def load(repo: Path) -> Config:
|
||||
"""Read `<repo>/pyroli.toml`, or the defaults if there is none.
|
||||
|
||||
Raises:
|
||||
ValueError: If the file exists and is malformed.
|
||||
"""
|
||||
path = repo / FILENAME
|
||||
if not path.is_file():
|
||||
return Config()
|
||||
return parse(path.read_text(encoding="utf-8"), name=str(path))
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Git facts the knowledge checks need. Impure: runs `git` in a repo.
|
||||
|
||||
Invariants:
|
||||
- Paths are repo-relative, exactly as `git ls-files` prints them.
|
||||
- A repo that is not a git repo raises `RuntimeError` carrying git's own
|
||||
stderr; there is no empty-set fallback, because "no file is tracked" and
|
||||
"not a repo" mean different things and the second silently passes every
|
||||
evidence check. `is_repo` is how a caller asks in advance.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def is_repo(repo: Path) -> bool:
|
||||
"""True if `repo` is inside a git working tree."""
|
||||
return (repo / ".git").exists()
|
||||
|
||||
|
||||
def _git(repo: Path, *args: str) -> str:
|
||||
"""Run a fixed git subcommand in `repo`. No user input reaches argv.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If git exits non-zero, carrying its stderr.
|
||||
"""
|
||||
out = subprocess.run( # noqa: S603 — fixed argv, no shell, no untrusted input
|
||||
["git", "-C", str(repo), *args], # noqa: S607 — git resolved from PATH
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if out.returncode != 0:
|
||||
msg = f"git {' '.join(args)} failed in {repo}: {out.stderr.strip()}"
|
||||
raise RuntimeError(msg)
|
||||
return out.stdout
|
||||
|
||||
|
||||
def tracked(repo: Path) -> frozenset[str]:
|
||||
"""Repo-relative paths tracked by git."""
|
||||
return frozenset(_git(repo, "ls-files").splitlines())
|
||||
|
||||
|
||||
def commit_times(repo: Path) -> dict[str, int]:
|
||||
"""Last-commit unix time per tracked path, from one `git log` walk."""
|
||||
log = _git(repo, "log", "--name-only", "--format=%n%ct", "--no-merges")
|
||||
times: dict[str, int] = {}
|
||||
stamp = 0
|
||||
for line in log.splitlines():
|
||||
if not line:
|
||||
continue
|
||||
if line.isdigit():
|
||||
stamp = int(line)
|
||||
elif line not in times: # newest-first, so the first hit is the latest
|
||||
times[line] = stamp
|
||||
return times
|
||||
@@ -0,0 +1,282 @@
|
||||
"""The ontology: which node types exist, which relations connect them.
|
||||
|
||||
Pure: text in, dataclass out. `load` is the one line that reads a file.
|
||||
|
||||
The vocabulary lives in `schema.yaml`, not in Python, because the corpus and
|
||||
the schema are what must survive and this tool is what is disposable. Nothing
|
||||
is shipped here: the default schemas belong to the skills (`research`, `swe`),
|
||||
and a repo copies one to `knowledge/schema.yaml` and edits it from there.
|
||||
|
||||
Invariants:
|
||||
- Unknown keys raise `ValueError` naming the file. A schema that silently
|
||||
drops a misspelled `rnage:` is a schema that lies about what it declares.
|
||||
- Domain and range default to `*`, never to empty. A relation with no
|
||||
stated domain constrains nothing; a relation constrained to nothing would
|
||||
make every use of it an error, which is not what omitting a key means.
|
||||
- Inverse names are reserved: an inverse may not collide with a declared
|
||||
relation, because the indexer materializes inverses as real edges and two
|
||||
sources for one relation name is ambiguous.
|
||||
|
||||
Conventions:
|
||||
- `ANY` is the wildcard, written `"*"` in YAML.
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
FILENAME = "schema.yaml"
|
||||
ANY = "*"
|
||||
|
||||
_NODE_KEYS = frozenset({"statuses", "requires", "diataxis"})
|
||||
_RELATION_KEYS = frozenset({"inverse", "domain", "range", "transitive"})
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True, slots=True)
|
||||
class NodeType:
|
||||
"""One kind of node.
|
||||
|
||||
Attributes:
|
||||
name: The `type:` value, and the directory the file lives in.
|
||||
statuses: Legal `status:` values; the type's state machine.
|
||||
requires: Frontmatter keys a node of this type must carry.
|
||||
diataxis: Which Diataxis quadrant the page shape belongs to, if fixed.
|
||||
"""
|
||||
|
||||
name: str
|
||||
statuses: tuple[str, ...]
|
||||
requires: tuple[str, ...] = ()
|
||||
diataxis: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True, slots=True)
|
||||
class RelationType:
|
||||
"""One typed edge.
|
||||
|
||||
Attributes:
|
||||
name: The frontmatter key under `edges:`.
|
||||
inverse: Relation name of the materialized backlink, if it has one.
|
||||
domain: Types allowed as the source; `(ANY,)` for unconstrained.
|
||||
range: Types allowed as the target; `(ANY,)` for unconstrained.
|
||||
transitive: Whether a closure query may follow it repeatedly.
|
||||
"""
|
||||
|
||||
name: str
|
||||
inverse: str | None = None
|
||||
domain: tuple[str, ...] = (ANY,)
|
||||
range: tuple[str, ...] = (ANY,)
|
||||
transitive: bool = False
|
||||
|
||||
def accepts(self, *, source: str, target: str) -> bool:
|
||||
"""Whether an edge between these two node types is legal."""
|
||||
return (ANY in self.domain or source in self.domain) and (
|
||||
ANY in self.range or target in self.range
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True, slots=True)
|
||||
class Schema:
|
||||
"""The whole vocabulary: node types, relation types, status classes.
|
||||
|
||||
Attributes:
|
||||
node_types: Keyed by name, in declaration order — which is also the
|
||||
order views present them in.
|
||||
relation_types: Keyed by name.
|
||||
status_classes: Status name to `settled` or `dead`. Statuses that mean
|
||||
neither are absent, and that absence is what makes the propagation
|
||||
check silent on work in progress.
|
||||
"""
|
||||
|
||||
node_types: Mapping[str, NodeType]
|
||||
relation_types: Mapping[str, RelationType]
|
||||
status_classes: Mapping[str, str]
|
||||
|
||||
@property
|
||||
def order(self) -> tuple[str, ...]:
|
||||
"""Node type names in declaration order, for sorting views."""
|
||||
return tuple(self.node_types)
|
||||
|
||||
def inverses(self) -> Mapping[str, str]:
|
||||
"""Relation name to inverse name, for the relations that declare one."""
|
||||
return {
|
||||
one.name: one.inverse
|
||||
for one in self.relation_types.values()
|
||||
if one.inverse is not None
|
||||
}
|
||||
|
||||
|
||||
def parse(text: str, name: str = FILENAME) -> Schema:
|
||||
"""Parse a `schema.yaml`.
|
||||
|
||||
Raises:
|
||||
ValueError: If the YAML is malformed, a key is unknown, or a declared
|
||||
domain, range or inverse does not make sense against the rest.
|
||||
"""
|
||||
try:
|
||||
data = yaml.safe_load(text)
|
||||
except yaml.YAMLError as exc:
|
||||
msg = f"{name}: not valid YAML ({exc.args[-1]})"
|
||||
raise ValueError(msg) from exc
|
||||
if not isinstance(data, dict):
|
||||
msg = f"{name}: top level is not a mapping"
|
||||
raise ValueError(msg)
|
||||
|
||||
unknown = sorted(set(data) - {"node_types", "relation_types", "status_classes"})
|
||||
if unknown:
|
||||
msg = f"{name}: unknown top-level key(s) {', '.join(unknown)}"
|
||||
raise ValueError(msg)
|
||||
|
||||
node_types = {
|
||||
key: _node_type(key, value, name)
|
||||
for key, value in _section(data, "node_types", name).items()
|
||||
}
|
||||
relation_types = {
|
||||
key: _relation_type(key, value, name)
|
||||
for key, value in _section(data, "relation_types", name).items()
|
||||
}
|
||||
_check_coherent(node_types, relation_types, name)
|
||||
return Schema(
|
||||
node_types=node_types,
|
||||
relation_types=relation_types,
|
||||
status_classes=_status_classes(data, name),
|
||||
)
|
||||
|
||||
|
||||
def _status_classes(data: dict[str, object], name: str) -> Mapping[str, str]:
|
||||
"""Invert `{class: [status]}` into `{status: class}`, rejecting overlap."""
|
||||
declared = data.get("status_classes") or {}
|
||||
if not isinstance(declared, dict):
|
||||
msg = f"{name}: `status_classes` must be a mapping of class -> [status]"
|
||||
raise ValueError(msg)
|
||||
unknown = sorted(set(declared) - {"settled", "dead"})
|
||||
if unknown:
|
||||
msg = f"{name}: unknown status class(es) {', '.join(unknown)}"
|
||||
raise ValueError(msg)
|
||||
found: dict[str, str] = {}
|
||||
for label in ("settled", "dead"):
|
||||
for status in _strings(declared, label, f"{name}: status_classes", default=()):
|
||||
if status in found:
|
||||
msg = f"{name}: status `{status}` is both settled and dead"
|
||||
raise ValueError(msg)
|
||||
found[status] = label
|
||||
return found
|
||||
|
||||
|
||||
def load(repo: Path, knowledge: Path) -> Schema | None:
|
||||
"""Read `<knowledge>/schema.yaml`, else `<repo>/schema.yaml`, else None.
|
||||
|
||||
None is not an error: a knowledge folder with no ontology is still a wiki,
|
||||
and `check` says once that the type checks did not run.
|
||||
|
||||
Raises:
|
||||
ValueError: If a file exists and is malformed.
|
||||
"""
|
||||
for path in (knowledge / FILENAME, repo / FILENAME):
|
||||
if path.is_file():
|
||||
return parse(path.read_text(encoding="utf-8"), name=str(path))
|
||||
return None
|
||||
|
||||
|
||||
def _section(data: dict[str, object], key: str, name: str) -> dict[str, object]:
|
||||
value = data.get(key)
|
||||
if not isinstance(value, dict):
|
||||
msg = f"{name}: `{key}` must be a mapping of name -> definition"
|
||||
raise ValueError(msg)
|
||||
return value
|
||||
|
||||
|
||||
def _node_type(key: str, value: object, name: str) -> NodeType:
|
||||
body = _body(key, value, _NODE_KEYS, name)
|
||||
statuses = _strings(body, "statuses", f"{name}: node type `{key}`", default=())
|
||||
if not statuses:
|
||||
msg = f"{name}: node type `{key}` must declare at least one status"
|
||||
raise ValueError(msg)
|
||||
diataxis = body.get("diataxis")
|
||||
if diataxis is not None and not isinstance(diataxis, str):
|
||||
msg = f"{name}: node type `{key}`: `diataxis` must be a string"
|
||||
raise ValueError(msg)
|
||||
return NodeType(
|
||||
name=key,
|
||||
statuses=statuses,
|
||||
requires=_strings(body, "requires", f"{name}: node type `{key}`", default=()),
|
||||
diataxis=diataxis,
|
||||
)
|
||||
|
||||
|
||||
def _relation_type(key: str, value: object, name: str) -> RelationType:
|
||||
body = _body(key, value, _RELATION_KEYS, name)
|
||||
where = f"{name}: relation `{key}`"
|
||||
inverse = body.get("inverse")
|
||||
if inverse is not None and not isinstance(inverse, str):
|
||||
msg = f"{where}: `inverse` must be a relation name"
|
||||
raise ValueError(msg)
|
||||
transitive = body.get("transitive", False)
|
||||
if not isinstance(transitive, bool):
|
||||
msg = f"{where}: `transitive` must be true or false"
|
||||
raise ValueError(msg)
|
||||
return RelationType(
|
||||
name=key,
|
||||
inverse=inverse,
|
||||
domain=_strings(body, "domain", where, default=(ANY,)),
|
||||
range=_strings(body, "range", where, default=(ANY,)),
|
||||
transitive=transitive,
|
||||
)
|
||||
|
||||
|
||||
def _body(
|
||||
key: str, value: object, known: frozenset[str], name: str
|
||||
) -> dict[str, object]:
|
||||
if value is None:
|
||||
return {}
|
||||
if not isinstance(value, dict):
|
||||
msg = f"{name}: `{key}` must be a mapping"
|
||||
raise ValueError(msg)
|
||||
unknown = sorted(set(value) - known)
|
||||
if unknown:
|
||||
msg = (
|
||||
f"{name}: `{key}` has unknown key(s) {', '.join(unknown)}; "
|
||||
f"known: {', '.join(sorted(known))}"
|
||||
)
|
||||
raise ValueError(msg)
|
||||
return value
|
||||
|
||||
|
||||
def _strings(
|
||||
body: Mapping[str, object],
|
||||
key: str,
|
||||
where: str,
|
||||
*,
|
||||
default: tuple[str, ...],
|
||||
) -> tuple[str, ...]:
|
||||
value = body.get(key)
|
||||
if value is None:
|
||||
return default
|
||||
if not isinstance(value, list) or not all(isinstance(one, str) for one in value):
|
||||
msg = f"{where}: `{key}` must be a list of strings"
|
||||
raise ValueError(msg)
|
||||
return tuple(value)
|
||||
|
||||
|
||||
def _check_coherent(
|
||||
node_types: Mapping[str, NodeType],
|
||||
relation_types: Mapping[str, RelationType],
|
||||
name: str,
|
||||
) -> None:
|
||||
"""Every type named by a domain, range or inverse must make sense."""
|
||||
for relation in relation_types.values():
|
||||
for field, names in (("domain", relation.domain), ("range", relation.range)):
|
||||
unknown = sorted(set(names) - set(node_types) - {ANY})
|
||||
if unknown:
|
||||
msg = (
|
||||
f"{name}: relation `{relation.name}` {field} names "
|
||||
f"undeclared type(s) {', '.join(unknown)}"
|
||||
)
|
||||
raise ValueError(msg)
|
||||
if relation.inverse in relation_types:
|
||||
msg = (
|
||||
f"{name}: relation `{relation.name}` declares inverse "
|
||||
f"`{relation.inverse}`, which is itself a declared relation"
|
||||
)
|
||||
raise ValueError(msg)
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Full-text search over atoms, for the wiki's `/search`.
|
||||
|
||||
Pure: atoms and a query in, ranked hits out. No index: the server re-reads the
|
||||
atoms per request anyway, and scanning a few thousand bodies is milliseconds.
|
||||
An index is one more thing that can go stale.
|
||||
|
||||
Invariants:
|
||||
- Every whitespace-separated term must match, case-insensitively, in the
|
||||
title, a tag, the slug, or the body. More terms, fewer hits.
|
||||
- Rank is the sum over terms of the best field each hits: title 3, tag or
|
||||
slug 2, body 1. Ties sort by slug, so results are stable.
|
||||
- The snippet is HTML-escaped *before* terms are wrapped in `<mark>`, so a
|
||||
body containing markup can never inject any.
|
||||
"""
|
||||
|
||||
import html
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
|
||||
from pyroli.atoms import Atom
|
||||
|
||||
SNIPPET_CHARS = 240
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True, slots=True)
|
||||
class Hit:
|
||||
"""One search result.
|
||||
|
||||
Attributes:
|
||||
atom: The matching atom.
|
||||
score: Higher ranks first.
|
||||
snippet: Escaped HTML: the first body line with a term, terms marked.
|
||||
"""
|
||||
|
||||
atom: Atom
|
||||
score: int
|
||||
snippet: str
|
||||
|
||||
|
||||
def search(atoms: Mapping[str, Atom], query: str) -> tuple[Hit, ...]:
|
||||
"""Atoms matching every term of `query`, best first."""
|
||||
terms = tuple(dict.fromkeys(query.lower().split()))
|
||||
if not terms:
|
||||
return ()
|
||||
hits = [
|
||||
Hit(atom=atom, score=score, snippet=_snippet(atom.body, terms))
|
||||
for atom in atoms.values()
|
||||
if (score := _score(atom, terms))
|
||||
]
|
||||
return tuple(sorted(hits, key=lambda hit: (-hit.score, hit.atom.slug)))
|
||||
|
||||
|
||||
def _score(atom: Atom, terms: tuple[str, ...]) -> int:
|
||||
"""Sum of each term's best field weight; 0 if any term matches nowhere."""
|
||||
title = atom.title.lower()
|
||||
labels = " ".join((atom.slug, *atom.tags)).lower()
|
||||
body = atom.body.lower()
|
||||
total = 0
|
||||
for term in terms:
|
||||
weight = 3 if term in title else 2 if term in labels else int(term in body)
|
||||
if not weight:
|
||||
return 0
|
||||
total += weight
|
||||
return total
|
||||
|
||||
|
||||
def _snippet(body: str, terms: tuple[str, ...]) -> str:
|
||||
lines = [line.strip() for line in body.splitlines() if line.strip()]
|
||||
line = next(
|
||||
(line for line in lines if any(term in line.lower() for term in terms)),
|
||||
lines[0] if lines else "",
|
||||
)
|
||||
escaped = html.escape(line[:SNIPPET_CHARS])
|
||||
pattern = "|".join(re.escape(html.escape(term)) for term in terms)
|
||||
return re.sub(pattern, r"<mark>\g<0></mark>", escaped, flags=re.IGNORECASE)
|
||||
@@ -0,0 +1,190 @@
|
||||
"""The served wiki: atoms and tags as linked HTML. Markdown only.
|
||||
|
||||
Impure, and the second of only two modules that are (`cli.py` is the other):
|
||||
every request re-reads the repo from disk. That is the point — a page is a
|
||||
view of the files as they are now, so editing an atom in an editor and hitting
|
||||
refresh shows it, and nothing has to be rebuilt or invalidated.
|
||||
|
||||
Invariants:
|
||||
- Re-read per request, never cache. At a few hundred atoms the parse is
|
||||
milliseconds; a cache would be the only thing here that can go stale.
|
||||
- A malformed atom returns 500 with the parser's message. It does not fall
|
||||
back to a partial page: a wiki that silently hides a broken file is how
|
||||
you stop noticing broken files.
|
||||
- **`POST /annotate` and `POST /annotate/delete` are the only routes
|
||||
that write, and both append to the annotation sidecar — never to an
|
||||
atom.** Atoms stay authored in one editor by one person; the margin is
|
||||
where reading talks back. Giving up that line is what would turn this
|
||||
into a CMS.
|
||||
|
||||
Conventions:
|
||||
- Routes: `/atom/<slug>`, `/tag/<tag>`, `/search?q=`.
|
||||
- `tag` and `home` scope the *front page* only. Every atom stays reachable
|
||||
by link, because a knowledge base covering a dozen subjects is unusable
|
||||
as one flat index while still being one graph.
|
||||
- No Python pages. The wiki renders `knowledge/`; code is `sonde`'s.
|
||||
"""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from pathlib import Path
|
||||
|
||||
from starlette.applications import Starlette
|
||||
from starlette.exceptions import HTTPException
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import HTMLResponse, JSONResponse
|
||||
from starlette.routing import Mount, Route
|
||||
from starlette.staticfiles import StaticFiles
|
||||
|
||||
from pyroli import annotations as ann
|
||||
from pyroli import atoms as atoms_mod
|
||||
from pyroli import config, search, site
|
||||
from pyroli import schema as schema_mod
|
||||
|
||||
STATIC = Path(__file__).parent / "static"
|
||||
|
||||
|
||||
def app(repo: Path, *, tag: str | None = None, home: str | None = None) -> Starlette:
|
||||
"""Build the application rooted at one repository, optionally scoped."""
|
||||
cfg = config.load(repo)
|
||||
knowledge = repo / cfg.knowledge
|
||||
scheme = schema_mod.load(repo, knowledge)
|
||||
store = repo / cfg.annotations
|
||||
|
||||
def load() -> dict[str, atoms_mod.Atom]:
|
||||
return atoms_mod.load(knowledge)
|
||||
|
||||
def notes() -> tuple[ann.Annotation, ...]:
|
||||
if not store.is_file():
|
||||
return ()
|
||||
return ann.parse(store.read_text(encoding="utf-8"))
|
||||
|
||||
async def index(_: Request) -> HTMLResponse:
|
||||
known = load()
|
||||
if home is not None:
|
||||
return _one_atom(home, known, notes(), knowledge)
|
||||
shown = (
|
||||
known
|
||||
if tag is None
|
||||
else {one.slug: one for one in atoms_mod.select(known, tag=tag)}
|
||||
)
|
||||
order = (
|
||||
scheme.order if scheme else tuple(sorted({a.type for a in shown.values()}))
|
||||
)
|
||||
grouped = {
|
||||
name: members
|
||||
for name in order
|
||||
if (members := atoms_mod.select(shown, node_type=name))
|
||||
}
|
||||
title = repo.name if tag is None else f"{repo.name} #{tag}"
|
||||
body = site.index_page(shown, grouped)
|
||||
return HTMLResponse(site.page(title, body))
|
||||
|
||||
async def atom(request: Request) -> HTMLResponse:
|
||||
return _one_atom(request.path_params["slug"], load(), notes(), knowledge)
|
||||
|
||||
annotate = _annotate_route(store, load, notes)
|
||||
|
||||
async def search_route(request: Request) -> HTMLResponse:
|
||||
query = request.query_params.get("q", "").strip()
|
||||
body = site.search_page(query, search.search(load(), query))
|
||||
return HTMLResponse(site.page(query or "search", body, query=query))
|
||||
|
||||
async def tag_page(request: Request) -> HTMLResponse:
|
||||
known = load()
|
||||
name = request.path_params["tag"]
|
||||
tagged = atoms_mod.select(known, tag=name)
|
||||
body = f"<h1>#{name}</h1>" + "".join(
|
||||
site.atom_page(one, known, ()) for one in tagged
|
||||
)
|
||||
return HTMLResponse(site.page(f"#{name}", body))
|
||||
|
||||
return Starlette(
|
||||
routes=[
|
||||
Route("/", index),
|
||||
Route("/atom/{slug}", atom),
|
||||
Route("/annotate", annotate, methods=["POST"]),
|
||||
Route("/annotate/delete", _delete_route(store, notes), methods=["POST"]),
|
||||
Route("/tag/{tag}", tag_page),
|
||||
Route("/search", search_route),
|
||||
Mount("/static", StaticFiles(directory=STATIC)),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _one_atom(
|
||||
slug: str,
|
||||
known: dict[str, atoms_mod.Atom],
|
||||
notes: tuple[ann.Annotation, ...],
|
||||
knowledge: Path,
|
||||
) -> HTMLResponse:
|
||||
"""One atom page, resolving `slug` through aliases first.
|
||||
|
||||
Shared by `/` under `--home` and by `/atom/<slug>`: a scoped front page is
|
||||
the same page, so rendering it twice would be two places for the margin to
|
||||
go missing from.
|
||||
"""
|
||||
found = atoms_mod.resolvable(known).get(slug)
|
||||
if found is None:
|
||||
raise HTTPException(404, f"no atom {slug!r} in {knowledge}")
|
||||
body = site.atom_page(
|
||||
found,
|
||||
known,
|
||||
site.backlinks(known, found.slug),
|
||||
ann.on(notes, found.slug),
|
||||
)
|
||||
return HTMLResponse(site.page(found.title or slug, body))
|
||||
|
||||
|
||||
def _annotate_route(
|
||||
store: Path,
|
||||
load: Callable[[], dict[str, atoms_mod.Atom]],
|
||||
notes: Callable[[], tuple[ann.Annotation, ...]],
|
||||
) -> Callable[[Request], Awaitable[JSONResponse]]:
|
||||
"""Build the one route that writes: append a margin note to the sidecar.
|
||||
|
||||
An unknown slug is a 404 and an empty quote or body is a 400, both before
|
||||
anything is written. The append itself is the last statement for that
|
||||
reason — a half-written store is the one failure this file cannot report.
|
||||
"""
|
||||
|
||||
async def annotate(request: Request) -> JSONResponse:
|
||||
form = await request.json()
|
||||
slug = str(form.get("slug", ""))
|
||||
if slug not in load():
|
||||
raise HTTPException(404, f"no atom {slug!r}")
|
||||
try:
|
||||
note = ann.create(
|
||||
notes(),
|
||||
slug=slug,
|
||||
selector=ann.Selector(
|
||||
exact=str(form.get("exact", "")),
|
||||
prefix=str(form.get("prefix", "")),
|
||||
suffix=str(form.get("suffix", "")),
|
||||
),
|
||||
body=str(form.get("body", "")),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
with store.open("a", encoding="utf-8") as handle:
|
||||
handle.write(ann.serialize(note))
|
||||
return JSONResponse({"id": note.id})
|
||||
|
||||
return annotate
|
||||
|
||||
|
||||
def _delete_route(
|
||||
store: Path,
|
||||
notes: Callable[[], tuple[ann.Annotation, ...]],
|
||||
) -> Callable[[Request], Awaitable[JSONResponse]]:
|
||||
"""Build the route that withdraws a margin note, by appending a deletion."""
|
||||
|
||||
async def delete(request: Request) -> JSONResponse:
|
||||
form = await request.json()
|
||||
target = str(form.get("id", ""))
|
||||
if target not in {one.id for one in ann.live(notes())}:
|
||||
raise HTTPException(404, f"no live annotation {target!r}")
|
||||
with store.open("a", encoding="utf-8") as handle:
|
||||
handle.write(ann.serialize_deletion(target))
|
||||
return JSONResponse({"deleted": target})
|
||||
|
||||
return delete
|
||||
@@ -0,0 +1,287 @@
|
||||
"""Markdown to HTML: the wiki half of the tool.
|
||||
|
||||
Pure: text and atoms in, HTML string out. Serving it is `server.py`'s job.
|
||||
|
||||
Invariants:
|
||||
- A `[[slug]]` renders as a link carrying the atom's *title*. Unlike the
|
||||
report path it does not raise on an unwritten atom: it renders a dangling
|
||||
link, styled, because a page you cannot open is a bad way to learn a
|
||||
link is dead. `pyroli check` is what fails.
|
||||
- Math never reaches the markdown parser. `$x_i$` would otherwise have its
|
||||
subscript eaten as emphasis, so math tokens are captured first and
|
||||
emitted as `<span data-tex>`, rendered client-side by KaTeX. An atom
|
||||
whose frontmatter says `math: typst` gets its math as Typst instead,
|
||||
rendered here to inline SVG (`typstmath.py`); KaTeX skips it.
|
||||
- Every path this module emits is site-absolute (`/atom/...`), so a page
|
||||
renders the same wherever it is mounted.
|
||||
|
||||
Conventions:
|
||||
- No template engine. The pages are four f-strings; a dependency that
|
||||
generates HTML from HTML-shaped syntax buys nothing at this size.
|
||||
"""
|
||||
|
||||
import html
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from markdown_it import MarkdownIt
|
||||
from markdown_it.renderer import RendererHTML
|
||||
from markdown_it.token import Token
|
||||
from markdown_it.utils import EnvType, OptionsDict
|
||||
from mdit_py_plugins.dollarmath import dollarmath_plugin
|
||||
|
||||
from pyroli import __version__, typstmath
|
||||
from pyroli.annotations import Annotation
|
||||
from pyroli.atoms import Atom, rewrite
|
||||
from pyroli.search import Hit
|
||||
|
||||
# When this copy was installed (or, in a working tree, last edited): what the
|
||||
# footer shows next to the version, because a date is recognisable at a glance
|
||||
# and a version number is not.
|
||||
BUILT = (
|
||||
datetime.fromtimestamp(Path(__file__).stat().st_mtime, tz=UTC)
|
||||
.astimezone()
|
||||
.strftime("%Y-%m-%d %H:%M")
|
||||
)
|
||||
|
||||
|
||||
def _math(
|
||||
_self: RendererHTML,
|
||||
tokens: list[Token],
|
||||
idx: int,
|
||||
_options: OptionsDict,
|
||||
env: EnvType,
|
||||
) -> str:
|
||||
display = tokens[idx].type == "math_block"
|
||||
if (typst := env.get("typst")) is not None:
|
||||
# A placeholder, filled after the whole page has been parsed, so that
|
||||
# every formula on it goes to `typst` in one batch.
|
||||
typst.append((tokens[idx].content.strip(), display))
|
||||
return f"\x00{len(typst) - 1}\x00"
|
||||
tex = html.escape(tokens[idx].content, quote=True)
|
||||
tag = "div" if display else "span"
|
||||
flag = "true" if display else "false"
|
||||
return f'<{tag} class="math" data-display="{flag}" data-tex="{tex}"></{tag}>'
|
||||
|
||||
|
||||
def _parser() -> MarkdownIt:
|
||||
md = MarkdownIt("commonmark", {"typographer": False})
|
||||
md.enable(["table", "strikethrough"])
|
||||
md.use(dollarmath_plugin, double_inline=True)
|
||||
# After `use`, not before: the plugin installs its own renderers into
|
||||
# `renderer.rules`, which shadow anything a Renderer subclass declares.
|
||||
for rule in ("math_inline", "math_block", "math_inline_double"):
|
||||
md.add_render_rule(rule, _math)
|
||||
return md
|
||||
|
||||
|
||||
MD = _parser()
|
||||
|
||||
|
||||
_PLACEHOLDER = re.compile(r"\x00(\d+)\x00")
|
||||
|
||||
|
||||
def markdown(text: str, atoms: Mapping[str, Atom], *, math: str = "tex") -> str:
|
||||
"""Render markdown with `[[wikilinks]]` resolved against `atoms`.
|
||||
|
||||
`math` is `"tex"` (KaTeX, in the browser) or `"typst"` (SVG, here).
|
||||
"""
|
||||
if math != "typst":
|
||||
return MD.render(_wikilinks(text, atoms))
|
||||
formulas: list[tuple[str, bool]] = []
|
||||
body = MD.render(_wikilinks(text, atoms), {"typst": formulas})
|
||||
rendered = typstmath.render(formulas)
|
||||
return _PLACEHOLDER.sub(lambda m: rendered[int(m[1])], body)
|
||||
|
||||
|
||||
def _wikilinks(text: str, atoms: Mapping[str, Atom]) -> str:
|
||||
def replace(slug: str) -> str:
|
||||
atom = atoms.get(slug)
|
||||
if atom is None:
|
||||
return f"[{slug}](/atom/{slug} '(no such atom)')"
|
||||
return f"[{atom.title}](/atom/{slug})"
|
||||
|
||||
return rewrite(text, replace)
|
||||
|
||||
|
||||
def backlinks(atoms: Mapping[str, Atom], slug: str) -> tuple[Atom, ...]:
|
||||
"""Other atoms whose body or edges point at `slug`; never `slug` itself."""
|
||||
return tuple(
|
||||
atom
|
||||
for atom in sorted(atoms.values(), key=lambda a: a.slug)
|
||||
if atom.slug != slug
|
||||
and (slug in atom.links or any(target == slug for _, target in atom.edges))
|
||||
)
|
||||
|
||||
|
||||
def page(title: str, body: str, *, query: str = "") -> str:
|
||||
"""Wrap rendered body HTML in the site shell, with KaTeX and the stylesheet.
|
||||
|
||||
The footer carries the version because the wiki is the one surface served
|
||||
by a *long-running* process: an edit to this file changes nothing until the
|
||||
server is restarted, and nothing on the page would otherwise say so.
|
||||
|
||||
The search box is a plain GET form, so it works with JavaScript off;
|
||||
`site.js` only adds the `/` shortcut.
|
||||
"""
|
||||
search = (
|
||||
'<form action="/search" role="search">'
|
||||
f'<input id="q" name="q" type="search" value="{html.escape(query, quote=True)}"'
|
||||
' placeholder="search ( / )" autocomplete="off"></form>'
|
||||
)
|
||||
return f"""<!doctype html>
|
||||
<html lang="en"><head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{html.escape(title)}</title>
|
||||
<link rel="stylesheet" href="/static/katex/katex.min.css">
|
||||
<link rel="stylesheet" href="/static/site.css">
|
||||
<script defer src="/static/katex/katex.min.js"></script>
|
||||
<script defer src="/static/site.js"></script>
|
||||
</head><body>
|
||||
<header><a href="/">index</a>{search}</header>
|
||||
<main>{body}</main>
|
||||
<footer>pyroli {html.escape(__version__)} · built {BUILT}</footer>
|
||||
</body></html>"""
|
||||
|
||||
|
||||
def atom_page(
|
||||
atom: Atom,
|
||||
atoms: Mapping[str, Atom],
|
||||
linking: tuple[Atom, ...],
|
||||
notes: tuple[Annotation, ...] = (),
|
||||
) -> str:
|
||||
"""One atom: frontmatter, body, typed edges, backlinks, margin notes."""
|
||||
meta = " · ".join(
|
||||
part
|
||||
for part in (
|
||||
atom.type,
|
||||
atom.status or "",
|
||||
" ".join(f'<a href="/tag/{t}">#{t}</a>' for t in atom.tags),
|
||||
)
|
||||
if part
|
||||
)
|
||||
return "".join(
|
||||
(
|
||||
f"<h1>{html.escape(atom.title or atom.slug)}</h1>",
|
||||
f'<p class="meta">{meta}</p>',
|
||||
f'<div class="body" data-slug="{html.escape(atom.slug, quote=True)}">',
|
||||
markdown(atom.body, atoms, math=str(atom.extra.get("math", "tex"))),
|
||||
"</div>",
|
||||
_annotations(atom, notes),
|
||||
_evidence(atom),
|
||||
_edges(atom, atoms),
|
||||
_list("Cited by", linking),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _annotations(atom: Atom, notes: tuple[Annotation, ...]) -> str:
|
||||
"""The margin: notes on this atom, then the form that adds one.
|
||||
|
||||
Each note carries its quote as `data-exact`; `site.js` finds it in the
|
||||
rendered body and marks it. Matching in the browser, on text rather than
|
||||
HTML, is what survives a quote across a line break, an apostrophe, or
|
||||
`**bold**` in the middle. A quote it cannot find stays listed, unmarked.
|
||||
"""
|
||||
items = "".join(
|
||||
f'<li id="{note.id}" class="ann" '
|
||||
f'data-exact="{html.escape(note.exact, quote=True)}">'
|
||||
f'<a class="ann-id" href="#q-{note.id}">{note.id}</a>'
|
||||
f'<button class="ann-delete" data-id="{note.id}" title="delete">'
|
||||
"×</button>"
|
||||
f"<blockquote>{html.escape(note.exact)}</blockquote>"
|
||||
f"<p>{html.escape(note.body)}</p>"
|
||||
f'<span class="meta">{html.escape(note.created)}</span></li>'
|
||||
for note in notes
|
||||
)
|
||||
listed = f"<ul class='annotations'>{items}</ul>" if items else ""
|
||||
heading = f"<h2>Margin <span class='meta'>{len(notes)}</span></h2>" if notes else ""
|
||||
# Rewritten by site.js once its listeners are attached, so the line doubles
|
||||
# as the affordance and as the answer to "is the JavaScript running?".
|
||||
hint = (
|
||||
'<p class="hint" id="annotate-hint">'
|
||||
"the margin needs JavaScript, and it has not run</p>"
|
||||
)
|
||||
return (
|
||||
f"{heading}{listed}{hint}"
|
||||
f'<div id="annotate" hidden data-slug="{html.escape(atom.slug, quote=True)}">'
|
||||
'<blockquote id="annotate-quote"></blockquote>'
|
||||
'<textarea id="annotate-body" rows="3" '
|
||||
'placeholder="what you have to say (ctrl+enter to save)"></textarea>'
|
||||
'<div><button id="annotate-save">save</button>'
|
||||
'<button id="annotate-cancel">cancel</button></div>'
|
||||
"</div>"
|
||||
)
|
||||
|
||||
|
||||
def index_page(
|
||||
atoms: Mapping[str, Atom],
|
||||
grouped: Mapping[str, tuple[Atom, ...]],
|
||||
) -> str:
|
||||
"""Every atom, grouped by node type, plus the tag list."""
|
||||
tags = sorted({tag for atom in atoms.values() for tag in atom.tags})
|
||||
parts = [
|
||||
f"<h1>{len(atoms)} atoms</h1>",
|
||||
'<p class="meta">'
|
||||
+ " ".join(f'<a href="/tag/{t}">#{t}</a>' for t in tags)
|
||||
+ "</p>",
|
||||
]
|
||||
parts += [
|
||||
f"<h2>{name}</h2>" + _list("", members)
|
||||
for name, members in grouped.items()
|
||||
if members
|
||||
]
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def _list(heading: str, atoms: tuple[Atom, ...]) -> str:
|
||||
if not atoms:
|
||||
return ""
|
||||
items = "".join(
|
||||
f'<li><a href="/atom/{a.slug}">{html.escape(a.title or a.slug)}</a>'
|
||||
f'<span class="slug">{a.slug}</span></li>'
|
||||
for a in atoms
|
||||
)
|
||||
head = f"<h2>{heading}</h2>" if heading else ""
|
||||
return f"{head}<ul class='atoms'>{items}</ul>"
|
||||
|
||||
|
||||
def _edges(atom: Atom, atoms: Mapping[str, Atom]) -> str:
|
||||
if not atom.edges:
|
||||
return ""
|
||||
rows = "".join(
|
||||
f"<tr><td>{html.escape(relation)}</td><td>"
|
||||
f'<a href="/atom/{target}">'
|
||||
f"{html.escape(atoms[target].title if target in atoms else target)}</a>"
|
||||
f"</td></tr>"
|
||||
for relation, target in atom.edges
|
||||
)
|
||||
return f"<h2>Edges</h2><table class='edges'>{rows}</table>"
|
||||
|
||||
|
||||
def _evidence(atom: Atom) -> str:
|
||||
if not atom.evidence:
|
||||
return ""
|
||||
items = "".join(
|
||||
f"<li><code>{html.escape(ref)}</code></li>" for ref in atom.evidence
|
||||
)
|
||||
return f"<h2>Evidence</h2><ul>{items}</ul>"
|
||||
|
||||
|
||||
def search_page(query: str, hits: tuple[Hit, ...]) -> str:
|
||||
"""Ranked results: title, slug, type, and the matching line."""
|
||||
count = f"{len(hits)} result{'' if len(hits) == 1 else 's'}"
|
||||
items = "".join(
|
||||
f'<li><a href="/atom/{hit.atom.slug}">'
|
||||
f"{html.escape(hit.atom.title or hit.atom.slug)}</a>"
|
||||
f'<span class="slug">{hit.atom.type} · {hit.atom.slug}</span>'
|
||||
f'<p class="snippet">{hit.snippet}</p></li>'
|
||||
for hit in hits
|
||||
)
|
||||
return (
|
||||
f"<h1>{html.escape(query)}</h1><p class='meta'>{count}</p>"
|
||||
f"<ul class='hits'>{items}</ul>"
|
||||
)
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,234 @@
|
||||
:root {
|
||||
--bg: #fdfdfc;
|
||||
--fg: #1f2328;
|
||||
--dim: #6a737d;
|
||||
--rule: #e3e3e0;
|
||||
--link: #1a5fb4;
|
||||
--dangling: #b02020;
|
||||
--code-bg: #f2f2ef;
|
||||
--mark: #fdf0a8;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #16181c;
|
||||
--fg: #d8d8d4;
|
||||
--dim: #8b949e;
|
||||
--rule: #2c2f36;
|
||||
--link: #79b8ff;
|
||||
--dangling: #f08a8a;
|
||||
--code-bg: #22252b;
|
||||
--mark: #4a4326;
|
||||
}
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font: 16px/1.6 -apple-system, "Inter", "Segoe UI", system-ui, sans-serif;
|
||||
}
|
||||
|
||||
header {
|
||||
border-bottom: 1px solid var(--rule);
|
||||
padding: 0.7rem 1.5rem;
|
||||
font-size: 0.85rem;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
color: var(--dim);
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 46rem;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1.5rem 6rem;
|
||||
}
|
||||
|
||||
a { color: var(--link); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
a[title="(no such atom)"] { color: var(--dangling); text-decoration: line-through; }
|
||||
|
||||
h1 { font-size: 1.6rem; line-height: 1.3; margin: 0 0 0.4rem; }
|
||||
h2 {
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--dim);
|
||||
margin: 2.2rem 0 0.6rem;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
padding-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.meta { color: var(--dim); font-size: 0.85rem; margin: 0 0 1.6rem; }
|
||||
|
||||
code, pre {
|
||||
font-family: "JetBrains Mono", ui-monospace, monospace;
|
||||
font-size: 0.85em;
|
||||
background: var(--code-bg);
|
||||
}
|
||||
code { padding: 0.1em 0.35em; border-radius: 3px; }
|
||||
pre { padding: 0.8rem 1rem; border-radius: 4px; overflow-x: auto; }
|
||||
pre code { background: none; padding: 0; }
|
||||
|
||||
blockquote {
|
||||
margin: 1.2rem 0;
|
||||
padding-left: 1rem;
|
||||
border-left: 3px solid var(--rule);
|
||||
color: var(--dim);
|
||||
}
|
||||
|
||||
ul.atoms { list-style: none; padding: 0; margin: 0; }
|
||||
ul.atoms li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.25rem 0;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
}
|
||||
ul.atoms .slug {
|
||||
color: var(--dim);
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
table { border-collapse: collapse; width: 100%; font-size: 0.9rem; }
|
||||
th, td { text-align: left; padding: 0.3rem 0.8rem 0.3rem 0; border-bottom: 1px solid var(--rule); }
|
||||
table.edges td:first-child {
|
||||
color: var(--dim);
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.8rem;
|
||||
width: 9rem;
|
||||
}
|
||||
|
||||
.katex-error { color: var(--dangling); }
|
||||
div.math { overflow-x: auto; padding: 0.4rem 0; }
|
||||
/* Typst math is inline SVG sized in em; display formulas are centred like KaTeX's. */
|
||||
/* 1.21em, as KaTeX: Computer Modern reads small next to a sans body. */
|
||||
.math.typst { font-size: 1.21em; }
|
||||
div.math.typst { text-align: center; }
|
||||
.math.typst svg { overflow: visible; }
|
||||
.math.typst.error code { color: var(--dangling); }
|
||||
|
||||
/* Annotations: the margin. A highlight is a link to its note and back. */
|
||||
mark {
|
||||
background: var(--mark);
|
||||
color: inherit;
|
||||
border-radius: 2px;
|
||||
padding: 0 0.1em;
|
||||
}
|
||||
mark a { color: inherit; text-decoration: none; }
|
||||
mark a:hover { text-decoration: underline; }
|
||||
|
||||
footer {
|
||||
border-top: 1px solid var(--rule);
|
||||
margin-top: 3rem;
|
||||
padding: 0.7rem 1.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--dim);
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--dim);
|
||||
font-size: 0.8rem;
|
||||
font-style: italic;
|
||||
margin: 1rem 0 0;
|
||||
}
|
||||
|
||||
ul.annotations { list-style: none; padding: 0; margin: 0; }
|
||||
li.ann {
|
||||
border-left: 3px solid var(--mark);
|
||||
padding: 0.2rem 0 0.2rem 0.9rem;
|
||||
margin-bottom: 1.2rem;
|
||||
}
|
||||
li.ann:target { background: var(--code-bg); }
|
||||
li.ann blockquote {
|
||||
margin: 0 0 0.4rem;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
font-size: 0.85rem;
|
||||
font-style: italic;
|
||||
}
|
||||
li.ann p { margin: 0 0 0.2rem; }
|
||||
li.ann .ann-id {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.7rem;
|
||||
color: var(--dim);
|
||||
}
|
||||
li.ann .meta { font-size: 0.7rem; margin: 0; }
|
||||
|
||||
#annotate {
|
||||
position: fixed;
|
||||
z-index: 10;
|
||||
right: 1.5rem;
|
||||
bottom: 1.5rem;
|
||||
width: min(24rem, calc(100vw - 3rem));
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 6px;
|
||||
padding: 0.9rem;
|
||||
box-shadow: 0 6px 24px rgb(0 0 0 / 18%);
|
||||
}
|
||||
#annotate blockquote {
|
||||
margin: 0 0 0.6rem;
|
||||
padding-left: 0.7rem;
|
||||
font-size: 0.8rem;
|
||||
max-height: 6rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
#annotate textarea {
|
||||
width: 100%;
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 4px;
|
||||
padding: 0.4rem 0.5rem;
|
||||
resize: vertical;
|
||||
}
|
||||
#annotate div { display: flex; gap: 0.5rem; margin-top: 0.5rem; }
|
||||
#annotate button {
|
||||
font: inherit;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.25rem 0.8rem;
|
||||
background: none;
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
#annotate button:hover { border-color: var(--link); color: var(--link); }
|
||||
|
||||
header form { margin-left: auto; }
|
||||
header input {
|
||||
font: inherit;
|
||||
color: var(--fg);
|
||||
background: var(--code-bg);
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 4px;
|
||||
padding: 0.15rem 0.5rem;
|
||||
width: 14rem;
|
||||
}
|
||||
ul.hits { list-style: none; padding: 0; }
|
||||
ul.hits li { margin: 0 0 1.1rem; }
|
||||
ul.hits .slug { margin-left: 0.6rem; color: var(--dim); font-size: 0.8rem; }
|
||||
ul.hits .snippet { margin: 0.15rem 0 0; color: var(--dim); font-size: 0.9rem; }
|
||||
ul.hits mark { background: var(--mark); color: inherit; }
|
||||
|
||||
mark[title] { cursor: pointer; }
|
||||
li.ann { position: relative; }
|
||||
li.ann .ann-delete {
|
||||
position: absolute;
|
||||
top: 0.1rem;
|
||||
right: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--dim);
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
li.ann .ann-delete:hover { color: var(--dangling); }
|
||||
@@ -0,0 +1,214 @@
|
||||
// Render every <span|div class="math" data-tex> with KaTeX. Typst math
|
||||
// (`math: typst` atoms) arrives as SVG and carries no data-tex.
|
||||
//
|
||||
// Deliberately not KaTeX's auto-render extension: the TeX arrives in an
|
||||
// attribute, already isolated from the markdown parser, so there are no
|
||||
// delimiters left to scan for and no way to mistake a dollar sign in prose
|
||||
// for the start of an equation.
|
||||
//
|
||||
// throwOnError: false makes a broken formula render as red source rather than
|
||||
// blanking the page — a loud failure that still lets you read the rest.
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
// Before the math, not after: a KaTeX failure must not be able to take the
|
||||
// margin down with it. That ordering is not hypothetical — a page with one
|
||||
// bad formula would otherwise silently lose the ability to annotate it,
|
||||
// which is precisely the page you want to say something about.
|
||||
annotate();
|
||||
// Also before the math: KaTeX fills the empty math spans with text, which
|
||||
// would sit between the words of a quote and stop it from matching.
|
||||
highlight();
|
||||
// `/` jumps to search from anywhere but a text field, as on most wikis.
|
||||
document.addEventListener("keydown", (event) => {
|
||||
const typing = event.target.closest?.("input, textarea");
|
||||
if (event.key === "/" && !typing) {
|
||||
event.preventDefault();
|
||||
document.getElementById("q").focus();
|
||||
}
|
||||
});
|
||||
for (const el of document.querySelectorAll(".math[data-tex]")) {
|
||||
katex.render(el.dataset.tex, el, {
|
||||
displayMode: el.dataset.display === "true",
|
||||
throwOnError: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Select text in an atom's body, say something about it, POST it to the
|
||||
// sidecar. The atom file is never touched — see server.py's invariants.
|
||||
//
|
||||
// The quote is the anchor, so `exact` is taken verbatim from the selection.
|
||||
// `prefix` and `suffix` come from the enclosing block's text: they are stored
|
||||
// for a future re-anchoring pass and read by nothing today.
|
||||
function annotate() {
|
||||
const panel = document.getElementById("annotate");
|
||||
const body = document.querySelector(".body");
|
||||
if (!panel || !body) return;
|
||||
|
||||
const quote = document.getElementById("annotate-quote");
|
||||
const input = document.getElementById("annotate-body");
|
||||
const hint = document.getElementById("annotate-hint");
|
||||
let pending = null;
|
||||
|
||||
// The page ships claiming the margin is dead; this is what disproves it.
|
||||
if (hint) hint.textContent = "select any text above to annotate it";
|
||||
|
||||
// Deleting appends a withdrawal to the sidecar (see annotations.py); the
|
||||
// reload is the confirmation, as for saving.
|
||||
for (const button of document.querySelectorAll(".ann-delete")) {
|
||||
button.addEventListener("click", async () => {
|
||||
const response = await fetch("/annotate/delete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id: button.dataset.id }),
|
||||
});
|
||||
if (response.ok) window.location.reload();
|
||||
});
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
panel.hidden = true;
|
||||
input.value = "";
|
||||
pending = null;
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!pending || !input.value.trim()) return;
|
||||
const response = await fetch("/annotate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ...pending, body: input.value }),
|
||||
});
|
||||
// A failed save must not look like a successful one: the note only exists
|
||||
// once the file has it, and a reload is the cheapest honest confirmation.
|
||||
if (!response.ok) {
|
||||
input.setCustomValidity(await response.text());
|
||||
input.reportValidity();
|
||||
return;
|
||||
}
|
||||
const { id } = await response.json();
|
||||
window.location.hash = id;
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
// On `document`, not on `.body`: a drag that ends past the last line, in the
|
||||
// margin, or on the heading releases the mouse outside `.body` and fires no
|
||||
// listener bound there. What decides is where the *selection* sits, and it
|
||||
// must sit entirely inside the body — a selection running from the prose
|
||||
// into the margin would store a quote that can never anchor again.
|
||||
const offer = () => {
|
||||
const selection = window.getSelection();
|
||||
const exact = selection.toString().trim();
|
||||
if (!exact || selection.rangeCount === 0) return;
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
if (!body.contains(range.commonAncestorContainer)) return;
|
||||
|
||||
const anchor =
|
||||
range.startContainer.nodeType === Node.ELEMENT_NODE
|
||||
? range.startContainer
|
||||
: range.startContainer.parentElement;
|
||||
const block = anchor && anchor.closest("p, li, h1, h2, h3, blockquote, td");
|
||||
const full = block ? block.textContent : "";
|
||||
const at = full.indexOf(exact);
|
||||
pending = {
|
||||
slug: body.dataset.slug,
|
||||
exact,
|
||||
prefix: at > 0 ? full.slice(Math.max(0, at - 48), at) : "",
|
||||
suffix: at >= 0 ? full.slice(at + exact.length, at + exact.length + 48) : "",
|
||||
};
|
||||
quote.textContent = exact;
|
||||
// A new selection starts a new note: never inherit the last one's text.
|
||||
input.value = "";
|
||||
input.setCustomValidity("");
|
||||
panel.hidden = false;
|
||||
// Focusing drops the browser's own selection highlight, which is why the
|
||||
// panel echoes the quote: the passage stays visible in the place you are
|
||||
// about to write about it, and typing starts with no extra click.
|
||||
input.focus();
|
||||
};
|
||||
|
||||
document.addEventListener("mouseup", offer);
|
||||
document.addEventListener("keyup", (event) => {
|
||||
if (event.shiftKey || event.key === "Shift") offer();
|
||||
});
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) save();
|
||||
if (event.key === "Escape" && !panel.hidden) close();
|
||||
});
|
||||
|
||||
input.addEventListener("input", () => input.setCustomValidity(""));
|
||||
document.getElementById("annotate-save").addEventListener("click", save);
|
||||
document.getElementById("annotate-cancel").addEventListener("click", close);
|
||||
}
|
||||
|
||||
// Mark each margin note's quote in the rendered body.
|
||||
//
|
||||
// Matching is on text with whitespace collapsed, not on HTML: the quote was
|
||||
// taken from a browser selection, so it has no tags, no entities, and spaces
|
||||
// where the source had line breaks. A match may cross elements (a quote
|
||||
// through **bold** or a link), so each text node it touches gets its own
|
||||
// <mark>. A quote that is not found stays listed in the margin, unmarked.
|
||||
function highlight() {
|
||||
const body = document.querySelector(".body");
|
||||
if (!body) return;
|
||||
for (const note of document.querySelectorAll("li.ann[data-exact]")) {
|
||||
const range = findText(body, note.dataset.exact);
|
||||
if (range) mark(range, note.id);
|
||||
}
|
||||
}
|
||||
|
||||
// A Range over the first occurrence of `exact` in `root`'s text, or null.
|
||||
function findText(root, exact) {
|
||||
const needle = exact.replace(/\s+/g, " ").trim();
|
||||
if (!needle) return null;
|
||||
// `flat` is the collapsed text; `at[i]` is where flat[i] came from.
|
||||
let flat = "";
|
||||
const at = [];
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
||||
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
||||
const text = node.nodeValue;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const space = /\s/.test(text[i]);
|
||||
if (space && (flat === "" || flat.endsWith(" "))) continue;
|
||||
flat += space ? " " : text[i];
|
||||
at.push([node, i]);
|
||||
}
|
||||
}
|
||||
const start = flat.indexOf(needle);
|
||||
if (start < 0) return null;
|
||||
const [startNode, startOffset] = at[start];
|
||||
const [endNode, endOffset] = at[start + needle.length - 1];
|
||||
const range = document.createRange();
|
||||
range.setStart(startNode, startOffset);
|
||||
range.setEnd(endNode, endOffset + 1);
|
||||
return range;
|
||||
}
|
||||
|
||||
// Wrap every text-node piece of `range` in a <mark> pointing at note `id`.
|
||||
function mark(range, id) {
|
||||
const root = range.commonAncestorContainer;
|
||||
const nodes = [];
|
||||
if (root.nodeType === Node.TEXT_NODE) {
|
||||
nodes.push(root);
|
||||
} else {
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
||||
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
||||
if (range.intersectsNode(node)) nodes.push(node);
|
||||
}
|
||||
}
|
||||
// Last to first: wrapping splits a text node, and the range's start node is
|
||||
// the only one whose offsets would shift if it were split first.
|
||||
nodes.reverse().forEach((node, index) => {
|
||||
const from = node === range.startContainer ? range.startOffset : 0;
|
||||
const to = node === range.endContainer ? range.endOffset : node.nodeValue.length;
|
||||
if (!node.nodeValue.slice(from, to).trim()) return;
|
||||
const piece = document.createRange();
|
||||
piece.setStart(node, from);
|
||||
piece.setEnd(node, to);
|
||||
const el = document.createElement("mark");
|
||||
el.title = id;
|
||||
el.addEventListener("click", () => { window.location.hash = id; });
|
||||
if (index === nodes.length - 1) el.id = `q-${id}`; // the first piece
|
||||
piece.surroundContents(el);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Typst math to inline SVG, for atoms whose frontmatter says `math: typst`.
|
||||
|
||||
Server-side, unlike KaTeX: there is no Typst in the browser worth shipping, and
|
||||
the `typst` binary is already on every machine the notes are written on.
|
||||
|
||||
Invariants:
|
||||
- One `typst` run per page, not per formula: each formula is a page of one
|
||||
document, so a page with fifty formulas costs one compile. Results are
|
||||
cached per (source, display) for the life of the process.
|
||||
- A formula that does not compile renders as its source, marked, with the
|
||||
error as a tooltip; it never takes its neighbours down. A failed batch is
|
||||
retried one formula at a time to find the culprit.
|
||||
- Inline SVGs sit on the text baseline. Typst does not put the baseline in
|
||||
the SVG, so a zero-size box labelled `<b>` after each inline formula marks
|
||||
it, and `typst eval` reads its position back.
|
||||
- Glyphs are filled with `currentColor`, so the math follows the theme.
|
||||
"""
|
||||
|
||||
import html
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
# The size formulas are set at; SVG lengths are divided by it to become `em`,
|
||||
# so the math scales with the surrounding text instead of being fixed points.
|
||||
SIZE_PT = 11
|
||||
|
||||
_PREAMBLE = f"""\
|
||||
#set page(width: auto, height: auto, margin: 0pt, fill: none)
|
||||
#set text(size: {SIZE_PT}pt, top-edge: "bounds", bottom-edge: "bounds")
|
||||
"""
|
||||
_MARK = "#box(width: 0pt, height: 0pt)<b>"
|
||||
_BASELINES = (
|
||||
"query(<b>).map(it => it.location().position()).map(p => (p.page, p.y.pt()))"
|
||||
)
|
||||
_LENGTH = re.compile(r'(width|height)="([\d.]+)pt"')
|
||||
|
||||
|
||||
def render(formulas: list[tuple[str, bool]]) -> list[str]:
|
||||
"""HTML for each `(source, display)`, in order, compiling what is not cached."""
|
||||
missing = [f for f in dict.fromkeys(formulas) if f not in _CACHE]
|
||||
if missing:
|
||||
try:
|
||||
_CACHE.update(zip(missing, _compile(missing), strict=True))
|
||||
except _TypstError:
|
||||
for one in missing:
|
||||
try:
|
||||
_CACHE[one] = _compile([one])[0]
|
||||
except _TypstError as error:
|
||||
_CACHE[one] = _failed(one, str(error))
|
||||
return [_CACHE[f] for f in formulas]
|
||||
|
||||
|
||||
_CACHE: dict[tuple[str, bool], str] = {}
|
||||
|
||||
|
||||
class _TypstError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _compile(formulas: list[tuple[str, bool]]) -> list[str]:
|
||||
pages = "\n#pagebreak()\n".join(
|
||||
f"$ {source} $" if display else f"${source}${_MARK}"
|
||||
for source, display in formulas
|
||||
)
|
||||
with tempfile.TemporaryDirectory(prefix="pyroli-typst-") as tmp:
|
||||
doc = Path(tmp) / "math.typ"
|
||||
doc.write_text(_PREAMBLE + pages + "\n", encoding="utf-8")
|
||||
_typst("compile", str(doc), str(Path(tmp) / "{p}.svg"))
|
||||
baselines = dict(json.loads(_typst("eval", _BASELINES, "--in", str(doc))))
|
||||
return [
|
||||
_html(
|
||||
(Path(tmp) / f"{page}.svg").read_text(encoding="utf-8"),
|
||||
baselines.get(page),
|
||||
display=display,
|
||||
)
|
||||
for page, (_, display) in enumerate(formulas, start=1)
|
||||
]
|
||||
|
||||
|
||||
def _typst(*args: str) -> str:
|
||||
binary = shutil.which("typst")
|
||||
if binary is None:
|
||||
msg = "typst is not on PATH"
|
||||
raise _TypstError(msg)
|
||||
done = subprocess.run( # noqa: S603 (fixed argv, no shell)
|
||||
[binary, *args, "--ignore-system-fonts"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if done.returncode != 0:
|
||||
raise _TypstError(done.stderr.strip())
|
||||
return done.stdout
|
||||
|
||||
|
||||
def _html(svg: str, baseline: float | None, *, display: bool) -> str:
|
||||
height = float(m.group(1)) if (m := re.search(r'height="([\d.]+)pt"', svg)) else 0
|
||||
svg = _LENGTH.sub(lambda m: f'{m[1]}="{float(m[2]) / SIZE_PT:.4f}em"', svg, count=2)
|
||||
svg = svg.replace('fill="#000000"', 'fill="currentColor"')
|
||||
if display:
|
||||
return f'<div class="math typst" data-display="true">{svg}</div>'
|
||||
descent = (height - baseline) / SIZE_PT if baseline is not None else 0
|
||||
return (
|
||||
f'<span class="math typst" data-display="false" '
|
||||
f'style="vertical-align: {-descent:.4f}em">{svg}</span>'
|
||||
)
|
||||
|
||||
|
||||
def _failed(formula: tuple[str, bool], error: str) -> str:
|
||||
source, display = formula
|
||||
tag = "div" if display else "span"
|
||||
return (
|
||||
f'<{tag} class="math typst error" title="{html.escape(error, quote=True)}">'
|
||||
f"<code>{html.escape(source)}</code></{tag}>"
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""Pure renderers: parsed objects in, `rich` renderables out. No I/O here."""
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Render the knowledge-check report: a summary line and one issue table.
|
||||
|
||||
Pure: atoms and issues in, `rich` renderable out.
|
||||
|
||||
Conventions:
|
||||
- Details carry `[[wikilinks]]`, which rich markup would eat; every cell
|
||||
that can contain one is escaped.
|
||||
- Errors sort first, so a long table still opens on what fails a gate.
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
|
||||
from rich.console import Group, RenderableType
|
||||
from rich.markup import escape
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
|
||||
from pyroli.atoms import Atom
|
||||
from pyroli.checks import Issue
|
||||
|
||||
_SEVERITY_STYLE = {"error": "red", "warn": "yellow"}
|
||||
|
||||
|
||||
def render(
|
||||
atoms: Mapping[str, Atom],
|
||||
issues: Sequence[Issue],
|
||||
sources: Mapping[str, str],
|
||||
cited: frozenset[str],
|
||||
) -> Group:
|
||||
"""Render the census line, the issue table, and the citation coverage line."""
|
||||
parts: list[RenderableType] = [_census(atoms, sources, cited)]
|
||||
parts.append(_table(issues) if issues else Text("all checks pass", style="green"))
|
||||
return Group(*parts)
|
||||
|
||||
|
||||
def _census(
|
||||
atoms: Mapping[str, Atom],
|
||||
sources: Mapping[str, str],
|
||||
cited: frozenset[str],
|
||||
) -> Text:
|
||||
counts: dict[str, int] = {}
|
||||
for atom in atoms.values():
|
||||
counts[atom.type] = counts.get(atom.type, 0) + 1
|
||||
census = ", ".join(f"{count} {name}" for name, count in sorted(counts.items()))
|
||||
uncited = len(atoms) - len(cited & frozenset(atoms))
|
||||
return Text.from_markup(
|
||||
f"[bold]{len(atoms)} atoms[/bold] — {census}; "
|
||||
f"{len(cited)} cited from {len(sources)} sources, {uncited} uncited"
|
||||
)
|
||||
|
||||
|
||||
def _table(issues: Sequence[Issue]) -> Table:
|
||||
table = Table()
|
||||
table.add_column("sev", no_wrap=True)
|
||||
table.add_column("check", no_wrap=True)
|
||||
table.add_column("atom", style="cyan", no_wrap=True)
|
||||
table.add_column("detail", overflow="fold")
|
||||
for issue in sorted(issues, key=lambda i: (i.severity, i.check, i.slug)):
|
||||
table.add_row(
|
||||
Text(issue.severity, style=_SEVERITY_STYLE.get(issue.severity, "")),
|
||||
issue.check,
|
||||
escape(issue.slug),
|
||||
escape(issue.detail),
|
||||
)
|
||||
return table
|
||||
@@ -0,0 +1,129 @@
|
||||
# The ontology: node types and relation types. Everything else reads this file.
|
||||
#
|
||||
# This is the shipped default. A repository overrides it completely by placing
|
||||
# its own `schema.yaml` at its root; `pyroli schema --dump` writes this one out
|
||||
# as a starting point. The corpus plus this file are what must survive — every
|
||||
# tool that reads them, including pyroli, is replaceable.
|
||||
#
|
||||
# Three groups, because the SWE/research split is not clean at the edges. A
|
||||
# `concept` is true regardless of which half produced it, a `gotcha` is
|
||||
# portable the same way, and a `source` is a source. Forcing those into one
|
||||
# half is how you get the same page written twice.
|
||||
#
|
||||
# research — discourse graphs: question / claim / evidence / experiment
|
||||
# swe — Diataxis + pattern language: howto / reference / pattern /
|
||||
# decision
|
||||
# shared — concept / gotcha / source
|
||||
#
|
||||
# The type is also the directory: `knowledge/claim/foo.md`. That way
|
||||
# `ls knowledge/` prints the ontology with no tool involved.
|
||||
|
||||
# What a status means to the propagation check, for the statuses where it
|
||||
# matters. Anything unlisted is in-progress and propagates nothing: an `open`
|
||||
# claim is allowed to depend on a refuted one, because reacting to a
|
||||
# refutation is exactly the work that status describes.
|
||||
status_classes:
|
||||
settled: [stable, supported, verified, done, accepted, answered, read]
|
||||
dead: [refuted, abandoned, dropped, aborted, superseded]
|
||||
|
||||
node_types:
|
||||
concept:
|
||||
statuses: [stub, draft, stable]
|
||||
diataxis: explanation
|
||||
gotcha:
|
||||
statuses: [stub, draft, stable]
|
||||
source:
|
||||
statuses: [unread, read]
|
||||
requires: [citekey]
|
||||
|
||||
question:
|
||||
statuses: [open, answered, dropped]
|
||||
claim:
|
||||
statuses: [open, supported, refuted, abandoned]
|
||||
evidence:
|
||||
statuses: [draft, verified]
|
||||
requires: [provenance]
|
||||
experiment:
|
||||
statuses: [planned, running, done, aborted]
|
||||
|
||||
howto:
|
||||
statuses: [stub, draft, stable]
|
||||
diataxis: how-to
|
||||
reference:
|
||||
statuses: [stub, draft, stable]
|
||||
diataxis: reference
|
||||
pattern:
|
||||
statuses: [stub, draft, stable]
|
||||
decision:
|
||||
statuses: [proposed, accepted, superseded]
|
||||
|
||||
relation_types:
|
||||
# Research: the discourse-graph edges. These are the contested ones — the
|
||||
# ones worth promoting to their own node when you want to qualify them.
|
||||
#
|
||||
# Domain and range are both `claim` plus `gotcha`, because both are
|
||||
# assertions: a gotcha is a normative claim that happens to be portable, so
|
||||
# it supports a project claim the way evidence does, and a measurement
|
||||
# supports it the way a measurement supports anything. rfm-jlens has three
|
||||
# measured claims supporting a gotcha ("the vocabulary must be centred"), and
|
||||
# a range of `[claim]` alone rejected all three.
|
||||
#
|
||||
# `concept` is in the domain and deliberately not in the range: a definition
|
||||
# can be a reason, but it is not the kind of thing a measurement confirms.
|
||||
supports:
|
||||
inverse: supported_by
|
||||
domain: [evidence, claim, concept, gotcha]
|
||||
range: [claim, gotcha]
|
||||
contradicts:
|
||||
inverse: contradicted_by
|
||||
domain: [evidence, claim, concept, gotcha]
|
||||
range: [claim, gotcha]
|
||||
# `contradicts` is tension; `refutes` is a decision that has been taken, and
|
||||
# it is the one the propagation check reacts to. Keeping them distinct is
|
||||
# what lets a refuted claim stay in the graph instead of being deleted.
|
||||
refutes:
|
||||
inverse: refuted_by
|
||||
domain: [evidence, claim, concept, gotcha]
|
||||
range: [claim, gotcha]
|
||||
tests:
|
||||
inverse: tested_by
|
||||
domain: [experiment]
|
||||
range: [claim, question]
|
||||
answers:
|
||||
inverse: answered_by
|
||||
domain: [claim, evidence]
|
||||
range: [question]
|
||||
raises:
|
||||
inverse: raised_by
|
||||
domain: ["*"]
|
||||
range: [question]
|
||||
|
||||
# Structural: cheap, uncontested, stay in frontmatter forever.
|
||||
depends_on:
|
||||
inverse: required_by
|
||||
transitive: true
|
||||
refines:
|
||||
inverse: refined_by
|
||||
broader:
|
||||
inverse: narrower # SKOS-flavoured: not subclass, not transitive
|
||||
|
||||
# Citation, a frozen CiTO subset. Range is always a source node; citing a
|
||||
# paper you have no node for is a `citekey`, not an edge.
|
||||
cites_as_evidence:
|
||||
range: [source]
|
||||
uses_method_in:
|
||||
range: [source]
|
||||
extends:
|
||||
range: [source]
|
||||
disagrees_with:
|
||||
range: [source]
|
||||
confirms:
|
||||
range: [source]
|
||||
background_from:
|
||||
range: [source]
|
||||
|
||||
# SWE
|
||||
supersedes:
|
||||
inverse: superseded_by
|
||||
domain: [decision]
|
||||
range: [decision]
|
||||
@@ -0,0 +1,134 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from pyroli import annotations
|
||||
from pyroli.annotations import Annotation, Selector
|
||||
|
||||
WHEN = datetime(2026, 8, 18, 12, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
def note(id_: str = "ANN-0001", slug: str = "a", exact: str = "the claim"):
|
||||
return Annotation(
|
||||
id=id_,
|
||||
slug=slug,
|
||||
selector=Selector(exact=exact, prefix="before ", suffix=" after"),
|
||||
body="this conflates two things",
|
||||
created=WHEN.isoformat(timespec="seconds"),
|
||||
)
|
||||
|
||||
|
||||
def test_a_record_survives_a_round_trip():
|
||||
original = note()
|
||||
(parsed,) = annotations.parse(annotations.serialize(original))
|
||||
assert parsed == original
|
||||
|
||||
|
||||
def test_blank_lines_are_skipped():
|
||||
text = (
|
||||
annotations.serialize(note()) + "\n" + annotations.serialize(note("ANN-0002"))
|
||||
)
|
||||
assert [one.id for one in annotations.parse(text)] == ["ANN-0001", "ANN-0002"]
|
||||
|
||||
|
||||
def test_a_malformed_line_names_its_number():
|
||||
text = annotations.serialize(note()) + "{not json\n"
|
||||
with pytest.raises(ValueError, match=r"annotations\.jsonl:2: not valid JSON"):
|
||||
annotations.parse(text)
|
||||
|
||||
|
||||
def test_a_line_missing_a_key_names_the_key():
|
||||
with pytest.raises(ValueError, match="missing key body"):
|
||||
annotations.parse(
|
||||
'{"id": "ANN-0001", "slug": "a", "created": "x", '
|
||||
'"selector": {"exact": "q"}}\n'
|
||||
)
|
||||
|
||||
|
||||
def test_ids_continue_after_the_highest_already_there():
|
||||
existing = (note("ANN-0001"), note("ANN-0007"))
|
||||
created = annotations.create(
|
||||
existing, slug="a", selector=Selector(exact="q"), body="hm", now=WHEN
|
||||
)
|
||||
assert created.id == "ANN-0008"
|
||||
|
||||
|
||||
def test_the_first_id_is_one():
|
||||
created = annotations.create(
|
||||
(), slug="a", selector=Selector(exact="q"), body="hm", now=WHEN
|
||||
)
|
||||
assert created.id == "ANN-0001"
|
||||
|
||||
|
||||
def test_context_is_clipped_to_the_stored_width():
|
||||
created = annotations.create(
|
||||
(),
|
||||
slug="a",
|
||||
selector=Selector(exact="q", prefix="x" * 200, suffix="y" * 200),
|
||||
body="hm",
|
||||
now=WHEN,
|
||||
)
|
||||
assert len(created.selector.prefix) == annotations.CONTEXT
|
||||
assert len(created.selector.suffix) == annotations.CONTEXT
|
||||
|
||||
|
||||
def test_an_annotation_with_no_quote_or_no_body_is_refused():
|
||||
with pytest.raises(ValueError, match="quoted passage"):
|
||||
annotations.create((), slug="a", selector=Selector(exact=" "), body="hm")
|
||||
with pytest.raises(ValueError, match="something to say"):
|
||||
annotations.create((), slug="a", selector=Selector(exact="q"), body=" ")
|
||||
|
||||
|
||||
def test_orphans_are_notes_whose_quote_is_gone():
|
||||
notes = (
|
||||
note("ANN-0001", exact="still here"),
|
||||
note("ANN-0002", exact="rewritten away"),
|
||||
)
|
||||
bodies = {"a": "a body that has still here in it"}
|
||||
assert [one.id for one in annotations.orphans(notes, bodies)] == ["ANN-0002"]
|
||||
|
||||
|
||||
def test_a_note_on_a_deleted_atom_is_orphaned_too():
|
||||
notes = (note("ANN-0001", slug="gone"),)
|
||||
assert annotations.orphans(notes, {}) == notes
|
||||
|
||||
|
||||
def test_on_selects_by_atom():
|
||||
notes = (note("ANN-0001", slug="a"), note("ANN-0002", slug="b"))
|
||||
assert [one.id for one in annotations.on(notes, "b")] == ["ANN-0002"]
|
||||
|
||||
|
||||
def test_a_deletion_is_an_appended_record_and_the_note_stays_in_the_file():
|
||||
text = annotations.serialize(note()) + annotations.serialize_deletion(
|
||||
"ANN-0001", now=WHEN
|
||||
)
|
||||
(parsed,) = annotations.parse(text)
|
||||
assert parsed.deleted == WHEN.isoformat(timespec="seconds")
|
||||
assert annotations.live((parsed,)) == ()
|
||||
|
||||
|
||||
def test_a_deleted_id_is_never_reused():
|
||||
text = (
|
||||
annotations.serialize(note("ANN-0001"))
|
||||
+ annotations.serialize(note("ANN-0002"))
|
||||
+ annotations.serialize_deletion("ANN-0002", now=WHEN)
|
||||
)
|
||||
created = annotations.create(
|
||||
annotations.parse(text), slug="a", selector=Selector(exact="q"), body="hm"
|
||||
)
|
||||
assert created.id == "ANN-0003"
|
||||
|
||||
|
||||
def test_deleting_an_unknown_id_names_the_line():
|
||||
text = annotations.serialize_deletion("ANN-0009", now=WHEN)
|
||||
with pytest.raises(ValueError, match=r"annotations\.jsonl:1: .*ANN-0009"):
|
||||
annotations.parse(text)
|
||||
|
||||
|
||||
def test_deleted_notes_are_neither_shown_nor_orphans():
|
||||
text = annotations.serialize(note()) + annotations.serialize_deletion(
|
||||
"ANN-0001", now=WHEN
|
||||
)
|
||||
parsed = annotations.parse(text)
|
||||
assert annotations.on(parsed, "a") == ()
|
||||
assert annotations.orphans(parsed, {}) == ()
|
||||
@@ -0,0 +1,184 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from pyroli import atoms, schema
|
||||
from pyroli import atoms as atoms_mod
|
||||
from pyroli.atoms import link_titles, parse, select
|
||||
|
||||
ATOM = """---
|
||||
title: The moment split
|
||||
type: evidence
|
||||
status: verified
|
||||
tags: [jlens, rfm_agop]
|
||||
confidence: high
|
||||
importance: 4
|
||||
verify: rg -c moment experiments/
|
||||
evidence:
|
||||
- experiments/e3_token_moments.py
|
||||
edges:
|
||||
depends_on: [h3-mean-covariance-split]
|
||||
refutes: [c3-layer-local-nfa]
|
||||
---
|
||||
|
||||
**Claim.** Body citing [[effective-rank]] and [[effective-rank]] again.
|
||||
"""
|
||||
|
||||
|
||||
def atom(text: str = ATOM, name: str = "moment-split"):
|
||||
return parse(Path(f"knowledge/evidence/{name}.md"), text)
|
||||
|
||||
|
||||
def test_frontmatter_fields_are_parsed():
|
||||
parsed = atom()
|
||||
assert parsed.slug == "moment-split"
|
||||
assert parsed.type == "evidence"
|
||||
assert parsed.tags == ("jlens", "rfm_agop")
|
||||
assert parsed.confidence == "high"
|
||||
assert parsed.importance == 4
|
||||
assert parsed.evidence == ("experiments/e3_token_moments.py",)
|
||||
|
||||
|
||||
def test_unknown_frontmatter_keys_are_kept_not_rejected():
|
||||
parsed = atom()
|
||||
assert parsed.extra == {"verify": "rg -c moment experiments/"}
|
||||
assert parsed.has("verify")
|
||||
assert parsed.has("evidence")
|
||||
assert not parsed.has("provenance")
|
||||
|
||||
|
||||
def test_edges_flatten_to_relation_target_pairs():
|
||||
assert atom().edges == (
|
||||
("depends_on", "h3-mean-covariance-split"),
|
||||
("refutes", "c3-layer-local-nfa"),
|
||||
)
|
||||
|
||||
|
||||
def test_links_are_deduplicated():
|
||||
assert atom().links == frozenset({"effective-rank"})
|
||||
|
||||
|
||||
def test_missing_frontmatter_raises():
|
||||
with pytest.raises(ValueError, match="malformed YAML frontmatter"):
|
||||
atom("no frontmatter here\n")
|
||||
|
||||
|
||||
def test_link_titles_renders_the_title_not_the_slug():
|
||||
atoms = {"moment-split": atom()}
|
||||
rendered = link_titles("see [[moment-split]]", atoms)
|
||||
assert rendered == "see [The moment split](knowledge/moment-split.md)"
|
||||
|
||||
|
||||
def test_an_alias_resolves_like_a_slug_and_renders_the_canonical_path():
|
||||
renamed = atom(
|
||||
ATOM.replace("type: evidence", "type: evidence\naliases: [old-name]")
|
||||
)
|
||||
atoms = {"moment-split": renamed}
|
||||
assert atoms_mod.resolvable(atoms)["old-name"] is renamed
|
||||
rendered = link_titles("see [[old-name]]", atoms)
|
||||
assert rendered == "see [The moment split](knowledge/moment-split.md)"
|
||||
|
||||
|
||||
def test_an_alias_shadowing_another_atom_raises():
|
||||
a = atom(ATOM.replace("type: evidence", "type: evidence\naliases: [taken]"), "a")
|
||||
b = atom(ATOM.replace("type: evidence", "type: evidence\naliases: [taken]"), "b")
|
||||
with pytest.raises(ValueError, match="alias 'taken' is already taken"):
|
||||
atoms_mod.resolvable({"a": a, "b": b})
|
||||
|
||||
|
||||
def test_link_titles_raises_on_an_atom_nobody_wrote():
|
||||
with pytest.raises(KeyError, match="ghost"):
|
||||
link_titles("see [[ghost]]", {})
|
||||
|
||||
|
||||
def _concept():
|
||||
return atom(
|
||||
ATOM.replace("type: evidence", "type: concept").replace(
|
||||
"status: verified", "status: stable"
|
||||
),
|
||||
"agop",
|
||||
)
|
||||
|
||||
|
||||
def test_select_orders_by_the_schema_declaration_order():
|
||||
order = schema.load(Path(__file__).parent, Path(__file__).parent).order
|
||||
atoms = {"moment-split": atom(), "agop": _concept()}
|
||||
chosen = select(atoms, tag="rfm_agop", order=order)
|
||||
assert [a.slug for a in chosen] == ["agop", "moment-split"]
|
||||
assert select(atoms, tag="absent") == ()
|
||||
|
||||
|
||||
def test_select_filters_combine():
|
||||
atoms = {"moment-split": atom(), "agop": _concept()}
|
||||
assert [a.slug for a in select(atoms, node_type="concept")] == ["agop"]
|
||||
assert select(atoms, tag="absent", node_type="concept") == ()
|
||||
|
||||
|
||||
def test_unparseable_yaml_names_the_file():
|
||||
broken = ATOM.replace("title: The moment split", "title: `backtick`")
|
||||
with pytest.raises(ValueError, match=r"moment-split\.md: frontmatter is not valid"):
|
||||
atom(broken)
|
||||
|
||||
|
||||
def test_scan_walks_subdirectories_and_skips_dot_dirs(tmp_path: Path):
|
||||
(tmp_path / "topic").mkdir()
|
||||
(tmp_path / ".zk" / "templates").mkdir(parents=True)
|
||||
body = "---\ntitle: A\ntype: concept\n---\n\n**A.**\n"
|
||||
(tmp_path / "topic" / "a.md").write_text(body)
|
||||
(tmp_path / ".zk" / "templates" / "default.md").write_text(body)
|
||||
found, strays = atoms.scan(tmp_path)
|
||||
assert list(found) == ["a"]
|
||||
assert strays == ()
|
||||
|
||||
|
||||
def test_scan_returns_markdown_without_frontmatter_as_a_stray(tmp_path: Path):
|
||||
(tmp_path / "inbox.md").write_text("# links\n\n- https://example.com\n")
|
||||
found, strays = atoms.scan(tmp_path)
|
||||
assert found == {}
|
||||
assert [path.as_posix() for path in strays] == ["inbox.md"]
|
||||
|
||||
|
||||
def test_scan_still_raises_on_frontmatter_that_is_present_and_broken(tmp_path: Path):
|
||||
(tmp_path / "broken.md").write_text("---\ntitle: `oops\n---\n\n**x**\n")
|
||||
with pytest.raises(ValueError, match=r"broken\.md"):
|
||||
atoms.scan(tmp_path)
|
||||
|
||||
|
||||
def test_scan_rejects_two_files_sharing_a_slug(tmp_path: Path):
|
||||
body = "---\ntitle: A\ntype: concept\n---\n\n**A.**\n"
|
||||
(tmp_path / "one").mkdir()
|
||||
(tmp_path / "two").mkdir()
|
||||
(tmp_path / "one" / "a.md").write_text(body)
|
||||
(tmp_path / "two" / "a.md").write_text(body)
|
||||
with pytest.raises(ValueError, match=r"duplicate slug 'a'"):
|
||||
atoms.scan(tmp_path)
|
||||
|
||||
|
||||
def test_load_of_a_missing_directory_is_a_file_not_found(tmp_path: Path):
|
||||
with pytest.raises(FileNotFoundError):
|
||||
atoms.load(tmp_path / "absent")
|
||||
|
||||
|
||||
def test_a_wikilink_inside_backticks_is_not_a_citation():
|
||||
"""Writing *about* the link syntax must not create a dangling link."""
|
||||
text = ATOM.replace(
|
||||
"Body citing [[effective-rank]]",
|
||||
"Body citing [[effective-rank]] but not `[[example-slug]]`",
|
||||
)
|
||||
assert atom(text).links == frozenset({"effective-rank"})
|
||||
|
||||
|
||||
def test_a_wikilink_inside_a_fence_is_not_a_citation():
|
||||
fenced = ATOM.replace(
|
||||
"**Claim.** Body citing [[effective-rank]] and [[effective-rank]] again.",
|
||||
"**Claim.**\n\n```\nedges:\n supports: [[not-a-link]]\n```\n",
|
||||
)
|
||||
assert atom(fenced).links == frozenset()
|
||||
|
||||
|
||||
def test_link_titles_leaves_code_alone():
|
||||
atoms = {"moment-split": atom()}
|
||||
rendered = link_titles("see [[moment-split]] and `[[moment-split]]`", atoms)
|
||||
assert rendered == (
|
||||
"see [The moment split](knowledge/moment-split.md) and `[[moment-split]]`"
|
||||
)
|
||||
@@ -0,0 +1,192 @@
|
||||
from pathlib import Path
|
||||
|
||||
from pyroli import checks, schema
|
||||
from pyroli.atoms import Atom
|
||||
|
||||
SCHEMA = schema.load(Path(__file__).parent, Path(__file__).parent)
|
||||
|
||||
|
||||
def atom(slug: str, **overrides) -> Atom:
|
||||
node_type = overrides.pop("type", "concept")
|
||||
fields = {
|
||||
"slug": slug,
|
||||
"path": Path(f"knowledge/{node_type}/{slug}.md"),
|
||||
"title": slug,
|
||||
"type": node_type,
|
||||
"status": "stable",
|
||||
"tags": (),
|
||||
"aliases": (),
|
||||
"confidence": None,
|
||||
"importance": None,
|
||||
"evidence": (),
|
||||
"edges": (),
|
||||
"links": frozenset(),
|
||||
"body": "**Claim.**",
|
||||
"extra": {},
|
||||
}
|
||||
return Atom(**{**fields, **overrides})
|
||||
|
||||
|
||||
def by_check(issues) -> dict[str, list[str]]:
|
||||
out: dict[str, list[str]] = {}
|
||||
for issue in issues:
|
||||
out.setdefault(issue.check, []).append(issue.slug)
|
||||
return out
|
||||
|
||||
|
||||
def test_links_flags_dangling_wikilinks_and_edges():
|
||||
atoms = {
|
||||
"a": atom("a", links=frozenset({"b", "ghost"})),
|
||||
"b": atom("b", edges=(("depends_on", "vanished"),)),
|
||||
}
|
||||
assert by_check(checks.links(atoms, atoms)) == {"link": ["a", "b"]}
|
||||
|
||||
|
||||
def test_citations_flag_a_report_naming_a_renamed_atom():
|
||||
atoms = {"a": atom("a")}
|
||||
sources = {"reports/r.py": "cites [[a]] and [[gone]]"}
|
||||
issues = checks.citations(atoms, sources)
|
||||
assert [i.slug for i in issues] == ["reports/r.py"]
|
||||
assert "[[gone]]" in issues[0].detail
|
||||
|
||||
|
||||
def test_evidence_requires_existence_then_git_tracking(tmp_path):
|
||||
(tmp_path / "experiments").mkdir()
|
||||
(tmp_path / "experiments/e1.py").write_text("")
|
||||
atoms = {
|
||||
"tracked": atom("tracked", evidence=("experiments/e1.py",)),
|
||||
"untracked": atom("untracked", evidence=("experiments/e1.py",)),
|
||||
"absent": atom("absent", evidence=("experiments/gone.py",)),
|
||||
}
|
||||
issues = checks.evidence(atoms, tmp_path, frozenset())
|
||||
assert {i.slug for i in issues} == {"tracked", "untracked", "absent"}
|
||||
|
||||
tracked = frozenset({"experiments/e1.py"})
|
||||
issues = checks.evidence(atoms, tmp_path, tracked)
|
||||
assert [i.slug for i in issues] == ["absent"]
|
||||
|
||||
|
||||
def test_propagation_flags_a_settled_atom_depending_on_a_dead_one():
|
||||
atoms = {
|
||||
"h": atom("h", type="claim", status="refuted"),
|
||||
"f": atom("f", type="claim", status="supported", edges=(("depends_on", "h"),)),
|
||||
"ok": atom("ok", type="claim", status="supported", edges=(("refutes", "h"),)),
|
||||
"wip": atom("wip", type="claim", status="open", edges=(("depends_on", "h"),)),
|
||||
}
|
||||
issues = checks.propagation(atoms, SCHEMA)
|
||||
assert [i.slug for i in issues] == ["f"]
|
||||
|
||||
|
||||
def test_which_statuses_propagate_comes_from_the_schema():
|
||||
"""`open` means neither settled nor dead, so it is exempt by declaration."""
|
||||
assert SCHEMA.status_classes["refuted"] == "dead"
|
||||
assert SCHEMA.status_classes["supported"] == "settled"
|
||||
assert "open" not in SCHEMA.status_classes
|
||||
|
||||
|
||||
def test_staleness_uses_commit_time_not_mtime():
|
||||
atoms = {"a": atom("a", evidence=("experiments/e1.py",))}
|
||||
fresh = {"knowledge/concept/a.md": 100, "experiments/e1.py": 200}
|
||||
stale = {"knowledge/concept/a.md": 300, "experiments/e1.py": 200}
|
||||
assert [i.slug for i in checks.staleness(atoms, fresh)] == ["a"]
|
||||
assert checks.staleness(atoms, stale) == []
|
||||
assert checks.staleness(atoms, {}) == [] # uncommitted atom: nothing to compare
|
||||
|
||||
|
||||
def test_hygiene_rejects_a_status_illegal_for_the_type():
|
||||
atoms = {"g": atom("g", type="gotcha", status="supported")}
|
||||
issues = checks.hygiene(atoms, SCHEMA)
|
||||
assert [(i.check, i.slug) for i in issues] == [("status", "g")]
|
||||
assert "stub, draft, stable" in issues[0].detail
|
||||
|
||||
|
||||
def test_hygiene_rejects_an_undeclared_type():
|
||||
atoms = {"x": atom("x", type="musing")}
|
||||
assert by_check(checks.hygiene(atoms, SCHEMA)) == {"type": ["x"]}
|
||||
|
||||
|
||||
def test_hygiene_rejects_an_invented_confidence_or_importance():
|
||||
atoms = {
|
||||
"c": atom("c", confidence="quite"),
|
||||
"i": atom("i", importance=9),
|
||||
}
|
||||
assert by_check(checks.hygiene(atoms, SCHEMA)) == {
|
||||
"confidence": ["c"],
|
||||
"importance": ["i"],
|
||||
}
|
||||
|
||||
|
||||
def test_relations_reject_an_edge_the_schema_does_not_declare():
|
||||
atoms = {"a": atom("a", edges=(("smells_like", "b"),)), "b": atom("b")}
|
||||
assert by_check(checks.relations(atoms, SCHEMA)) == {"relation": ["a"]}
|
||||
|
||||
|
||||
def test_writing_an_inverse_by_hand_says_where_the_forward_edge_goes():
|
||||
atoms = {
|
||||
"a": atom("a", type="claim", status="open", edges=(("supported_by", "b"),)),
|
||||
"b": atom("b", type="evidence", status="draft"),
|
||||
}
|
||||
issues = checks.relations(atoms, SCHEMA)
|
||||
assert "inverse of `supports`" in issues[0].detail
|
||||
assert "`supports: [a]` on [[b]]" in issues[0].detail
|
||||
|
||||
|
||||
def test_relations_enforce_domain_and_range():
|
||||
atoms = {
|
||||
"d": atom("d", type="decision", status="accepted"),
|
||||
"c": atom("c", type="concept"),
|
||||
}
|
||||
atoms["d"] = atom(
|
||||
"d", type="decision", status="accepted", edges=(("supersedes", "c"),)
|
||||
)
|
||||
issues = checks.relations(atoms, SCHEMA)
|
||||
assert "does not go decision -> concept" in issues[0].detail
|
||||
|
||||
|
||||
def test_filing_flags_a_file_in_the_wrong_directory():
|
||||
atoms = {
|
||||
"right": atom("right", type="claim", status="open"),
|
||||
"wrong": atom(
|
||||
"wrong",
|
||||
type="claim",
|
||||
status="open",
|
||||
path=Path("knowledge/concept/wrong.md"),
|
||||
),
|
||||
}
|
||||
issues = checks.filing(atoms, Path("knowledge"))
|
||||
assert [i.slug for i in issues] == ["wrong"]
|
||||
assert "concept/" in issues[0].detail
|
||||
|
||||
|
||||
def test_a_long_atom_is_not_an_issue():
|
||||
atoms = {"long": atom("long", body="line\n" * 400)}
|
||||
assert checks.hygiene(atoms, SCHEMA) == []
|
||||
|
||||
|
||||
def test_requires_accepts_a_key_the_parser_does_not_know():
|
||||
required = atom("e", type="evidence", status="draft")
|
||||
assert "provenance" in SCHEMA.node_types["evidence"].requires
|
||||
assert [i.check for i in checks.hygiene({"e": required}, SCHEMA)] == ["required"]
|
||||
given = atom("e", type="evidence", status="draft", extra={"provenance": "x"})
|
||||
assert checks.hygiene({"e": given}, SCHEMA) == []
|
||||
|
||||
|
||||
def test_without_a_schema_only_the_schema_free_checks_run():
|
||||
atoms = {"x": atom("x", type="nonsense", status="whatever")}
|
||||
assert checks.hygiene(atoms, None) == []
|
||||
|
||||
|
||||
def test_strays_are_warnings_naming_the_file():
|
||||
issues = checks.strays([Path("inbox.md"), Path("topic/raw.md")])
|
||||
assert [i.severity for i in issues] == ["warn", "warn"]
|
||||
assert [i.slug for i in issues] == ["inbox.md", "topic/raw.md"]
|
||||
|
||||
|
||||
def test_staleness_keys_atoms_by_their_path_under_the_repo():
|
||||
nested = atom(
|
||||
"a",
|
||||
path=Path("notes/topic/a.md"),
|
||||
evidence=("experiments/e1.py",),
|
||||
)
|
||||
committed = {"notes/topic/a.md": 100, "experiments/e1.py": 200}
|
||||
assert [i.slug for i in checks.staleness({"a": nested}, committed)] == ["a"]
|
||||
@@ -0,0 +1,48 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from pyroli import config
|
||||
|
||||
|
||||
def test_defaults_are_the_research_repo_layout():
|
||||
default = config.Config()
|
||||
assert default.knowledge == "knowledge"
|
||||
assert "experiments/*.py" in default.citing
|
||||
|
||||
|
||||
def test_a_missing_file_gives_the_defaults(tmp_path: Path):
|
||||
assert config.load(tmp_path) == config.Config()
|
||||
|
||||
|
||||
def test_partial_file_keeps_the_other_defaults():
|
||||
parsed = config.parse('knowledge = "."')
|
||||
assert parsed.knowledge == "."
|
||||
assert parsed.citing == config.Config().citing
|
||||
|
||||
|
||||
def test_globs_parse_as_tuples():
|
||||
parsed = config.parse('citing = ["notes/*.md"]')
|
||||
assert parsed.citing == ("notes/*.md",)
|
||||
|
||||
|
||||
def test_an_unknown_key_names_itself_and_the_file():
|
||||
with pytest.raises(ValueError, match=r"unknown key.*knowlege"):
|
||||
config.parse('knowlege = "."', name="pyroli.toml")
|
||||
|
||||
|
||||
def test_a_wrong_shape_is_a_value_error():
|
||||
with pytest.raises(ValueError, match="list of glob strings"):
|
||||
config.parse('citing = "reports/*.py"')
|
||||
with pytest.raises(ValueError, match="directory path string"):
|
||||
config.parse("knowledge = 3")
|
||||
|
||||
|
||||
def test_malformed_toml_names_the_file():
|
||||
with pytest.raises(ValueError, match="not valid TOML"):
|
||||
config.parse("knowledge = ", name="somewhere/pyroli.toml")
|
||||
|
||||
|
||||
def test_load_reads_the_file(tmp_path: Path):
|
||||
(tmp_path / config.FILENAME).write_text('knowledge = "notes"\n')
|
||||
assert config.load(tmp_path).knowledge == "notes"
|
||||
@@ -0,0 +1,109 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from pyroli import schema
|
||||
|
||||
MINIMAL = """
|
||||
node_types:
|
||||
claim: { statuses: [open, supported] }
|
||||
source: { statuses: [unread, read] }
|
||||
relation_types:
|
||||
supports: { inverse: supported_by, domain: [claim], range: [claim] }
|
||||
cites_as_evidence: { range: [source] }
|
||||
"""
|
||||
|
||||
|
||||
SKILLS = Path(__file__).resolve().parents[3] # the skills/ dir this package ships in
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path",
|
||||
[SKILLS / "research/references/schema.yaml", SKILLS / "swe/references/schema.yaml"],
|
||||
ids=["research", "swe"],
|
||||
)
|
||||
def test_the_skill_schemas_parse(path: Path):
|
||||
"""The schemas the skills ship are the ones repos copy; they must load."""
|
||||
assert schema.parse(path.read_text(), name=str(path)).node_types
|
||||
|
||||
|
||||
def test_knowledge_schema_wins_over_repo_schema(tmp_path: Path):
|
||||
knowledge = tmp_path / "knowledge"
|
||||
knowledge.mkdir()
|
||||
(tmp_path / schema.FILENAME).write_text(MINIMAL.replace("source", "note"))
|
||||
loaded = schema.load(tmp_path, knowledge)
|
||||
assert loaded is not None
|
||||
assert set(loaded.node_types) == {"claim", "note"}
|
||||
(knowledge / schema.FILENAME).write_text(MINIMAL)
|
||||
loaded = schema.load(tmp_path, knowledge)
|
||||
assert loaded is not None
|
||||
assert set(loaded.node_types) == {"claim", "source"}
|
||||
|
||||
|
||||
def test_no_schema_is_none_not_an_error(tmp_path: Path):
|
||||
assert schema.load(tmp_path, tmp_path / "knowledge") is None
|
||||
|
||||
|
||||
def test_omitted_domain_and_range_mean_any():
|
||||
relation = schema.parse(MINIMAL).relation_types["cites_as_evidence"]
|
||||
assert relation.domain == (schema.ANY,)
|
||||
assert relation.accepts(source="claim", target="source")
|
||||
assert not relation.accepts(source="claim", target="claim")
|
||||
|
||||
|
||||
def test_domain_is_enforced_in_both_directions():
|
||||
relation = schema.parse(MINIMAL).relation_types["supports"]
|
||||
assert relation.accepts(source="claim", target="claim")
|
||||
assert not relation.accepts(source="source", target="claim")
|
||||
|
||||
|
||||
def test_inverses_lists_only_relations_that_declare_one():
|
||||
assert schema.parse(MINIMAL).inverses() == {"supports": "supported_by"}
|
||||
|
||||
|
||||
def test_an_unknown_key_names_itself_and_the_file():
|
||||
text = "node_types:\n claim: { statuses: [open], stat: x }\n"
|
||||
with pytest.raises(ValueError, match=r"unknown key\(s\) stat"):
|
||||
schema.parse(text + "relation_types: {}", name="schema.yaml")
|
||||
|
||||
|
||||
def test_an_unknown_top_level_key_raises():
|
||||
with pytest.raises(ValueError, match="unknown top-level key"):
|
||||
schema.parse("node_types: {}\nrelation_types: {}\nedge_types: {}")
|
||||
|
||||
|
||||
def test_a_type_with_no_status_is_a_type_with_no_state_machine():
|
||||
with pytest.raises(ValueError, match="at least one status"):
|
||||
schema.parse("node_types:\n claim: {}\nrelation_types: {}")
|
||||
|
||||
|
||||
def test_a_range_naming_an_undeclared_type_raises():
|
||||
text = MINIMAL.replace("range: [source] }", "range: [paper] }")
|
||||
with pytest.raises(ValueError, match="range names undeclared type\\(s\\) paper"):
|
||||
schema.parse(text)
|
||||
|
||||
|
||||
def test_an_inverse_colliding_with_a_relation_raises():
|
||||
text = MINIMAL + " supported_by: { domain: [claim] }\n"
|
||||
with pytest.raises(ValueError, match="which is itself a declared relation"):
|
||||
schema.parse(text)
|
||||
|
||||
|
||||
def test_malformed_yaml_names_the_file():
|
||||
with pytest.raises(ValueError, match="not valid YAML"):
|
||||
schema.parse("node_types: [\n", name="somewhere/schema.yaml")
|
||||
|
||||
|
||||
def test_an_assertion_can_be_supported_but_a_definition_cannot():
|
||||
"""`supports` reaches a gotcha, because a gotcha is a normative claim.
|
||||
|
||||
rfm-jlens has three measured claims supporting "the vocabulary must be
|
||||
centred"; a range of `[claim]` alone rejected all three. `concept` stays
|
||||
out of the range on purpose — a definition can be a reason for something,
|
||||
but it is not the kind of thing a measurement confirms.
|
||||
"""
|
||||
research = SKILLS / "research/references/schema.yaml"
|
||||
supports = schema.parse(research.read_text()).relation_types["supports"]
|
||||
assert supports.accepts(source="claim", target="gotcha")
|
||||
assert supports.accepts(source="gotcha", target="claim")
|
||||
assert not supports.accepts(source="claim", target="concept")
|
||||
@@ -0,0 +1,44 @@
|
||||
from pathlib import Path
|
||||
|
||||
from pyroli.atoms import parse
|
||||
from pyroli.search import search
|
||||
|
||||
|
||||
def atom(slug: str, title: str, body: str, tags: str = "[]"):
|
||||
text = f"---\ntitle: {title}\ntype: concept\ntags: {tags}\n---\n\n{body}\n"
|
||||
return parse(Path(f"knowledge/concept/{slug}.md"), text)
|
||||
|
||||
|
||||
ATOMS = {
|
||||
"rank": atom("rank", "Effective rank", "The entropy of the spectrum.\nNot rank."),
|
||||
"split": atom("split", "The moment split", "Uses the effective rank of Sigma."),
|
||||
"haar": atom("haar", "Haar null", "Random rotations.", tags="[rank]"),
|
||||
}
|
||||
|
||||
|
||||
def test_every_term_must_match_somewhere():
|
||||
assert [h.atom.slug for h in search(ATOMS, "effective spectrum")] == ["rank"]
|
||||
assert search(ATOMS, "effective zebra") == ()
|
||||
|
||||
|
||||
def test_title_beats_tag_beats_body():
|
||||
assert [h.atom.slug for h in search(ATOMS, "rank")] == ["rank", "haar", "split"]
|
||||
|
||||
|
||||
def test_matching_is_case_insensitive():
|
||||
assert [h.atom.slug for h in search(ATOMS, "SIGMA")] == ["split"]
|
||||
|
||||
|
||||
def test_the_snippet_is_the_first_matching_body_line_with_terms_marked():
|
||||
(hit,) = search(ATOMS, "sigma")
|
||||
assert hit.snippet == "Uses the effective rank of <mark>Sigma</mark>."
|
||||
|
||||
|
||||
def test_snippet_is_escaped_before_marking():
|
||||
atoms = {"x": atom("x", "X", "a <b> tag")}
|
||||
(hit,) = search(atoms, "tag")
|
||||
assert hit.snippet == "a <b> <mark>tag</mark>"
|
||||
|
||||
|
||||
def test_an_empty_query_finds_nothing():
|
||||
assert search(ATOMS, " ") == ()
|
||||
@@ -0,0 +1,49 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from pyroli.server import app
|
||||
|
||||
ATOM = "---\ntitle: The claim\ntype: concept\nstatus: stable\n---\n\nIt's a body.\n"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repo(tmp_path: Path) -> Path:
|
||||
(tmp_path / "knowledge/concept").mkdir(parents=True)
|
||||
(tmp_path / "knowledge/concept/a.md").write_text(ATOM)
|
||||
return tmp_path
|
||||
|
||||
|
||||
def test_annotate_then_delete_appends_and_never_touches_the_atom(repo: Path):
|
||||
client = TestClient(app(repo))
|
||||
note = {"slug": "a", "exact": "It's a body", "body": "hm"}
|
||||
assert client.post("/annotate", json=note).json() == {"id": "ANN-0001"}
|
||||
assert client.post("/annotate/delete", json={"id": "ANN-0001"}).status_code == 200
|
||||
lines = (repo / "annotations.jsonl").read_text().splitlines()
|
||||
assert [sorted(json.loads(line)) for line in lines] == [
|
||||
["body", "created", "id", "selector", "slug"],
|
||||
["deleted", "id"],
|
||||
]
|
||||
assert (repo / "knowledge/concept/a.md").read_text() == ATOM
|
||||
assert "ANN-0001" not in client.get("/atom/a").text
|
||||
|
||||
|
||||
def test_deleting_twice_or_an_unknown_id_is_a_404(repo: Path):
|
||||
client = TestClient(app(repo), raise_server_exceptions=False)
|
||||
client.post("/annotate", json={"slug": "a", "exact": "body", "body": "hm"})
|
||||
assert client.post("/annotate/delete", json={"id": "ANN-0001"}).status_code == 200
|
||||
assert client.post("/annotate/delete", json={"id": "ANN-0001"}).status_code == 404
|
||||
assert client.post("/annotate/delete", json={"id": "ANN-0042"}).status_code == 404
|
||||
|
||||
|
||||
def test_the_quote_reaches_the_page_as_data_for_the_highlighter(repo: Path):
|
||||
client = TestClient(app(repo))
|
||||
client.post("/annotate", json={"slug": "a", "exact": 'It\'s a "body"', "body": "q"})
|
||||
assert 'data-exact="It's a "body""' in client.get("/atom/a").text
|
||||
|
||||
|
||||
def test_search_route_finds_the_atom(repo: Path):
|
||||
page = TestClient(app(repo)).get("/search", params={"q": "body"}).text
|
||||
assert '<a href="/atom/a">The claim</a>' in page
|
||||
@@ -0,0 +1,141 @@
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from pyroli import __version__
|
||||
from pyroli.atoms import parse
|
||||
from pyroli.search import search
|
||||
from pyroli.site import atom_page, backlinks, markdown, page, search_page
|
||||
|
||||
ATOM = """---
|
||||
title: The moment split
|
||||
type: evidence
|
||||
status: verified
|
||||
tags: [jlens]
|
||||
evidence:
|
||||
- experiments/e3.py
|
||||
edges:
|
||||
depends_on: [effective-rank]
|
||||
---
|
||||
|
||||
**Claim.** Body citing [[effective-rank]], with $M = v v^T + \\Sigma$ inline.
|
||||
|
||||
$$\\min_i \\lambda_i(\\Delta) \\ge -\\epsilon$$
|
||||
"""
|
||||
|
||||
|
||||
def atom(text: str = ATOM, name: str = "moment-split"):
|
||||
return parse(Path(f"knowledge/{name}.md"), text)
|
||||
|
||||
|
||||
def known():
|
||||
other = atom(
|
||||
ATOM.replace("title: The moment split", "title: Effective rank"),
|
||||
"effective-rank",
|
||||
)
|
||||
return {"moment-split": atom(), "effective-rank": other}
|
||||
|
||||
|
||||
def test_a_wikilink_renders_the_title_and_a_site_absolute_href():
|
||||
html = markdown("see [[effective-rank]]", known())
|
||||
assert '<a href="/atom/effective-rank">Effective rank</a>' in html
|
||||
|
||||
|
||||
def test_an_unwritten_atom_renders_a_marked_link_rather_than_raising():
|
||||
html = markdown("see [[ghost]]", {})
|
||||
assert 'href="/atom/ghost"' in html
|
||||
assert "no such atom" in html
|
||||
|
||||
|
||||
def test_inline_math_survives_the_markdown_parser():
|
||||
html = markdown("$M = v v^T + \\Sigma_s$", known())
|
||||
# The subscript must reach KaTeX intact; markdown would read _s as emphasis.
|
||||
assert 'data-tex="M = v v^T + \\Sigma_s"' in html
|
||||
assert 'data-display="false"' in html
|
||||
assert "<em>" not in html
|
||||
|
||||
|
||||
def test_display_math_is_a_block():
|
||||
html = markdown("$$x^2$$", known())
|
||||
assert 'data-display="true"' in html
|
||||
assert html.strip().startswith("<div")
|
||||
|
||||
|
||||
needs_typst = pytest.mark.skipif(shutil.which("typst") is None, reason="no typst")
|
||||
|
||||
|
||||
@needs_typst
|
||||
def test_typst_math_renders_server_side_as_svg_on_the_baseline():
|
||||
html = markdown(
|
||||
"Inline $u^top W_(i j)$ then\n\n$$ sum_(i=1)^n x_i $$", {}, math="typst"
|
||||
)
|
||||
assert "data-tex" not in html # KaTeX must leave it alone
|
||||
assert html.count("<svg") == 2
|
||||
assert 'class="math typst" data-display="false" style="vertical-align:' in html
|
||||
assert '<div class="math typst" data-display="true">' in html
|
||||
assert 'fill="currentColor"' in html
|
||||
assert "\x00" not in html
|
||||
|
||||
|
||||
@needs_typst
|
||||
def test_a_broken_typst_formula_shows_its_source_and_spares_the_others():
|
||||
html = markdown("$x^2$ and $nosuchfn(x)$ and $y$", {}, math="typst")
|
||||
assert html.count("<svg") == 2
|
||||
assert "<code>nosuchfn(x)</code>" in html
|
||||
assert "unknown variable" in html
|
||||
|
||||
|
||||
@needs_typst
|
||||
def test_math_typst_in_frontmatter_switches_the_atom_page():
|
||||
typst = atom(
|
||||
ATOM.replace("tags: [jlens]", "tags: [jlens]\nmath: typst")
|
||||
.replace("$M = v v^T + \\Sigma$", "$M = v v^top + Sigma$")
|
||||
.replace(
|
||||
"$$\\min_i \\lambda_i(\\Delta) \\ge -\\epsilon$$",
|
||||
"$$ min_i lambda_i (Delta) >= -epsilon $$",
|
||||
)
|
||||
)
|
||||
html = atom_page(typst, known(), ())
|
||||
assert html.count("<svg") == 2
|
||||
assert "data-tex" not in html
|
||||
|
||||
|
||||
def test_backlinks_find_both_body_links_and_edges():
|
||||
linking = backlinks(known(), "effective-rank")
|
||||
assert [a.slug for a in linking] == ["moment-split"]
|
||||
assert backlinks(known(), "moment-split") == ()
|
||||
|
||||
|
||||
def test_atom_page_carries_frontmatter_evidence_and_edges():
|
||||
html = atom_page(atom(), known(), ())
|
||||
assert "<h1>The moment split</h1>" in html
|
||||
assert "experiments/e3.py" in html
|
||||
assert "depends_on" in html
|
||||
assert 'href="/tag/jlens"' in html
|
||||
|
||||
|
||||
def test_page_footer_names_the_running_build():
|
||||
"""The wiki is served by a long-running process; the page says which one."""
|
||||
assert f"pyroli {__version__}" in page("t", "<p>b</p>")
|
||||
|
||||
|
||||
def test_atom_page_declares_the_margin_dead_until_javascript_says_otherwise():
|
||||
"""Server-rendered pessimism: site.js rewrites this once its listeners bind,
|
||||
so a stale asset or a dead script shows up as a sentence rather than as a
|
||||
selection that quietly does nothing."""
|
||||
html = atom_page(atom(), known(), ())
|
||||
assert 'id="annotate-hint"' in html
|
||||
assert "has not run" in html
|
||||
|
||||
|
||||
def test_every_page_carries_a_search_form_that_keeps_the_query():
|
||||
html = page("t", "", query='a "quoted" <q>')
|
||||
assert '<form action="/search"' in html
|
||||
assert 'value="a "quoted" <q>"' in html
|
||||
|
||||
|
||||
def test_search_page_links_hits_and_counts_them():
|
||||
html = search_page("effective rank", search(known(), "effective rank"))
|
||||
assert '<a href="/atom/effective-rank">Effective rank</a>' in html
|
||||
assert "2 results<" in html # the other atom links [[effective-rank]]
|
||||
+397
@@ -0,0 +1,397 @@
|
||||
version = 1
|
||||
revision = 5
|
||||
requires-python = ">=3.12"
|
||||
|
||||
[[package]]
|
||||
name = "annotated-doc"
|
||||
version = "0.0.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyio"
|
||||
version = "4.15.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "idna" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.15'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpcore2"
|
||||
version = "2.13.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "h11" },
|
||||
{ name = "truststore" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cb/f3/1db7aa2bc2524062192bb0e0323969492d1883152a232fe36eea65f4e35c/httpcore2-2.13.1.tar.gz", hash = "sha256:e0aa977abe17e69a3b820a24542a6fa88702676d83880b8d194dcd18408e5103", size = 68071, upload-time = "2026-09-23T07:47:22.372Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/09/ba/a4568248771ce81957bfb7cc600264a40fbcda092391ee1c415c50be4bea/httpcore2-2.13.1-py3-none-any.whl", hash = "sha256:e1e05d4f25f7d7d496bfb96748f6f4b67657b03da069b3a68c36069f3db73d0a", size = 83423, upload-time = "2026-09-23T07:47:19.365Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx2"
|
||||
version = "2.13.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio", marker = "sys_platform != 'emscripten'" },
|
||||
{ name = "httpcore2", marker = "sys_platform != 'emscripten'" },
|
||||
{ name = "httpx2-jsfetch", marker = "sys_platform == 'emscripten'" },
|
||||
{ name = "idna" },
|
||||
{ name = "truststore", marker = "sys_platform != 'emscripten'" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d5/44/474bef2a0e9d90f1715d32cb98b0738695ca17ba324095fb2497ed7fbd59/httpx2-2.13.1.tar.gz", hash = "sha256:e48744a19e3af5ee48313d0ce5fe941d5422fae5705ea922a4aabf94d7800dfa", size = 100405, upload-time = "2026-09-23T07:47:23.052Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/9c/6fe8931fd9f381042a9e4c7d5a7b4cbf7016b252bec0c99a49fce42c3326/httpx2-2.13.1-py3-none-any.whl", hash = "sha256:6dff50fabc270ee5fd25d845d0b078ed20564579744d6d962850975996d2f9a4", size = 95597, upload-time = "2026-09-23T07:47:20.995Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx2-jsfetch"
|
||||
version = "1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.20"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markdown-it-py"
|
||||
version = "4.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "mdurl" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mdit-py-plugins"
|
||||
version = "0.6.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markdown-it-py" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mdurl"
|
||||
version = "0.1.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.21.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyrefly"
|
||||
version = "1.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4c/7e/082cc1b392318bacfbc087732524f3e089e9e7fe45b7645c4649b6f1f1e9/pyrefly-1.3.1.tar.gz", hash = "sha256:b774f1dfac82867fc16fe2175d80a630e2a72a6835d5b7e492568d6bfb6b05e7", size = 6679145, upload-time = "2026-09-15T05:09:34.49Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/bc/f09b5bd7eed796349bd93627aa2c3086df7ed0a114dafc3d2b2841599ee7/pyrefly-1.3.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0a2a7dfed824fb066aa8a7c8d856c152b98e998fb84b954d5f892fb9834c0bd1", size = 15090117, upload-time = "2026-09-15T05:08:58.477Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/24/a31e7de8e0f310c9f0753826d0df8b6a8ff86793941a6bd88ba24b735633/pyrefly-1.3.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:37a1a1fb41e7190fe3964a877875dd809d6ee2887809e2c3d6e5f25a69805bfd", size = 14430773, upload-time = "2026-09-15T05:09:01.67Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/e1/022352beb186f36d6213a95a975c42676ee418ff5e730629378d25564c5e/pyrefly-1.3.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45b7650bc651a5a784701f2fb4f579da9a421c4d1b5fd91f9048f4529e46093c", size = 14877762, upload-time = "2026-09-15T05:09:04.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/2c/d62a1b445aad1fd46375b83393b8a67da53883aeeba73d269ce71b3e8fd1/pyrefly-1.3.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ad57f38bc73679c14a5f62995a2bd3bc43649b7270c858487e2eab78a6f95362", size = 16118225, upload-time = "2026-09-15T05:09:07.828Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/67/3c3fa8a13e3ce8d8322ffb3627255a41a52261f17793e1a24e9214f10857/pyrefly-1.3.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3506b1252e9f5d755d0693c5a46d46b85579d36534f6e22a41eb8e098d0dde90", size = 16051197, upload-time = "2026-09-15T05:09:10.81Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/d1/84f81b9e32288b5d11c75e006f67e0f6b78b85da575673bd1a21524c8172/pyrefly-1.3.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39d5a17c1ad655e5f93a6e8e0be90b8db28c6015fe07d6ed4e054891c9e14ef8", size = 15440062, upload-time = "2026-09-15T05:09:14.641Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/3c/2aa0c5ad6a2957a728bb38cfab70d1e9cafad21b0b1c1d92df74a3cf22bd/pyrefly-1.3.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c61e9dcf888beb81ca0e0c60ea4442640fdcf2f1f65bfe3d9f5d89b85516eb03", size = 14898534, upload-time = "2026-09-15T05:09:17.823Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/5d/2974f249fb2e285f966485719d977c5a2248cc4866403af94d935775ff15/pyrefly-1.3.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a78eed1c53f37807f0e2997be8edbe833b91eb9915f2cb553a1bdcd599e7105c", size = 15477758, upload-time = "2026-09-15T05:09:20.964Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/79/d31f8f0abf5b0610ec24183286ee8a1ee5b76c9aaaa91091399d7e1aaf60/pyrefly-1.3.1-py3-none-win32.whl", hash = "sha256:ea0d0349f1bc80fe8e00a7b3640e40db7486398b685f87b9bd5d2207063668f0", size = 14133005, upload-time = "2026-09-15T05:09:25.03Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/23/1859f47f536314755d80c4ec2a76d2698435ae8b73604bcd1dfa57ecb39b/pyrefly-1.3.1-py3-none-win_amd64.whl", hash = "sha256:4c24e7d99df9237916c33be23714219a7c8eaf22bad2ac0c3e1d45adcf7df8fb", size = 15066557, upload-time = "2026-09-15T05:09:28.265Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/2a/73e3721921c2bb2c950a9f8c362ee5e2367556b5d4416eb4f8f88503e66f/pyrefly-1.3.1-py3-none-win_arm64.whl", hash = "sha256:1ed85e2b957a78e93353125d890ce3668ce450e49b74025a15c87b6e8ec7f3d1", size = 14348209, upload-time = "2026-09-15T05:09:31.422Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyroli"
|
||||
version = "0.8.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "markdown-it-py" },
|
||||
{ name = "mdit-py-plugins" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "rich" },
|
||||
{ name = "starlette" },
|
||||
{ name = "typer" },
|
||||
{ name = "uvicorn" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "httpx2" },
|
||||
{ name = "pyrefly" },
|
||||
{ name = "pytest" },
|
||||
{ name = "ruff" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "markdown-it-py", specifier = ">=4.2.0" },
|
||||
{ name = "mdit-py-plugins", specifier = ">=0.6.1" },
|
||||
{ name = "pyyaml", specifier = ">=6.0" },
|
||||
{ name = "rich", specifier = ">=13.0" },
|
||||
{ name = "starlette", specifier = ">=1.3.1" },
|
||||
{ name = "typer", specifier = ">=0.12" },
|
||||
{ name = "uvicorn", specifier = ">=0.52.0" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "httpx2", specifier = ">=2.13.1" },
|
||||
{ name = "pyrefly", specifier = ">=0.16" },
|
||||
{ name = "pytest", specifier = ">=8.0" },
|
||||
{ name = "ruff", specifier = ">=0.6" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyyaml"
|
||||
version = "6.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rich"
|
||||
version = "15.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markdown-it-py" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.16.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/96/bf/c935ca98e73fe8ce65b87ef08a280c0c1e85295d569228c15e87d8fdfaf1/ruff-0.16.9.tar.gz", hash = "sha256:12b625c6cfba78d285d9f48eda5f053374f1e53cb10ef17342a383750db99161", size = 4948764, upload-time = "2026-09-24T20:37:49.416Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/26/df51322b52ee1ada7eff2d071ea09d11d5c2d1dcc9f02594f5785c4d1635/ruff-0.16.9-py3-none-linux_armv6l.whl", hash = "sha256:95e6f022090368ab3b824c36276839c53b2adf1a3f4c09fefc33dfc400f6da96", size = 10082922, upload-time = "2026-09-24T20:37:13.045Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/27/7bf51f5a7aa375e9f339280a303aab44ca75dc1525f1cdc5991761685b0f/ruff-0.16.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a5f27be168556594a86d2f415db0cf43f5291917849318f873c7e2791f7a8c67", size = 10236360, upload-time = "2026-09-24T20:37:16.049Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/63/09659283f92f02dff45809da194a70da2f688d87c55d8875c4fae3536072/ruff-0.16.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1632eb1d6197f33bd00b1acbc5b71009e89a8895c158e2d2b03a834fac964ab6", size = 9892940, upload-time = "2026-09-24T20:37:17.957Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/58/98de1b72ec172f5f8f1731236fe21585b3998bf7dfe9fcc44ae9ba626012/ruff-0.16.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3f951b14d865d5952c89d40a5ca07e87abe24fa5453299878411e127748fb1c", size = 10032114, upload-time = "2026-09-24T20:37:19.942Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/7d/f1e17c54ab59d4bad1dce8ee3e22a7a1d0ef4745240decacdcf3832b5bb2/ruff-0.16.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:447fc07e1573afff7cb02803462b12b6c8ece7cf10e2cd78565fa6d7a1c0bf8d", size = 9910227, upload-time = "2026-09-24T20:37:21.872Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/19/436f647a65075bbd3bab2668b3bdaa5120559b294694018cdcefabbbf30b/ruff-0.16.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a3e039a6a40ed976c491722b60e0ae4a4aa1a86057f540ee7a37a5d19ae9120", size = 10547484, upload-time = "2026-09-24T20:37:24.229Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/59/38430a6bc2f6d8095447ac39625cf8b6e9344a47e6c26225c8ba1bff3ffb/ruff-0.16.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4684dded7db60aa57cb118fa158630f5feade4af5782903b6053484bdf9bd129", size = 11412367, upload-time = "2026-09-24T20:37:26.307Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/bd/bbb6d7fc7f208c8b8c50dd5dc8206e4cfdb1e7a8fb852606a3adf370c880/ruff-0.16.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d29c934357e45642fda2f34c0b1f4025b4a6c01e15e4bf0016879d60078a142c", size = 10869787, upload-time = "2026-09-24T20:37:28.35Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/b8/9c543074918061abbefc3bd139bee22de35abedb00dde0f2d27288838962/ruff-0.16.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a21713e629d3e5bdb2f5c2def1cc7f04f47fa8e1a7eb0571b4a28e1da64bc728", size = 10406494, upload-time = "2026-09-24T20:37:30.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/7a/5a8851bd146e7ccf8fd4b003f6c75c11f8fbdb0e60673b45097986b6bf41/ruff-0.16.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7baa24ef5fc8e77aa93879e1d3f43754a01ae488e869f1ae30cf431afd4d2452", size = 10590083, upload-time = "2026-09-24T20:37:32.439Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/f0/4c3467188f23f806960b46fa76575a7cd0514c9ba90562650b71efc96980/ruff-0.16.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a41aac6230aadfaa133bdfa1614488531ffa3e0837567ae04c0da2058a9c0f9e", size = 10119151, upload-time = "2026-09-24T20:37:34.581Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/34/5a4def5adea572ce6aea0bb64f21f928ee317b80e0db747d7979b01d7261/ruff-0.16.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c2529fb5896d49115b0e9aa8f887490b34bbe76baf879ec2264ac59406869ce7", size = 9911544, upload-time = "2026-09-24T20:37:36.796Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/5d/d15ebea7499eef9373318c0ee6ca127832927c6529731f6d48e18dce7ca9/ruff-0.16.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:41e3870277694177429b56406d65dfbdb2c2802c52b715edaf6a0b829c69d4ee", size = 10269884, upload-time = "2026-09-24T20:37:38.857Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/56/c5d3cd119ded7a3c7aba0e961b69cb3df701c91662c98ad694467d060ce1/ruff-0.16.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8adbe4e58af167f767d7b2ba5e83c42e878350796cf78c2f5e14ab9903a92588", size = 10749366, upload-time = "2026-09-24T20:37:41.042Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/fe/734ec7527029ac757ecf821f143c9f3fcf69149c21f53a044a899b430f5a/ruff-0.16.9-py3-none-win32.whl", hash = "sha256:0e1dbc2073624dee6618d41d0098690a7244654af746704b64759e12b6b6b385", size = 10152355, upload-time = "2026-09-24T20:37:43.025Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/21/26e4643629b3ebb44f0a06f9c9a53058d63d989415f63a9a3c28e2ee7f22/ruff-0.16.9-py3-none-win_amd64.whl", hash = "sha256:6bd40fec8cd4c8a3d4dd589bd8ad4e6320c13c29234159bfd959a40d529d597b", size = 10592965, upload-time = "2026-09-24T20:37:44.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/60/5fb1a39dbb5ae314d5f59bc7348a63c1d5c20f3cd83914c4b5cb0be31d2d/ruff-0.16.9-py3-none-win_arm64.whl", hash = "sha256:ed1a252039200f57a59eebc063b54beabea67bfbaaca0eeaa7f54b5fbcda2284", size = 10458649, upload-time = "2026-09-24T20:37:46.882Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shellingham"
|
||||
version = "1.5.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "starlette"
|
||||
version = "1.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7b/2b/3850dc6bf7ef71b088962eba31dafc6cffd2f96e577ebb0bb316df96da3e/starlette-1.7.0.tar.gz", hash = "sha256:c79f74ea63cff761804fbbfb182f1e0b440c2d07b164d24700c5a1bab5d6ff5d", size = 2736246, upload-time = "2026-09-23T07:30:26.35Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/d6/1ec1b290f9e0fb067899b61e1d37a30c923068bad260b216dbe37a7d2967/starlette-1.7.0-py3-none-any.whl", hash = "sha256:67f8e99895493dd2911a03f11314af6ceebeae4e704bb9f43dfc6a9db151c93e", size = 78980, upload-time = "2026-09-23T07:30:24.567Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "truststore"
|
||||
version = "0.10.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typer"
|
||||
version = "0.27.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-doc" },
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "rich" },
|
||||
{ name = "shellingham" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/16/f7/57713ba479fd405eb76de31404b2c744c289e336b2d999511ebf51e496f7/typer-0.27.2.tar.gz", hash = "sha256:269b7eb9d3c202ca84b4bc9618cb04ebb43d3d4d1e567e4c768607232c05f945", size = 204045, upload-time = "2026-08-28T10:26:55.046Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/bf/205d0004930ede8f542fb58f601526fccf4ae7626075ca1e6c4de5d3d652/typer-0.27.2-py3-none-any.whl", hash = "sha256:b3a5fc4342d5fc8fda8fc3010b1cf117e9249aab7fae800c2eff62fd3842d97d", size = 123130, upload-time = "2026-08-28T10:26:53.752Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uvicorn"
|
||||
version = "0.54.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/da/34/30e9280707135d2cfc589dfff3cb796bd07a3aeb1a3e415ba09dd89d7bb4/uvicorn-0.54.0.tar.gz", hash = "sha256:a2e33cbfaa0306f8e6b0c13e0cb89d7d7a2da3e62b90c66e18c33d9807b28620", size = 112283, upload-time = "2026-09-25T06:52:37.601Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/38/0c/b54a4fdd7f90a3af8b02ebc9ce6712c2c208b7926a2f7bad95c33ebbe943/uvicorn-0.54.0-py3-none-any.whl", hash = "sha256:505bdb0f318731d45f1f712071fc781a8981f6847a31c902c9f5e652d4f67faf", size = 87427, upload-time = "2026-09-25T06:52:35.829Z" },
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
# Where the ideas behind this skill come from
|
||||
|
||||
Read these when a design question about the atom format, the ontology or the wiki comes up: most have already been argued there.
|
||||
|
||||
## Papers
|
||||
|
||||
- Krötzsch, Simančík, Horrocks — _A Description Logic Primer_, arXiv:1201.4089. What the refusal of OWL and reasoners is refusing: `check` is closed-world and forty lines, not an inference engine.
|
||||
- Hogan et al. — _Knowledge Graphs_, ACM CSUR 54(4), arXiv:2003.02320, §2–3. Graph data models, schemas, identity.
|
||||
- Groth, Gibson, Velterop — _The anatomy of a nanopublication_ (2010). An assertion is inseparable from its provenance: the `evidence:` / `provenance:` fields.
|
||||
- Chan et al. — _Steps Towards an Infrastructure for Scholarly Synthesis_, arXiv:2407.20666. Discourse graphs: question / claim / evidence, and the `supports` / `contradicts` edges.
|
||||
- Gruber (1993) — _A translation approach to portable ontology specifications_. What an ontology is for, and why it lives in data (`schema.yaml`), not code.
|
||||
|
||||
## Not papers
|
||||
|
||||
- [Diátaxis](https://diataxis.fr): the four page kinds (`howto`, `reference`, `concept`, tutorial) that must not be mixed.
|
||||
- [ADRs](https://adr.github.io) and MADR: the `decision` type, immutable once accepted, superseded rather than edited.
|
||||
- [CiTO](https://sparontologies.github.io): the citation relations (`cites_as_evidence`, `extends`, ...).
|
||||
- W3C Web Annotation Data Model, Selectors and States: the margin's `TextQuoteSelector` (exact / prefix / suffix).
|
||||
- nLab and the Stacks Project: atomic, heavily linked, stable-id mathematical pages.
|
||||
- ArchWiki: status templates (stub, draft) as a work queue.
|
||||
- gwern.net: `importance` and `confidence` on every page.
|
||||
- C2 wiki: ThreadMode → DocumentMode, i.e. margin notes refactored back into prose.
|
||||
@@ -0,0 +1,22 @@
|
||||
# <project>
|
||||
|
||||
<One paragraph: what this repo is, and what it is not.>
|
||||
|
||||
## Skills
|
||||
|
||||
Load `common-tooling`, `knowledge-and-memory`, and <`python` | `research` | `swe` | `torch`>.
|
||||
|
||||
Deltas from them: <none yet; each one says why>.
|
||||
|
||||
## Where things are
|
||||
|
||||
<Only what `tree -L 2` does not make obvious.>
|
||||
|
||||
## Commands
|
||||
|
||||
- Gate: `<the one command that must pass before a commit>`
|
||||
- Wiki: `pyroli serve` (`pyroli check` before committing atoms)
|
||||
|
||||
## State (<date>)
|
||||
|
||||
<What works, what is in progress, what is next. Link atoms with [[slug]]; never restate them.>
|
||||
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
# (Re)install `pyroli` from the copy shipped in this skill (../pyroli).
|
||||
#
|
||||
# The installed tool is a copy, not a link: re-run after every change to
|
||||
# ../pyroli, and bump its version so `pyroli --version` shows which build runs.
|
||||
set -euo pipefail
|
||||
here=$(cd -- "$(dirname -- "$(readlink -f -- "$0")")" && pwd)
|
||||
command -v uv >/dev/null || {
|
||||
echo "uv is missing: run the common-tooling skill's scripts/install-tools.sh" >&2
|
||||
exit 1
|
||||
}
|
||||
uv tool install --reinstall --quiet "$here/../pyroli"
|
||||
pyroli --version
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
name: matplotlib
|
||||
description: Beautiful plots examples with matplotlib
|
||||
---
|
||||
|
||||
When using matplotlib, use these colors if possible:
|
||||
|
||||
```python
|
||||
primary = '#FFB84C'
|
||||
second = '#A459D1'
|
||||
third = '#F266AB'
|
||||
highlight = '#2CD3E1'
|
||||
neutral = '#8C8C96'
|
||||
ink = '#33333B'
|
||||
```
|
||||
|
||||
Use large police, and large width so we see things clearly, something like:
|
||||
|
||||
```python
|
||||
|
||||
plt.rcParams.update({'font.size': 15, 'axes.titlesize': 17, 'axes.labelsize': 15, 'xtick.labelsize': 13, 'ytick.labelsize': 13, 'legend.fontsize': 13, 'figure.titlesize': 18, 'lines.linewidth': 3, 'text.usetex': False})
|
||||
|
||||
```
|
||||
|
||||
Export as SVG when possible, in transparent:
|
||||
|
||||
```python
|
||||
plt.savefig('the-super-plot.svg', transparent=True)
|
||||
```
|
||||
|
||||
You can ask the user about the style and fix it in the repository's AGENTS.md so it's clear.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
name: mypy-setup
|
||||
description: pyproject setup for mypy
|
||||
---
|
||||
|
||||
Read `pyproject.toml`
|
||||
@@ -0,0 +1,14 @@
|
||||
[[tool.mypy.overrides]]
|
||||
ignore_missing_imports = true
|
||||
module = [
|
||||
"accelerate.*",
|
||||
"circuitsvis.*",
|
||||
"datasets.*",
|
||||
"networkx.*",
|
||||
"nnsight.*",
|
||||
"nnterp.*",
|
||||
"plotly.*",
|
||||
"scholarly.*",
|
||||
"sklearn.*",
|
||||
"transformer_lens.*",
|
||||
]
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
name: pyrefly-setup
|
||||
description: pyproject.toml setup example for pyrefly
|
||||
---
|
||||
|
||||
Read `pyproject.toml`
|
||||
@@ -0,0 +1,15 @@
|
||||
[tool.pyrefly]
|
||||
ignore-missing-imports = [
|
||||
"accelerate.*",
|
||||
"circuitsvis.*",
|
||||
"datasets.*",
|
||||
"networkx.*",
|
||||
"nnsight.*",
|
||||
"nnterp.*",
|
||||
"plotly.*",
|
||||
"scholarly.*",
|
||||
"sklearn.*",
|
||||
"transformer_lens.*",
|
||||
]
|
||||
[tool.pyrefly.errors]
|
||||
parse-error = false
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
name: pyright-setup
|
||||
description: pyproject setup for pyright
|
||||
---
|
||||
|
||||
Read `pyproject.toml`
|
||||
@@ -0,0 +1,13 @@
|
||||
[tool.pyright]
|
||||
ignore = [
|
||||
"accelerate",
|
||||
"circuitsvis",
|
||||
"datasets",
|
||||
"networkx",
|
||||
"nnsight",
|
||||
"nnterp",
|
||||
"plotly",
|
||||
"scholarly",
|
||||
"sklearn",
|
||||
"transformer_lens",
|
||||
]
|
||||
@@ -0,0 +1,75 @@
|
||||
---
|
||||
name: python
|
||||
description: Python conventions, and `sonde` for exploring a Python repo. Load every time you need to write python.
|
||||
---
|
||||
|
||||
## The philosophy
|
||||
|
||||
**Code is the spec.** No `SPECS.md`, no `STRUCTURE.md` — they drift. Docstrings carry what the name and types cannot. Tests are the examples; a workflow script is the example when one function isn't enough.
|
||||
|
||||
**Strong typing, pure functions, no classes unless the data is a record.** Avoid bloat and speculative abstraction. Separate pure from impure: the impure edge should be one named module or function, not a habit spread through the file.
|
||||
|
||||
## Exploring code: `sonde`
|
||||
|
||||
Read structure before reading files: `sonde module <pkg>` (docstring, submodules, public API table, invariants), `sonde symbol <pkg.mod.Name>` (signature and docstring), `sonde examples <pkg.mod.name>` (tests that use it). It reads docstrings, which is one more reason to keep them good. Install or update with `scripts/install-sonde.sh` (uv comes from the `common-tooling` installer).
|
||||
|
||||
## Tooling
|
||||
|
||||
`uv` only: `uv run …`, `uv add …`, `uv sync`. No `pip`, no system Python. See `references/templates/pyproject.toml` for the default `pyproject.toml`, and `references/templates/gitignore`. Change the version, keep the rules.
|
||||
|
||||
Gate before every commit:
|
||||
|
||||
```bash
|
||||
uv run ruff check --fix && uv run ruff format && uv run pyrefly check && uv run pytest
|
||||
```
|
||||
|
||||
## Types
|
||||
|
||||
- Internal data: `@dataclass(frozen=True, kw_only=True, slots=True)`. Fields use `tuple` / `frozenset` / `Mapping`, never `list` / `set` / `dict`.
|
||||
- External data (configs, JSON, CLI): `pydantic.BaseModel` with `ConfigDict(frozen=True, extra="forbid")`.
|
||||
- Unions: PEP 604 (`X | None`, `A | B`). Sum types carry a `tag: Literal[...]` discriminator.
|
||||
- No `Optional`, no `typing.Dict/List/Tuple`.
|
||||
|
||||
## Logging and output
|
||||
|
||||
- Logs: `loguru` to stderr; pass args separately (`logger.info("loaded {}", x)`), never an f-string inside the call.
|
||||
- Human-facing results: `rich` to stdout (tables, `track()`, `pprint`).
|
||||
- `print` is banned (ruff `T20`).
|
||||
|
||||
## CLI and config
|
||||
|
||||
TOML config parsed by pydantic. CLI with `typer`, or `tyro`, or both.
|
||||
|
||||
## Discipline
|
||||
|
||||
- **YAGNI.** No config option with one value, no ABC with one implementation, no abstraction for a hypothetical need.
|
||||
- **One way to do things.** TOML configs, loguru logs, einops ops. No alternatives living side by side.
|
||||
- **No `try/except` "for robustness".** Catch only when the handler does something more than re-raise.
|
||||
- **No silent fallbacks.** No CPU fallback, no default-on-missing-config. Raise. _Loud errors beat silent issues that haunt you for weeks._
|
||||
- `# noqa` requires a comment. More than three in a file means the design is wrong. (feedback appreciated if this happens)
|
||||
|
||||
### Forbidden without justification
|
||||
|
||||
`print` · bare/blind `except` · mutating args · module-level mutable state · `**kwargs` propagation (thin wrappers only) · raw tensor ops · `Optional` / `Dict` / `List` / `Tuple` from `typing` · new top-level dependencies
|
||||
|
||||
## Docstrings
|
||||
|
||||
**Google style**, enforced by ruff `D` (`convention = "google"`). Keep them short, structured, and free of type duplication.
|
||||
|
||||
**Function**
|
||||
|
||||
- One-line summary, imperative mood, period. This is the public-API table cell.
|
||||
- `Args` / `Returns` / `Raises` only when they add information the signature does not already carry.
|
||||
- Never restate a type in prose — the annotation is the source.
|
||||
|
||||
**Class**
|
||||
|
||||
- `Attributes:` for frozen dataclasses. Do not docstring individual fields.
|
||||
|
||||
**Module** — the structural surface `sonde module` shows. Hard cap ~30 lines.
|
||||
|
||||
- One-line summary, then what the module owns and its purity.
|
||||
- `Invariants:` — what must stay true, and why. **Not derivable from the code**, which is why it is written down and why it does not rot.
|
||||
- `Conventions:` — local decisions a reader would otherwise re-litigate.
|
||||
|
||||
Invariants are most important in pure modules, optional elsewhere. A module whose invariants section would be empty does not need one. The test of a good module docstring: after reading the module docstring, would you still need to read the full file to know whether your change is allowed? If yes, the invariants are missing.
|
||||
@@ -0,0 +1,8 @@
|
||||
# Copy to .gitignore.
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.egg-info/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
# research: artifacts are regenerated from committed code, never committed
|
||||
artifacts/
|
||||
@@ -0,0 +1,70 @@
|
||||
# Template for a new research repo. Rename, then delete these comments.
|
||||
#
|
||||
# Layout it assumes:
|
||||
# core/ runners/ experiments/ reports/ configs/ knowledge/ tests/ artifacts/
|
||||
# `pyroli` is a dev tool and a one-function import; it is not a framework.
|
||||
#
|
||||
# Use uv add ... to get the latest version of packages if version is not important
|
||||
|
||||
[project]
|
||||
name = "CHANGEME"
|
||||
version = "0.1.0"
|
||||
description = "CHANGEME — one line, the question this repo investigates."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
authors = [
|
||||
{ name = "Sckathach", email = "thomas.winninger@telecom-sudparis.eu" },
|
||||
]
|
||||
dependencies = [
|
||||
"beartype>=0.19",
|
||||
"einops>=0.8",
|
||||
"jaxtyping>=0.2",
|
||||
"loguru>=0.7",
|
||||
"pydantic>=2.0",
|
||||
"rich>=13.0",
|
||||
"safetensors>=0.4",
|
||||
"torch>=2.4",
|
||||
"typer>=0.12",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["uv_build>=0.12.0,<0.13.0"]
|
||||
build-backend = "uv_build"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"hypothesis>=6.0",
|
||||
"marimo>=0.9",
|
||||
"pyrefly>=0.16",
|
||||
"pytest>=8.0",
|
||||
"ruff>=0.6",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 88
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["ALL"]
|
||||
ignore = [
|
||||
"COM812", # conflicts with the formatter
|
||||
"D203", # incompatible with D211
|
||||
"D213", # incompatible with D212
|
||||
"CPY001", # no copyright headers
|
||||
]
|
||||
|
||||
[tool.ruff.lint.pydocstyle]
|
||||
convention = "google"
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"tests/*" = ["D", "S101", "PLR2004", "ANN", "INP001"]
|
||||
# jaxtyping annotations are strings ruff reads as undefined names.
|
||||
"core/*" = ["F722"]
|
||||
# marimo regenerates the bare `return` ruff strips (PLR1711); a trailing
|
||||
# expression *is* the figure (B018); cell-local names are not module constants.
|
||||
"reports/*" = ["ANN", "B018", "D103", "N806", "PLR1711", "PLR0913"]
|
||||
# An experiment script is one long pre-registered function by design.
|
||||
"experiments/*" = ["PLR0913", "PLR0915"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
markers = ["slow: needs a real model"]
|
||||
Executable
+13
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
# (Re)install `sonde` from the copy shipped in this skill (../sonde).
|
||||
#
|
||||
# The installed tool is a copy, not a link: re-run after every change to
|
||||
# ../sonde, and bump its version so `sonde --version` shows which build runs.
|
||||
set -euo pipefail
|
||||
here=$(cd -- "$(dirname -- "$(readlink -f -- "$0")")" && pwd)
|
||||
command -v uv >/dev/null || {
|
||||
echo "uv is missing: run the common-tooling skill's scripts/install-tools.sh" >&2
|
||||
exit 1
|
||||
}
|
||||
uv tool install --reinstall --quiet "$here/../sonde"
|
||||
sonde --version
|
||||
@@ -0,0 +1,43 @@
|
||||
[project]
|
||||
name = "sonde"
|
||||
version = "0.2.0"
|
||||
description = "Probe a Python repo and its artifacts: docstrings, API, pre-registrations, safetensors provenance."
|
||||
requires-python = ">=3.12"
|
||||
authors = [{ name = "Sckathach", email = "thomas.winninger@telecom-sudparis.eu" }]
|
||||
dependencies = ["griffe>=1.0", "rich>=13.0", "typer>=0.12"]
|
||||
|
||||
[project.scripts]
|
||||
sonde = "sonde.cli:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["uv_build>=0.12.0,<0.13.0"]
|
||||
build-backend = "uv_build"
|
||||
|
||||
[dependency-groups]
|
||||
dev = ["pytest>=8.0", "ruff>=0.6", "pyrefly>=0.16"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 88
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["ALL"]
|
||||
ignore = [
|
||||
"COM812", # conflicts with the formatter
|
||||
"D203", # incompatible with D211
|
||||
"D213", # incompatible with D212
|
||||
"CPY001", # no copyright headers
|
||||
# Malformed *input* raises ValueError here, including bad shapes: one
|
||||
# exception type keeps `except ValueError` sufficient at the CLI.
|
||||
"TRY004",
|
||||
]
|
||||
|
||||
[tool.ruff.lint.pydocstyle]
|
||||
convention = "google"
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"tests/*" = ["D", "S101", "PLR2004", "ANN", "INP001", "DTZ001"]
|
||||
# typer's API is boolean options with positional defaults.
|
||||
"src/sonde/cli.py" = ["FBT001", "FBT002"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
@@ -0,0 +1,18 @@
|
||||
"""sonde — probe a Python repo and its artifacts.
|
||||
|
||||
Code: `module`, `symbol`, `examples` (griffe, no import of the probed code).
|
||||
Experiments: `prereg`, the pre-registration gate of the `research` skill.
|
||||
Artifacts: `meta` reads safetensors headers; `provenance` builds what they
|
||||
should carry, and is the one thing meant to be imported:
|
||||
|
||||
from sonde import provenance
|
||||
save_file(tensors, path, metadata=provenance(asdict(cfg)))
|
||||
"""
|
||||
|
||||
from importlib.metadata import version
|
||||
|
||||
from sonde.provenance import provenance
|
||||
|
||||
__version__ = version("sonde")
|
||||
|
||||
__all__ = ["__version__", "provenance"]
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Read safetensors headers: provenance metadata and tensor shapes.
|
||||
|
||||
The header is parsed directly from the file (8-byte little-endian length, then
|
||||
a JSON object), so this costs no torch import and reads only the first few
|
||||
kilobytes of a multi-gigabyte artifact.
|
||||
|
||||
The `research` skill asks artifacts to carry `git_commit`, `timestamp`,
|
||||
`experiment_config` and `labels` — which `provenance.py` writes — and that is
|
||||
what makes an atom's `evidence:` verifiable rather than decorative.
|
||||
|
||||
Invariants:
|
||||
- `read` raises on a file that is not safetensors. A swallowed error in
|
||||
the tool that reads provenance is the worst possible place for one.
|
||||
- Tensor names and shapes come from the same header, so `show` can answer
|
||||
"what are the rows of this matrix" without loading it.
|
||||
"""
|
||||
|
||||
import json
|
||||
import struct
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
_HEADER_SIZE_BYTES = 8
|
||||
_MAX_HEADER_BYTES = 100_000_000
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True, slots=True)
|
||||
class Tensor:
|
||||
"""One tensor entry of a safetensors header.
|
||||
|
||||
Attributes:
|
||||
dtype: safetensors dtype string, e.g. `F32`.
|
||||
shape: Dimensions, outermost first.
|
||||
"""
|
||||
|
||||
name: str
|
||||
dtype: str
|
||||
shape: tuple[int, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True, slots=True)
|
||||
class Artifact:
|
||||
"""A safetensors file's header.
|
||||
|
||||
Attributes:
|
||||
metadata: The `__metadata__` mapping; always str -> str by format.
|
||||
tensors: Header entries, in file order.
|
||||
"""
|
||||
|
||||
path: Path
|
||||
metadata: Mapping[str, str]
|
||||
tensors: tuple[Tensor, ...]
|
||||
|
||||
@property
|
||||
def config(self) -> Mapping[str, object] | None:
|
||||
"""`experiment_config` decoded from JSON, or None if absent.
|
||||
|
||||
Raises:
|
||||
ValueError: If the field is present but not valid JSON.
|
||||
"""
|
||||
raw = self.metadata.get("experiment_config")
|
||||
if raw is None:
|
||||
return None
|
||||
return json.loads(raw)
|
||||
|
||||
@property
|
||||
def labels(self) -> Mapping[str, tuple[str, ...]]:
|
||||
"""First-axis labels per tensor name, as written by `sonde.provenance`.
|
||||
|
||||
Raises:
|
||||
ValueError: If the field is present but not valid JSON.
|
||||
"""
|
||||
raw = self.metadata.get("labels")
|
||||
if raw is None:
|
||||
return {}
|
||||
return {name: tuple(map(str, names)) for name, names in json.loads(raw).items()}
|
||||
|
||||
|
||||
def read(path: Path) -> Artifact:
|
||||
"""Parse the header of a safetensors file.
|
||||
|
||||
Raises:
|
||||
ValueError: If the header is missing, oversized, or not a JSON object.
|
||||
"""
|
||||
with path.open("rb") as handle:
|
||||
raw_size = handle.read(_HEADER_SIZE_BYTES)
|
||||
if len(raw_size) < _HEADER_SIZE_BYTES:
|
||||
msg = f"{path}: too short to be a safetensors file"
|
||||
raise ValueError(msg)
|
||||
(size,) = struct.unpack("<Q", raw_size)
|
||||
if size > _MAX_HEADER_BYTES:
|
||||
msg = f"{path}: header claims {size} bytes; not a safetensors file"
|
||||
raise ValueError(msg)
|
||||
try:
|
||||
header = json.loads(handle.read(size))
|
||||
except json.JSONDecodeError as exc:
|
||||
# Re-raise naming the file: a bare decode error hides which one.
|
||||
msg = f"{path}: header is not JSON; not a safetensors file ({exc})"
|
||||
raise ValueError(msg) from exc
|
||||
|
||||
if not isinstance(header, dict):
|
||||
msg = f"{path}: header is not a JSON object"
|
||||
raise ValueError(msg)
|
||||
metadata = header.get("__metadata__") or {}
|
||||
|
||||
return Artifact(
|
||||
path=path,
|
||||
metadata=metadata,
|
||||
tensors=tuple(
|
||||
Tensor(name=name, dtype=entry["dtype"], shape=tuple(entry["shape"]))
|
||||
for name, entry in header.items()
|
||||
if name != "__metadata__"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def parse_since(spec: str, now: datetime) -> datetime:
|
||||
"""Resolve `today`, `yesterday`, `<n>d` or an ISO timestamp against `now`.
|
||||
|
||||
Raises:
|
||||
ValueError: If the spec matches none of those forms.
|
||||
"""
|
||||
spec = spec.strip().lower()
|
||||
if spec == "today":
|
||||
return _midnight(now)
|
||||
if spec == "yesterday":
|
||||
return _midnight(now - timedelta(days=1))
|
||||
if spec.endswith("d") and spec[:-1].isdigit():
|
||||
return now - timedelta(days=int(spec[:-1]))
|
||||
return datetime.fromisoformat(spec)
|
||||
|
||||
|
||||
def _midnight(moment: datetime) -> datetime:
|
||||
return moment.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
|
||||
def timestamp(artifact: Artifact) -> datetime | None:
|
||||
"""The artifact's `timestamp` metadata as a datetime, or None if unusable."""
|
||||
raw = artifact.metadata.get("timestamp")
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(raw)
|
||||
except ValueError: # a hand-written timestamp is data, not a crash
|
||||
return None
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Command-line entry: typer app, filesystem reads, dispatch to views.
|
||||
|
||||
Impure by design: everything it prints comes from a pure renderer in `views/`.
|
||||
|
||||
Conventions:
|
||||
- `--repo` (default: cwd) roots code lookups. Packages resolve under it and
|
||||
under `<repo>/src`; tests in `<repo>/tests`.
|
||||
- Exit code 1 means a gate failed or an input did not resolve. `main` is
|
||||
the single place that turns an exception into that exit code, and it
|
||||
catches only the three types the parsers raise on bad input — a bug
|
||||
still gets its traceback.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
|
||||
from sonde import __version__ as sonde_version
|
||||
from sonde import artifacts, docstrings, loader, prereg
|
||||
from sonde.views import artifacts as artifacts_view
|
||||
from sonde.views import examples as examples_view
|
||||
from sonde.views import module as module_view
|
||||
from sonde.views import prereg as prereg_view
|
||||
from sonde.views import symbol as symbol_view
|
||||
|
||||
app = typer.Typer(
|
||||
add_completion=False,
|
||||
no_args_is_help=True,
|
||||
help="Probe a Python repo and its artifacts: API, pre-registrations, provenance.",
|
||||
)
|
||||
# `highlight=False`: rich's auto-colouring picks colours that vanish on some
|
||||
# dark themes. Only explicit markup is coloured.
|
||||
console = Console(highlight=False)
|
||||
|
||||
Repo = Annotated[Path, typer.Option("--repo", help="Repository root.")]
|
||||
Dotted = Annotated[str, typer.Argument(help="Dotted path, e.g. 'core.types.Example'.")]
|
||||
|
||||
|
||||
def _version(value: bool) -> None:
|
||||
if value:
|
||||
console.print(f"sonde {sonde_version}")
|
||||
raise typer.Exit
|
||||
|
||||
|
||||
@app.callback()
|
||||
def _root(
|
||||
version: Annotated[
|
||||
bool,
|
||||
typer.Option(
|
||||
"--version",
|
||||
callback=_version,
|
||||
is_eager=True,
|
||||
help="Print the installed version and exit.",
|
||||
),
|
||||
] = False,
|
||||
) -> None:
|
||||
"""Probe a Python repo and its artifacts: API, pre-registrations, provenance."""
|
||||
|
||||
|
||||
@app.command()
|
||||
def module(path: Dotted, repo: Repo = Path()) -> None:
|
||||
"""Module docstring, public API table, invariants and conventions."""
|
||||
console.print(module_view.render(loader.load(repo, path)))
|
||||
|
||||
|
||||
@app.command()
|
||||
def symbol(path: Dotted, repo: Repo = Path()) -> None:
|
||||
"""Full signature and docstring of one class, function or attribute."""
|
||||
console.print(symbol_view.render(loader.load(repo, path)))
|
||||
|
||||
|
||||
@app.command()
|
||||
def examples(
|
||||
path: Dotted,
|
||||
repo: Repo = Path(),
|
||||
tests: Annotated[Path | None, typer.Option(help="Tests directory.")] = None,
|
||||
) -> None:
|
||||
"""Test functions referencing a symbol, as snippets."""
|
||||
console.print(examples_view.render(path, tests or repo / "tests"))
|
||||
|
||||
|
||||
@app.command(name="prereg")
|
||||
def preregistration(
|
||||
script: Annotated[Path, typer.Argument(help="Path to an experiment script.")],
|
||||
) -> None:
|
||||
"""Pre-registration of an experiment: its sections, and which are missing."""
|
||||
source = script.read_text(encoding="utf-8")
|
||||
doc = docstrings.parse(loader.module_docstring(source))
|
||||
console.print(prereg_view.render(script.name, doc, source))
|
||||
if prereg.missing(doc):
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@app.command()
|
||||
def meta(
|
||||
target: Annotated[Path, typer.Argument(help="A .safetensors file or a directory.")],
|
||||
recursive: Annotated[bool, typer.Option("-r", "--recursive")] = False,
|
||||
since: Annotated[
|
||||
str | None, typer.Option(help="today | yesterday | <n>d | ISO timestamp.")
|
||||
] = None,
|
||||
commit: Annotated[
|
||||
str | None,
|
||||
typer.Option("--git", help="Keep artifacts whose commit starts with this."),
|
||||
] = None,
|
||||
) -> None:
|
||||
"""Provenance metadata, tensor shapes and labels of safetensors artifacts."""
|
||||
if target.is_file():
|
||||
console.print(artifacts_view.render_one(artifacts.read(target)))
|
||||
return
|
||||
|
||||
pattern = "**/*.safetensors" if recursive else "*.safetensors"
|
||||
found = [artifacts.read(path) for path in sorted(target.glob(pattern))]
|
||||
cutoff = artifacts.parse_since(since, datetime.now()) if since else None # noqa: DTZ005
|
||||
kept = [artifact for artifact in found if _keeps(artifact, cutoff, commit)]
|
||||
console.print(artifacts_view.render_many(kept, target))
|
||||
|
||||
|
||||
def _keeps(
|
||||
artifact: artifacts.Artifact,
|
||||
cutoff: datetime | None,
|
||||
commit: str | None,
|
||||
) -> bool:
|
||||
when = artifacts.timestamp(artifact)
|
||||
if cutoff is not None and (when is None or when < cutoff):
|
||||
return False
|
||||
return not (
|
||||
commit is not None
|
||||
and not artifact.metadata.get("git_commit", "").startswith(commit)
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Run the app, reporting bad input as one red line and exit code 1."""
|
||||
try:
|
||||
app()
|
||||
except (ValueError, FileNotFoundError, RuntimeError) as exc:
|
||||
console.print(f"[red]error:[/red] {exc}")
|
||||
raise SystemExit(1) from None
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,92 @@
|
||||
r"""Split a docstring into a summary plus named sections.
|
||||
|
||||
Pure: text in, `Docstring` out. Serves both Google-style sections
|
||||
(`Invariants:`, `Conventions:`) and the pre-registration sections
|
||||
(`Question:`, `Canary:`, ...), which differ only in whether the body
|
||||
starts on the header line.
|
||||
|
||||
Invariants:
|
||||
- A header is a single capitalised word plus ':' at column 0 of the
|
||||
cleaned docstring. Everything before the first header is the summary.
|
||||
- Text on the header line belongs to that section, so
|
||||
`Question: why` and `Question:\\n why` parse identically.
|
||||
- Section bodies are dedented one paragraph at a time, so a body that
|
||||
mixes an indented paragraph with a flush-left one comes out flush.
|
||||
|
||||
Conventions:
|
||||
- `inspect.cleandoc`, not `textwrap.dedent`: a docstring's first line is
|
||||
flush and the rest is indented, so dedent alone finds a common prefix
|
||||
of "" and leaves every header indented and unmatched.
|
||||
- Content may only follow a header across **two or more** spaces, the
|
||||
alignment the pre-registration format already uses. That is what keeps
|
||||
a prose line like `Note: see above` from becoming a section.
|
||||
- Headers are case-sensitive: `Canary:` is a section, `canary:` is prose.
|
||||
- A repeated header is an error, not a silent overwrite.
|
||||
"""
|
||||
|
||||
import inspect
|
||||
import re
|
||||
import textwrap
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
|
||||
_HEADER = re.compile(r"^([A-Z][A-Za-z_]*):(?:[ \t]{2,}(\S.*))?$")
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True, slots=True)
|
||||
class Docstring:
|
||||
"""A parsed docstring.
|
||||
|
||||
Attributes:
|
||||
summary: Text before the first section header, dedented.
|
||||
sections: Section name to dedented body, in source order.
|
||||
"""
|
||||
|
||||
summary: str
|
||||
sections: Mapping[str, str]
|
||||
|
||||
|
||||
def parse(docstring: str) -> Docstring:
|
||||
"""Split a docstring into its summary and named sections.
|
||||
|
||||
Raises:
|
||||
ValueError: If a section header appears twice.
|
||||
"""
|
||||
lines = inspect.cleandoc(docstring).splitlines()
|
||||
summary: list[str] = []
|
||||
sections: dict[str, list[str]] = {}
|
||||
current: list[str] | None = None
|
||||
|
||||
for line in lines:
|
||||
header = _HEADER.match(line)
|
||||
if header is None:
|
||||
(summary if current is None else current).append(line)
|
||||
continue
|
||||
name, inline = header.group(1), header.group(2)
|
||||
if name in sections:
|
||||
msg = f"duplicate docstring section {name!r}"
|
||||
raise ValueError(msg)
|
||||
current = sections.setdefault(name, [])
|
||||
if inline:
|
||||
current.append(inline)
|
||||
|
||||
return Docstring(
|
||||
summary="\n".join(summary).strip(),
|
||||
sections={name: _dedent(body) for name, body in sections.items()},
|
||||
)
|
||||
|
||||
|
||||
def _dedent(lines: list[str]) -> str:
|
||||
"""Dedent a section body one paragraph at a time, the first line excepted.
|
||||
|
||||
Per paragraph, not per body: a real section has one paragraph indented
|
||||
under its header and a later one flush left, so the body's common prefix
|
||||
is "" and dedenting it as a whole leaves the first paragraph ragged.
|
||||
Within a paragraph only the common prefix goes, so an ASCII table keeps
|
||||
its shape.
|
||||
"""
|
||||
head, *rest = lines or [""]
|
||||
tail = "\n\n".join(
|
||||
textwrap.dedent(paragraph) for paragraph in "\n".join(rest).split("\n\n")
|
||||
)
|
||||
return f"{head.strip()}\n{tail}".strip()
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Load a Python package via griffe and walk dotted paths.
|
||||
|
||||
Pure on its arguments (the filesystem read happens inside griffe).
|
||||
|
||||
Invariants:
|
||||
- `load(repo, "core.types")` returns the `core.types` module object.
|
||||
- `load(repo, "core.types.Example")` returns the class object.
|
||||
- Failure to resolve any segment raises `ValueError` naming the segment.
|
||||
|
||||
Conventions:
|
||||
- The top-level segment is the package name, searched under `repo` and
|
||||
`repo/src` so that both flat and src layouts resolve.
|
||||
"""
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import griffe
|
||||
|
||||
|
||||
def search_paths(repo: Path) -> list[str]:
|
||||
"""Directories griffe should treat as import roots for `repo`."""
|
||||
return [str(path) for path in (repo, repo / "src") if path.is_dir()]
|
||||
|
||||
|
||||
def load(repo: Path, dotted: str) -> Any: # noqa: ANN401 — griffe objects are dynamic
|
||||
"""Walk a dotted path within a top-level package rooted at `repo`.
|
||||
|
||||
Raises:
|
||||
ValueError: If the path is empty, the package is not found, or a
|
||||
segment does not resolve.
|
||||
"""
|
||||
if not dotted:
|
||||
msg = "empty path"
|
||||
raise ValueError(msg)
|
||||
top, *rest = dotted.split(".")
|
||||
paths = search_paths(repo)
|
||||
try:
|
||||
obj = griffe.load(top, search_paths=paths)
|
||||
except ImportError as exc:
|
||||
# griffe's own error is a 60-line traceback through its finder; the
|
||||
# only facts that help are the package name and where we looked.
|
||||
msg = f"no package '{top}' under {' or '.join(paths)}"
|
||||
raise ValueError(msg) from exc
|
||||
walked = top
|
||||
for part in rest:
|
||||
members = getattr(obj, "members", None)
|
||||
if members is None or part not in members:
|
||||
msg = f"path '{dotted}' not found (failed at '{part}' under '{walked}')"
|
||||
raise ValueError(msg)
|
||||
obj = members[part]
|
||||
walked = f"{walked}.{part}"
|
||||
return obj
|
||||
|
||||
|
||||
def module_docstring(source: str) -> str:
|
||||
"""The module-level docstring of Python source text, or '' if there is none."""
|
||||
return ast.get_docstring(ast.parse(source)) or ""
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Pre-registration: the docstring sections an experiment must carry.
|
||||
|
||||
The `research` skill puts an experiment's spec in its module docstring, so
|
||||
that spec and implementation cannot drift. This module says which sections
|
||||
that means; `sonde prereg <script>` reviews one before it is implemented.
|
||||
|
||||
Invariants:
|
||||
- `sonde prereg` exits 1 when any section is missing. The gate is on the
|
||||
one script being written, never a repo-wide sweep: a sweep that errors
|
||||
on scripts older than the format teaches you to delete sections.
|
||||
- Unknown sections are left alone. The set below is a floor, not a schema.
|
||||
"""
|
||||
|
||||
from sonde import docstrings
|
||||
|
||||
SECTIONS: tuple[str, ...] = (
|
||||
"Question", # why we want to know, in the human's vocabulary
|
||||
"Tests", # the [[hypothesis]] served, and what either outcome changes
|
||||
"Table", # the empty results table: rows, columns, units, sentences
|
||||
"Canary", # a quantity whose correct value is known in advance
|
||||
"Falsifier", # what outcome kills this line of work
|
||||
"Cost", # rough compute, so "is this worth it" is answerable
|
||||
)
|
||||
|
||||
REQUIRED: frozenset[str] = frozenset(SECTIONS)
|
||||
|
||||
|
||||
def missing(doc: docstrings.Docstring) -> tuple[str, ...]:
|
||||
"""Pre-registration sections absent from a parsed docstring, in canonical order."""
|
||||
return tuple(name for name in SECTIONS if name not in doc.sections)
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Build the metadata a safetensors artifact must carry: the write side of `meta`.
|
||||
|
||||
Impure: reads the clock and runs `git` once. No torch, no safetensors import —
|
||||
it returns a plain `dict[str, str]` for the caller's `save_file(..., metadata=)`,
|
||||
so `sonde` stays installable on a machine with neither.
|
||||
|
||||
from sonde import provenance
|
||||
save_file(tensors, path, metadata=provenance(asdict(cfg), labels={"acts": names}))
|
||||
|
||||
Invariants:
|
||||
- Every value is a string, as the safetensors format requires; anything
|
||||
structured is JSON-dumped, so it greps.
|
||||
- A dirty tree is recorded as `<sha>-dirty`, never silently as `<sha>`: an
|
||||
artifact made from uncommitted code cannot be reproduced from its commit,
|
||||
and the metadata should say so.
|
||||
- Not being in a git repo raises. An artifact with no commit cannot back an
|
||||
`evidence:` field, and finding that out at write time is the cheap moment.
|
||||
- `labels` maps a tensor name to the labels of its first axis. A positional
|
||||
axis with no labels is how a report ends up hardcoding row names.
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from collections.abc import Mapping, Sequence
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def provenance(
|
||||
config: Mapping[str, object],
|
||||
*,
|
||||
labels: Mapping[str, Sequence[str]] | None = None,
|
||||
repo: Path = Path(),
|
||||
) -> dict[str, str]:
|
||||
"""Metadata for `safetensors.save_file`: commit, timestamp, config, labels.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If `repo` is not inside a git working tree.
|
||||
"""
|
||||
metadata = {
|
||||
"git_commit": _head(repo),
|
||||
"timestamp": datetime.now().astimezone().isoformat(timespec="seconds"),
|
||||
"experiment_config": json.dumps(config, default=str, sort_keys=True),
|
||||
}
|
||||
if labels:
|
||||
metadata["labels"] = json.dumps({k: list(v) for k, v in labels.items()})
|
||||
return metadata
|
||||
|
||||
|
||||
def _git(repo: Path, *args: str) -> str:
|
||||
out = subprocess.run( # noqa: S603 — fixed argv, no shell, no untrusted input
|
||||
["git", "-C", str(repo), *args], # noqa: S607 — git resolved from PATH
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if out.returncode != 0:
|
||||
msg = f"git {' '.join(args)} failed in {repo}: {out.stderr.strip()}"
|
||||
raise RuntimeError(msg)
|
||||
return out.stdout.strip()
|
||||
|
||||
|
||||
def _head(repo: Path) -> str:
|
||||
commit = _git(repo, "rev-parse", "--short", "HEAD")
|
||||
return f"{commit}-dirty" if _git(repo, "status", "--porcelain") else commit
|
||||
@@ -0,0 +1 @@
|
||||
"""Pure renderers: parsed objects in, `rich` renderables out. No I/O here."""
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Render safetensors provenance: one artifact in full, or a directory as a table.
|
||||
|
||||
Pure: parsed `Artifact`s in, `rich` renderable out.
|
||||
|
||||
Conventions:
|
||||
- Every artifact shows its tensor shapes. The concrete bug this exists for
|
||||
is a report cell that hardcodes row labels because the artifact stores
|
||||
them positionally; shapes make that visible.
|
||||
- Missing provenance renders as a red `-`, not an empty cell. An artifact
|
||||
with no `git_commit` cannot back an `evidence:` field.
|
||||
"""
|
||||
|
||||
import json
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
|
||||
from rich.console import Group, RenderableType
|
||||
from rich.json import JSON
|
||||
from rich.rule import Rule
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
|
||||
from sonde.artifacts import Artifact
|
||||
|
||||
_MISSING = Text("-", style="red")
|
||||
|
||||
|
||||
def render_one(artifact: Artifact) -> Group:
|
||||
"""Render one artifact: provenance fields, config JSON, tensor table."""
|
||||
parts: list[RenderableType] = [Rule(f"[bold cyan]{artifact.path.name}")]
|
||||
|
||||
fields = Table(show_header=False, box=None, padding=(0, 2, 0, 0))
|
||||
fields.add_column(style="dim")
|
||||
fields.add_column(overflow="fold")
|
||||
for key in ("timestamp", "git_commit"):
|
||||
fields.add_row(key, artifact.metadata.get(key) or _MISSING)
|
||||
for key in sorted(set(artifact.metadata) - {"timestamp", "git_commit"}):
|
||||
if key not in {"experiment_config", "labels"}:
|
||||
fields.add_row(key, artifact.metadata[key])
|
||||
parts.append(fields)
|
||||
|
||||
config = artifact.metadata.get("experiment_config")
|
||||
if config is not None:
|
||||
parts.append(Text("\nexperiment_config", style="dim"))
|
||||
parts.append(JSON(config))
|
||||
parts.append(Text(""))
|
||||
parts.append(_tensor_table(artifact))
|
||||
return Group(*parts)
|
||||
|
||||
|
||||
def render_many(artifacts: Sequence[Artifact], root: Path) -> Group:
|
||||
"""Render one row per artifact: path, timestamp, commit, config, shapes."""
|
||||
if not artifacts:
|
||||
return Group(Text(f"no safetensors under {root}", style="yellow"))
|
||||
|
||||
table = Table(title=f"safetensors in {root}", expand=True)
|
||||
table.add_column("file", style="cyan", no_wrap=True)
|
||||
table.add_column("when", style="green", no_wrap=True)
|
||||
table.add_column("config", overflow="fold")
|
||||
table.add_column("tensors", overflow="fold")
|
||||
for artifact in artifacts:
|
||||
table.add_row(
|
||||
artifact.path.relative_to(root).as_posix(),
|
||||
_when(artifact),
|
||||
_config_cell(artifact),
|
||||
_shapes(artifact),
|
||||
)
|
||||
return Group(table)
|
||||
|
||||
|
||||
def _when(artifact: Artifact) -> Text | RenderableType:
|
||||
stamp = artifact.metadata.get("timestamp")
|
||||
commit = artifact.metadata.get("git_commit")
|
||||
if stamp is None and commit is None:
|
||||
return _MISSING
|
||||
return Text(f"{stamp or '-'}\n{commit or '-'}")
|
||||
|
||||
|
||||
def _config_cell(artifact: Artifact) -> RenderableType:
|
||||
"""One line per config key: an indented JSON block makes rows unreadably tall."""
|
||||
raw = artifact.metadata.get("experiment_config")
|
||||
if raw is None:
|
||||
return _MISSING
|
||||
config = json.loads(raw)
|
||||
if not isinstance(config, dict):
|
||||
return Text(json.dumps(config))
|
||||
return Text(
|
||||
"\n".join(f"{key}={json.dumps(value)}" for key, value in config.items())
|
||||
)
|
||||
|
||||
|
||||
def _shapes(artifact: Artifact) -> str:
|
||||
return "\n".join(
|
||||
f"{tensor.name} {list(tensor.shape)}" for tensor in artifact.tensors
|
||||
)
|
||||
|
||||
|
||||
def _tensor_table(artifact: Artifact) -> Table:
|
||||
table = Table(title="tensors", header_style="bold", expand=False)
|
||||
table.add_column("name", style="bold")
|
||||
table.add_column("dtype", style="dim")
|
||||
table.add_column("shape")
|
||||
table.add_column("labels", overflow="fold")
|
||||
for tensor in artifact.tensors:
|
||||
names = artifact.labels.get(tensor.name)
|
||||
shown = _MISSING if names is None else Text(", ".join(names))
|
||||
table.add_row(tensor.name, tensor.dtype, str(list(tensor.shape)), shown)
|
||||
return table
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Find test functions that reference a symbol, and show them as snippets.
|
||||
|
||||
Pure on its arguments apart from reading the test files it is pointed at.
|
||||
Matching is textual over `ast`-extracted test bodies — noisy by design, since
|
||||
a same-named symbol from another module is still usually the example wanted.
|
||||
|
||||
Conventions:
|
||||
- Match is on the *unqualified* trailing name of the dotted path.
|
||||
- At most `max_hits` snippets are shown, each trimmed to `max_lines`.
|
||||
"""
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from rich.console import Group
|
||||
from rich.syntax import Syntax
|
||||
from rich.text import Text
|
||||
|
||||
|
||||
def render(
|
||||
dotted: str,
|
||||
tests_dir: Path,
|
||||
max_hits: int = 5,
|
||||
max_lines: int = 20,
|
||||
) -> Group:
|
||||
"""Walk `tests_dir` and render the test functions referencing the symbol."""
|
||||
name = dotted.rsplit(".", 1)[-1]
|
||||
if not tests_dir.is_dir():
|
||||
return Group(Text(f"no tests directory at {tests_dir}", style="yellow"))
|
||||
|
||||
hits = find(tests_dir, name)
|
||||
if not hits:
|
||||
return Group(Text(f"no test references to '{name}'", style="yellow"))
|
||||
|
||||
parts: list[Any] = [Text(f"{len(hits)} test references to '{name}'", style="bold")]
|
||||
for path, lineno, snippet in hits[:max_hits]:
|
||||
trimmed = "\n".join(snippet.splitlines()[:max_lines])
|
||||
parts.append(Text(f"\n{path}:{lineno}", style="dim cyan"))
|
||||
parts.append(Syntax(trimmed, "python", theme="ansi_dark"))
|
||||
if len(hits) > max_hits:
|
||||
parts.append(Text(f"\n... and {len(hits) - max_hits} more", style="dim"))
|
||||
return Group(*parts)
|
||||
|
||||
|
||||
def find(tests_dir: Path, name: str) -> list[tuple[Path, int, str]]:
|
||||
"""Test functions under `tests_dir` whose source mentions `name`."""
|
||||
hits: list[tuple[Path, int, str]] = []
|
||||
for path in sorted(tests_dir.rglob("*.py")):
|
||||
text = path.read_text(encoding="utf-8")
|
||||
for node in ast.walk(ast.parse(text)):
|
||||
if not isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef):
|
||||
continue
|
||||
source = ast.get_source_segment(text, node)
|
||||
if node.name.startswith("test_") and source and name in source:
|
||||
hits.append((path, node.lineno, source))
|
||||
return hits
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Render a module overview: docstring summary + public API table.
|
||||
|
||||
Pure: takes a griffe `Module`, returns a `rich` renderable.
|
||||
|
||||
Conventions:
|
||||
- A symbol is public iff its name does not start with '_' and, when
|
||||
`__all__` is defined, appears in it.
|
||||
- Submodules get their own table, and are listed whether or not `__all__`
|
||||
mentions them: `sonde module <pkg>` is the repo map, so a package that
|
||||
re-exports three names must still show its nine modules.
|
||||
- `Invariants:` and `Conventions:` are shown verbatim — they are the part
|
||||
of a module docstring that is not derivable from the code.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from rich.console import Group
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
|
||||
from sonde import docstrings
|
||||
|
||||
_PANELLED = ("Invariants", "Conventions")
|
||||
|
||||
|
||||
def render(module: Any) -> Group: # noqa: ANN401 — griffe objects are dynamic
|
||||
"""Render module summary, public API table, and the two panelled sections."""
|
||||
doc = docstrings.parse(docstring(module))
|
||||
parts: list[Any] = []
|
||||
if doc.summary:
|
||||
parts.append(Text(doc.summary))
|
||||
|
||||
parts += [
|
||||
table
|
||||
for table in (_submodule_table(module), _api_table(module))
|
||||
if table.row_count
|
||||
]
|
||||
parts += [
|
||||
Panel(Text(doc.sections[name]), title=name, title_align="left", expand=False)
|
||||
for name in _PANELLED
|
||||
if doc.sections.get(name)
|
||||
]
|
||||
return Group(*parts)
|
||||
|
||||
|
||||
def _submodule_table(module: Any) -> Table: # noqa: ANN401
|
||||
table = Table(title="Modules", header_style="bold", expand=False)
|
||||
table.add_column("name", style="bold")
|
||||
table.add_column("summary")
|
||||
|
||||
for name, member in _members(module).items():
|
||||
if name.startswith("_") or kind_of(member) != "module":
|
||||
continue
|
||||
# Text(), not str: docstrings carry [[wikilinks]] and rich markup eats them.
|
||||
table.add_row(name, Text(_first_line(docstring(member))))
|
||||
return table
|
||||
|
||||
|
||||
def _api_table(module: Any) -> Table: # noqa: ANN401
|
||||
table = Table(title="Public API", header_style="bold", expand=False)
|
||||
table.add_column("name", style="bold")
|
||||
table.add_column("kind", style="dim")
|
||||
table.add_column("signature")
|
||||
table.add_column("summary")
|
||||
|
||||
for name in _public_names(module):
|
||||
member = module.members[name]
|
||||
if getattr(member, "is_alias", False) or kind_of(member) == "module":
|
||||
continue
|
||||
# Text(), not str: docstrings carry [[wikilinks]] and rich markup eats them.
|
||||
table.add_row(
|
||||
name,
|
||||
kind_of(member),
|
||||
Text(_short_signature(member)),
|
||||
Text(_first_line(docstring(member))),
|
||||
)
|
||||
return table
|
||||
|
||||
|
||||
def _members(module: Any) -> dict[str, Any]: # noqa: ANN401
|
||||
return getattr(module, "members", {}) or {}
|
||||
|
||||
|
||||
def _public_names(module: Any) -> list[str]: # noqa: ANN401
|
||||
members = _members(module)
|
||||
exported = getattr(members.get("__all__"), "value", None)
|
||||
explicit = (
|
||||
{str(name) for name in exported} if isinstance(exported, list | tuple) else None
|
||||
)
|
||||
return [
|
||||
name
|
||||
for name in members
|
||||
if not name.startswith("_") and (explicit is None or name in explicit)
|
||||
]
|
||||
|
||||
|
||||
def _short_signature(member: Any) -> str: # noqa: ANN401
|
||||
params = getattr(member, "parameters", None)
|
||||
if params is None:
|
||||
return ""
|
||||
return "(" + ", ".join(param.name for param in params) + ")"
|
||||
|
||||
|
||||
def kind_of(obj: Any) -> str: # noqa: ANN401
|
||||
"""The griffe kind of an object as a string, or '?' if it has none."""
|
||||
kind = getattr(obj, "kind", None)
|
||||
return kind.value if kind is not None else "?"
|
||||
|
||||
|
||||
def docstring(obj: Any) -> str: # noqa: ANN401
|
||||
"""The raw docstring text of a griffe object, or '' if it has none."""
|
||||
doc = getattr(obj, "docstring", None)
|
||||
return (doc.value if doc is not None else "") or ""
|
||||
|
||||
|
||||
def _first_line(text: str) -> str:
|
||||
return next((line.strip() for line in text.splitlines() if line.strip()), "")
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Render a script's pre-registration sections, and what is missing.
|
||||
|
||||
Pure: a parsed docstring in, `rich` renderable out. This is the review
|
||||
surface of the two-step landing: a short read that decides whether the
|
||||
experiment gets implemented at all.
|
||||
|
||||
Conventions:
|
||||
- Sections render in the canonical order of `prereg.SECTIONS`, whatever
|
||||
order the docstring uses.
|
||||
- Sections outside that set are shown last rather than dropped.
|
||||
- Flush-left paragraphs are reflowed to the panel width. A paragraph with
|
||||
any indented or bulleted line is left byte for byte: that is how the
|
||||
`Table:` section's ASCII table survives.
|
||||
- `[[slug]]` citations are printed as written. Resolving them to titles is
|
||||
`pyroli`'s job, and the wiki's experiment page does it.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
from rich.console import Group, RenderableType
|
||||
from rich.panel import Panel
|
||||
from rich.text import Text
|
||||
|
||||
from sonde import prereg
|
||||
from sonde.docstrings import Docstring
|
||||
|
||||
|
||||
def render(name: str, doc: Docstring, source: str = "") -> Group:
|
||||
"""Render the summary, each pre-registration section, and any gaps.
|
||||
|
||||
`source` is the script text; given, a section missing only because its
|
||||
header has one space before the text gets a hint saying so.
|
||||
"""
|
||||
parts: list[RenderableType] = [Text(f"{name}\n", style="bold cyan")]
|
||||
if doc.summary:
|
||||
parts.append(Text(reflow(doc.summary)))
|
||||
parts.append(Text(""))
|
||||
|
||||
extra = tuple(key for key in doc.sections if key not in prereg.REQUIRED)
|
||||
for section in (*prereg.SECTIONS, *extra):
|
||||
body = doc.sections.get(section)
|
||||
if body:
|
||||
parts.append(
|
||||
Panel(
|
||||
Text(reflow(body)),
|
||||
title=section,
|
||||
title_align="left",
|
||||
border_style="dim" if section in extra else "none",
|
||||
)
|
||||
)
|
||||
|
||||
return Group(*parts, *_missing(doc, source))
|
||||
|
||||
|
||||
def reflow(text: str) -> str:
|
||||
"""Join the lines of every flush-left prose paragraph; leave the rest alone."""
|
||||
return "\n\n".join(
|
||||
paragraph if _is_literal(paragraph) else _join(paragraph)
|
||||
for paragraph in text.split("\n\n")
|
||||
)
|
||||
|
||||
|
||||
def _is_literal(paragraph: str) -> bool:
|
||||
"""True if any line is indented or bulleted, i.e. its layout is the content."""
|
||||
return any(
|
||||
line[:1].isspace() or line.lstrip().startswith(("- ", "* ", "| "))
|
||||
for line in paragraph.splitlines()
|
||||
)
|
||||
|
||||
|
||||
def _join(paragraph: str) -> str:
|
||||
return " ".join(line.strip() for line in paragraph.splitlines())
|
||||
|
||||
|
||||
def _missing(doc: Docstring, source: str) -> list[RenderableType]:
|
||||
missing = prereg.missing(doc)
|
||||
if not missing:
|
||||
return []
|
||||
style = "red" if "Canary" in missing else "yellow"
|
||||
# The parser needs two spaces after a header with inline text (so that
|
||||
# prose like "Note: see above" is not a section); one space is the typo.
|
||||
hints = [
|
||||
Text(
|
||||
f"`{name}:` needs two spaces before its text, or the text on the next line"
|
||||
)
|
||||
for name in missing
|
||||
if re.search(rf"^\s*{name}: \S", source, re.MULTILINE)
|
||||
]
|
||||
return [Text(f"\nmissing: {', '.join(missing)}", style=style), *hints]
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Render a single symbol: full signature + full docstring.
|
||||
|
||||
Pure: takes a griffe class/function/attribute object, returns a `rich`
|
||||
renderable.
|
||||
|
||||
Conventions:
|
||||
- For a class, field declarations and method headers are rendered as a
|
||||
python block: dataclass shape is the load-bearing structure for most
|
||||
type-driven invariants, so it belongs in the view.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from rich.console import Group
|
||||
from rich.syntax import Syntax
|
||||
from rich.text import Text
|
||||
|
||||
from sonde.views.module import docstring, kind_of
|
||||
|
||||
_DUNDERS = ("__init__", "__call__")
|
||||
|
||||
|
||||
def render(obj: Any) -> Group: # noqa: ANN401 — griffe objects are dynamic
|
||||
"""Render signature, class body if any, and the full docstring."""
|
||||
parts: list[Any] = []
|
||||
signature = _signature(obj)
|
||||
if signature:
|
||||
parts.append(_python(signature))
|
||||
|
||||
if kind_of(obj) == "class":
|
||||
body = _class_body(obj)
|
||||
if body:
|
||||
parts.append(_python(body))
|
||||
|
||||
doc = docstring(obj)
|
||||
if doc:
|
||||
parts.append(Text(doc))
|
||||
return Group(*parts)
|
||||
|
||||
|
||||
def _python(source: str) -> Syntax:
|
||||
return Syntax(source, "python", theme="ansi_dark", word_wrap=True)
|
||||
|
||||
|
||||
def _class_body(cls: Any) -> str: # noqa: ANN401
|
||||
"""Attribute declarations then method headers, mirroring a source file."""
|
||||
members = [
|
||||
member
|
||||
for member in (getattr(cls, "members", None) or {}).values()
|
||||
if not getattr(member, "is_alias", False)
|
||||
]
|
||||
attributes = [
|
||||
f" {_attribute(member)}"
|
||||
for member in members
|
||||
if kind_of(member) == "attribute"
|
||||
]
|
||||
methods = [
|
||||
f" def {member.name}({_params(member)}){_returns(member)}: ..."
|
||||
for member in members
|
||||
if kind_of(member) == "function"
|
||||
and (not member.name.startswith("_") or member.name in _DUNDERS)
|
||||
]
|
||||
return "\n".join(attributes + methods)
|
||||
|
||||
|
||||
def _attribute(attr: Any) -> str: # noqa: ANN401
|
||||
line = attr.name
|
||||
annotation = getattr(attr, "annotation", None)
|
||||
value = getattr(attr, "value", None)
|
||||
if annotation is not None:
|
||||
line += f": {annotation}"
|
||||
if value is not None:
|
||||
line += f" = {value}"
|
||||
return line
|
||||
|
||||
|
||||
def _signature(obj: Any) -> str: # noqa: ANN401
|
||||
kind = kind_of(obj)
|
||||
if kind == "function":
|
||||
return f"def {obj.name}({_params(obj)}){_returns(obj)}: ..."
|
||||
if kind == "class":
|
||||
bases = ", ".join(str(base) for base in getattr(obj, "bases", None) or [])
|
||||
return f"class {obj.name}({bases}): ..." if bases else f"class {obj.name}: ..."
|
||||
if kind == "attribute":
|
||||
return _attribute(obj)
|
||||
return ""
|
||||
|
||||
|
||||
def _returns(func: Any) -> str: # noqa: ANN401
|
||||
returns = getattr(func, "returns", None)
|
||||
return f" -> {returns}" if returns is not None else ""
|
||||
|
||||
|
||||
def _params(func: Any) -> str: # noqa: ANN401
|
||||
params = getattr(func, "parameters", None)
|
||||
if params is None:
|
||||
return ""
|
||||
return ", ".join(_param(param) for param in params)
|
||||
|
||||
|
||||
def _param(param: Any) -> str: # noqa: ANN401
|
||||
piece = param.name
|
||||
annotation = getattr(param, "annotation", None)
|
||||
default = getattr(param, "default", None)
|
||||
if annotation is not None:
|
||||
piece += f": {annotation}"
|
||||
if default is not None:
|
||||
piece += f" = {default}"
|
||||
return piece
|
||||
@@ -0,0 +1,9 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repo_root() -> Path:
|
||||
"""This repo's root, whose `src/sonde` is the package `load` is tested against."""
|
||||
return Path(__file__).resolve().parent.parent
|
||||
@@ -0,0 +1,60 @@
|
||||
import json
|
||||
import struct
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from sonde.artifacts import parse_since, read, timestamp
|
||||
|
||||
|
||||
def write_safetensors(path, header: dict):
|
||||
blob = json.dumps(header).encode()
|
||||
path.write_bytes(struct.pack("<Q", len(blob)) + blob + b"\x00" * 4)
|
||||
return path
|
||||
|
||||
|
||||
def test_read_returns_metadata_and_shapes(tmp_path):
|
||||
path = write_safetensors(
|
||||
tmp_path / "data.safetensors",
|
||||
{
|
||||
"__metadata__": {
|
||||
"git_commit": "993d34d",
|
||||
"timestamp": "2026-07-29T14:12:03",
|
||||
"experiment_config": json.dumps({"model": "gpt2"}),
|
||||
},
|
||||
"rho": {"dtype": "F32", "shape": [139], "data_offsets": [0, 4]},
|
||||
},
|
||||
)
|
||||
artifact = read(path)
|
||||
assert artifact.metadata["git_commit"] == "993d34d"
|
||||
assert artifact.config == {"model": "gpt2"}
|
||||
assert artifact.tensors[0].shape == (139,)
|
||||
assert timestamp(artifact) == datetime(2026, 7, 29, 14, 12, 3)
|
||||
|
||||
|
||||
def test_artifact_without_provenance_reads_but_says_so(tmp_path):
|
||||
path = write_safetensors(
|
||||
tmp_path / "naked.safetensors",
|
||||
{"x": {"dtype": "F32", "shape": [3], "data_offsets": [0, 12]}},
|
||||
)
|
||||
artifact = read(path)
|
||||
assert artifact.metadata == {}
|
||||
assert artifact.config is None
|
||||
assert timestamp(artifact) is None
|
||||
|
||||
|
||||
def test_reading_a_non_safetensors_file_raises(tmp_path):
|
||||
path = tmp_path / "data.pt"
|
||||
path.write_bytes(b"PK\x03\x04" + b"\x00" * 64)
|
||||
with pytest.raises(ValueError, match="not a safetensors file"):
|
||||
read(path)
|
||||
|
||||
|
||||
def test_parse_since_forms():
|
||||
now = datetime(2026, 7, 30, 14, 0, 0)
|
||||
assert parse_since("today", now) == datetime(2026, 7, 30)
|
||||
assert parse_since("yesterday", now) == datetime(2026, 7, 29)
|
||||
assert parse_since("7d", now) == datetime(2026, 7, 23, 14, 0, 0)
|
||||
assert parse_since("2026-01-02", now) == datetime(2026, 1, 2)
|
||||
with pytest.raises(ValueError, match="Invalid isoformat"):
|
||||
parse_since("last tuesday", now)
|
||||
@@ -0,0 +1,61 @@
|
||||
import pytest
|
||||
|
||||
from sonde.docstrings import parse
|
||||
|
||||
|
||||
def test_google_block_sections_split():
|
||||
doc = parse(
|
||||
"""Summary line.
|
||||
|
||||
More summary.
|
||||
|
||||
Invariants:
|
||||
- a holds
|
||||
- b holds
|
||||
|
||||
Conventions:
|
||||
one line
|
||||
"""
|
||||
)
|
||||
assert doc.summary == "Summary line.\n\nMore summary."
|
||||
assert doc.sections["Invariants"] == "- a holds\n- b holds"
|
||||
assert doc.sections["Conventions"] == "one line"
|
||||
|
||||
|
||||
def test_preregistration_sections_start_on_the_header_line():
|
||||
doc = parse(
|
||||
"""E3 geometry: where directions sit.
|
||||
|
||||
Question: Why do we want
|
||||
to know this?
|
||||
Canary: v reads out at rank 1.
|
||||
"""
|
||||
)
|
||||
assert doc.summary == "E3 geometry: where directions sit."
|
||||
assert doc.sections["Question"] == "Why do we want\nto know this?"
|
||||
assert doc.sections["Canary"] == "v reads out at rank 1."
|
||||
|
||||
|
||||
def test_prose_colon_is_not_a_section():
|
||||
doc = parse("Summary.\n\nNote: this is prose, not a header.\n")
|
||||
assert doc.sections == {}
|
||||
assert "Note" in doc.summary
|
||||
|
||||
|
||||
def test_duplicate_section_raises():
|
||||
with pytest.raises(ValueError, match="duplicate docstring section 'Canary'"):
|
||||
parse("S.\n\nCanary:\n one\n\nCanary:\n two\n")
|
||||
|
||||
|
||||
def test_a_section_mixing_indented_and_flush_paragraphs_comes_out_flush():
|
||||
doc = parse(
|
||||
"Summary.\n\n"
|
||||
"Cost: Seconds, CPU, no model loaded,\n"
|
||||
" no backward pass.\n"
|
||||
"\n"
|
||||
"Saves to artifacts/e3/data.safetensors.\n"
|
||||
)
|
||||
assert doc.sections["Cost"] == (
|
||||
"Seconds, CPU, no model loaded,\nno backward pass.\n\n"
|
||||
"Saves to artifacts/e3/data.safetensors."
|
||||
)
|
||||
@@ -0,0 +1,29 @@
|
||||
import pytest
|
||||
|
||||
from sonde.loader import load, module_docstring, search_paths
|
||||
|
||||
|
||||
def test_search_paths_covers_flat_and_src_layouts(tmp_path):
|
||||
(tmp_path / "src").mkdir()
|
||||
assert search_paths(tmp_path) == [str(tmp_path), str(tmp_path / "src")]
|
||||
assert search_paths(tmp_path / "src") == [str(tmp_path / "src")]
|
||||
|
||||
|
||||
def test_load_walks_a_dotted_path(repo_root):
|
||||
assert load(repo_root, "sonde.loader").name == "loader"
|
||||
assert load(repo_root, "sonde.artifacts.Artifact").name == "Artifact"
|
||||
|
||||
|
||||
def test_a_missing_package_names_where_we_looked(repo_root):
|
||||
with pytest.raises(ValueError, match="no package 'nosuchpkg' under"):
|
||||
load(repo_root, "nosuchpkg")
|
||||
|
||||
|
||||
def test_a_missing_member_names_the_failing_segment(repo_root):
|
||||
with pytest.raises(ValueError, match=r"failed at 'Ghost' under 'sonde\.loader'"):
|
||||
load(repo_root, "sonde.loader.Ghost")
|
||||
|
||||
|
||||
def test_module_docstring_of_a_script_without_one():
|
||||
assert module_docstring("x = 1\n") == ""
|
||||
assert module_docstring('"""Summary."""\nx = 1\n') == "Summary."
|
||||
@@ -0,0 +1,39 @@
|
||||
from rich.console import Console
|
||||
|
||||
from sonde.docstrings import parse
|
||||
from sonde.loader import module_docstring
|
||||
from sonde.prereg import SECTIONS, missing
|
||||
from sonde.views.prereg import render
|
||||
|
||||
PREREG = '''"""One line.
|
||||
|
||||
Question: why
|
||||
Tests: [[h3]]
|
||||
Table: rows x cols
|
||||
Canary: min_eig(Delta) >= -noise
|
||||
Falsifier: rho is flat
|
||||
Cost: seconds, CPU
|
||||
"""
|
||||
'''
|
||||
|
||||
|
||||
def test_a_full_preregistration_has_no_gaps():
|
||||
assert missing(parse(module_docstring(PREREG))) == ()
|
||||
|
||||
|
||||
def test_a_missing_canary_is_named():
|
||||
source = PREREG.replace("Canary: min_eig(Delta) >= -noise\n", "")
|
||||
assert missing(parse(module_docstring(source))) == ("Canary",)
|
||||
|
||||
|
||||
def test_a_script_with_no_preregistration_misses_every_section():
|
||||
assert missing(parse("Just a summary.")) == SECTIONS
|
||||
|
||||
|
||||
def test_a_one_space_header_is_named_as_the_reason_it_is_missing():
|
||||
source = PREREG.replace("Canary: min_eig", "Canary: min_eig")
|
||||
doc = parse(module_docstring(source))
|
||||
assert missing(doc) == ("Canary",)
|
||||
console = Console(width=200, record=True)
|
||||
console.print(render("e3.py", doc, source))
|
||||
assert "`Canary:` needs two spaces" in console.export_text()
|
||||
@@ -0,0 +1,50 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from sonde import provenance
|
||||
|
||||
|
||||
def git(repo: Path, *args: str) -> None:
|
||||
subprocess.run(["git", "-C", str(repo), *args], check=True, capture_output=True) # noqa: S603, S607
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repo(tmp_path: Path) -> Path:
|
||||
git(tmp_path, "init", "-q")
|
||||
(tmp_path / "x").write_text("x\n")
|
||||
git(tmp_path, "add", "x")
|
||||
git(
|
||||
tmp_path,
|
||||
"-c",
|
||||
"user.name=t",
|
||||
"-c",
|
||||
"user.email=t@t",
|
||||
"commit",
|
||||
"-qm",
|
||||
"x",
|
||||
"--no-gpg-sign",
|
||||
)
|
||||
return tmp_path
|
||||
|
||||
|
||||
def test_every_value_is_a_string_and_structure_is_json(repo: Path):
|
||||
meta = provenance(
|
||||
{"layer": 6, "path": Path("a")}, labels={"acts": ["cat"]}, repo=repo
|
||||
)
|
||||
assert all(isinstance(value, str) for value in meta.values())
|
||||
assert json.loads(meta["experiment_config"]) == {"layer": 6, "path": "a"}
|
||||
assert json.loads(meta["labels"]) == {"acts": ["cat"]}
|
||||
|
||||
|
||||
def test_a_dirty_tree_is_recorded_as_dirty(repo: Path):
|
||||
assert not provenance({}, repo=repo)["git_commit"].endswith("-dirty")
|
||||
(repo / "x").write_text("changed\n")
|
||||
assert provenance({}, repo=repo)["git_commit"].endswith("-dirty")
|
||||
|
||||
|
||||
def test_outside_git_it_raises(tmp_path: Path):
|
||||
with pytest.raises(RuntimeError, match="rev-parse"):
|
||||
provenance({}, repo=tmp_path)
|
||||
Generated
+241
@@ -0,0 +1,241 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.12"
|
||||
|
||||
[[package]]
|
||||
name = "annotated-doc"
|
||||
version = "0.0.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "griffe"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "griffecli" },
|
||||
{ name = "griffelib" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d5/03/75ca4d08a7eff164292d42cf72c7c510423b3414e66205f0ba5b35ad3034/griffe-2.3.0.tar.gz", hash = "sha256:aa5634c0d7802583ecd29a7fe335c02d462ffdf5ce8f51426a3d326f374e6d93", size = 247493, upload-time = "2026-09-04T15:08:15.364Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/61/f9/8bfacdb992aed404a3a53694689759841635bce93b86ddb621c1d7f2558c/griffe-2.3.0-py3-none-any.whl", hash = "sha256:92a41ff72eb1eb316cdcb7248a608f1313f9fbc6e35d5cffa5cf7c813b7db70b", size = 5072, upload-time = "2026-09-04T15:08:14.074Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "griffecli"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama" },
|
||||
{ name = "griffelib" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5c/15/f70539f7efb44f27d209b43e386b1a5397bd476879ff19a206b75454f58e/griffecli-2.3.0.tar.gz", hash = "sha256:916fdc851cf51d38e9b5ab82550305df45e5981d5dfe65d346544464abedb3ec", size = 59676, upload-time = "2026-09-04T15:08:16.426Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/06/8c/8ffa4272d6bf85a0abe8898ef74ef41d505317bb4103e14a11d45d799dce/griffecli-2.3.0-py3-none-any.whl", hash = "sha256:f94653edcb52ebcac21eaeb3b7121b065a636174eddf2bd558b5935e72a553e8", size = 11593, upload-time = "2026-09-04T15:08:11.739Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "griffelib"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/27/af/018c10bc9edd42b6ef6db2e96b09542050d5253f9b195e74bc910b2d13ab/griffelib-2.3.0.tar.gz", hash = "sha256:7b0952caf5bca6afa4bb5ee8c6a2d183fe3f21b62efc5f6c7243cb2b26d2d115", size = 234534, upload-time = "2026-09-04T15:08:17.472Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/41/63/e876e789525063c840ccfa8857febdabd6523bcef9ce7eb979b9305ea895/griffelib-2.3.0-py3-none-any.whl", hash = "sha256:1b8f9cd525681c26b1d6d574faa1371651e8459ca51d209684f50b8096ae06e0", size = 169423, upload-time = "2026-09-04T15:08:12.956Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markdown-it-py"
|
||||
version = "4.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "mdurl" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mdurl"
|
||||
version = "0.1.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.21.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyrefly"
|
||||
version = "1.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4c/7e/082cc1b392318bacfbc087732524f3e089e9e7fe45b7645c4649b6f1f1e9/pyrefly-1.3.1.tar.gz", hash = "sha256:b774f1dfac82867fc16fe2175d80a630e2a72a6835d5b7e492568d6bfb6b05e7", size = 6679145, upload-time = "2026-09-15T05:09:34.49Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/bc/f09b5bd7eed796349bd93627aa2c3086df7ed0a114dafc3d2b2841599ee7/pyrefly-1.3.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0a2a7dfed824fb066aa8a7c8d856c152b98e998fb84b954d5f892fb9834c0bd1", size = 15090117, upload-time = "2026-09-15T05:08:58.477Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/24/a31e7de8e0f310c9f0753826d0df8b6a8ff86793941a6bd88ba24b735633/pyrefly-1.3.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:37a1a1fb41e7190fe3964a877875dd809d6ee2887809e2c3d6e5f25a69805bfd", size = 14430773, upload-time = "2026-09-15T05:09:01.67Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/e1/022352beb186f36d6213a95a975c42676ee418ff5e730629378d25564c5e/pyrefly-1.3.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45b7650bc651a5a784701f2fb4f579da9a421c4d1b5fd91f9048f4529e46093c", size = 14877762, upload-time = "2026-09-15T05:09:04.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/2c/d62a1b445aad1fd46375b83393b8a67da53883aeeba73d269ce71b3e8fd1/pyrefly-1.3.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ad57f38bc73679c14a5f62995a2bd3bc43649b7270c858487e2eab78a6f95362", size = 16118225, upload-time = "2026-09-15T05:09:07.828Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/67/3c3fa8a13e3ce8d8322ffb3627255a41a52261f17793e1a24e9214f10857/pyrefly-1.3.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3506b1252e9f5d755d0693c5a46d46b85579d36534f6e22a41eb8e098d0dde90", size = 16051197, upload-time = "2026-09-15T05:09:10.81Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/d1/84f81b9e32288b5d11c75e006f67e0f6b78b85da575673bd1a21524c8172/pyrefly-1.3.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39d5a17c1ad655e5f93a6e8e0be90b8db28c6015fe07d6ed4e054891c9e14ef8", size = 15440062, upload-time = "2026-09-15T05:09:14.641Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/3c/2aa0c5ad6a2957a728bb38cfab70d1e9cafad21b0b1c1d92df74a3cf22bd/pyrefly-1.3.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c61e9dcf888beb81ca0e0c60ea4442640fdcf2f1f65bfe3d9f5d89b85516eb03", size = 14898534, upload-time = "2026-09-15T05:09:17.823Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/5d/2974f249fb2e285f966485719d977c5a2248cc4866403af94d935775ff15/pyrefly-1.3.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a78eed1c53f37807f0e2997be8edbe833b91eb9915f2cb553a1bdcd599e7105c", size = 15477758, upload-time = "2026-09-15T05:09:20.964Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/79/d31f8f0abf5b0610ec24183286ee8a1ee5b76c9aaaa91091399d7e1aaf60/pyrefly-1.3.1-py3-none-win32.whl", hash = "sha256:ea0d0349f1bc80fe8e00a7b3640e40db7486398b685f87b9bd5d2207063668f0", size = 14133005, upload-time = "2026-09-15T05:09:25.03Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/23/1859f47f536314755d80c4ec2a76d2698435ae8b73604bcd1dfa57ecb39b/pyrefly-1.3.1-py3-none-win_amd64.whl", hash = "sha256:4c24e7d99df9237916c33be23714219a7c8eaf22bad2ac0c3e1d45adcf7df8fb", size = 15066557, upload-time = "2026-09-15T05:09:28.265Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/2a/73e3721921c2bb2c950a9f8c362ee5e2367556b5d4416eb4f8f88503e66f/pyrefly-1.3.1-py3-none-win_arm64.whl", hash = "sha256:1ed85e2b957a78e93353125d890ce3668ce450e49b74025a15c87b6e8ec7f3d1", size = 14348209, upload-time = "2026-09-15T05:09:31.422Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rich"
|
||||
version = "15.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markdown-it-py" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.16.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/96/bf/c935ca98e73fe8ce65b87ef08a280c0c1e85295d569228c15e87d8fdfaf1/ruff-0.16.9.tar.gz", hash = "sha256:12b625c6cfba78d285d9f48eda5f053374f1e53cb10ef17342a383750db99161", size = 4948764, upload-time = "2026-09-24T20:37:49.416Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/26/df51322b52ee1ada7eff2d071ea09d11d5c2d1dcc9f02594f5785c4d1635/ruff-0.16.9-py3-none-linux_armv6l.whl", hash = "sha256:95e6f022090368ab3b824c36276839c53b2adf1a3f4c09fefc33dfc400f6da96", size = 10082922, upload-time = "2026-09-24T20:37:13.045Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/27/7bf51f5a7aa375e9f339280a303aab44ca75dc1525f1cdc5991761685b0f/ruff-0.16.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a5f27be168556594a86d2f415db0cf43f5291917849318f873c7e2791f7a8c67", size = 10236360, upload-time = "2026-09-24T20:37:16.049Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/63/09659283f92f02dff45809da194a70da2f688d87c55d8875c4fae3536072/ruff-0.16.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1632eb1d6197f33bd00b1acbc5b71009e89a8895c158e2d2b03a834fac964ab6", size = 9892940, upload-time = "2026-09-24T20:37:17.957Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/58/98de1b72ec172f5f8f1731236fe21585b3998bf7dfe9fcc44ae9ba626012/ruff-0.16.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3f951b14d865d5952c89d40a5ca07e87abe24fa5453299878411e127748fb1c", size = 10032114, upload-time = "2026-09-24T20:37:19.942Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/7d/f1e17c54ab59d4bad1dce8ee3e22a7a1d0ef4745240decacdcf3832b5bb2/ruff-0.16.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:447fc07e1573afff7cb02803462b12b6c8ece7cf10e2cd78565fa6d7a1c0bf8d", size = 9910227, upload-time = "2026-09-24T20:37:21.872Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/19/436f647a65075bbd3bab2668b3bdaa5120559b294694018cdcefabbbf30b/ruff-0.16.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a3e039a6a40ed976c491722b60e0ae4a4aa1a86057f540ee7a37a5d19ae9120", size = 10547484, upload-time = "2026-09-24T20:37:24.229Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/59/38430a6bc2f6d8095447ac39625cf8b6e9344a47e6c26225c8ba1bff3ffb/ruff-0.16.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4684dded7db60aa57cb118fa158630f5feade4af5782903b6053484bdf9bd129", size = 11412367, upload-time = "2026-09-24T20:37:26.307Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/bd/bbb6d7fc7f208c8b8c50dd5dc8206e4cfdb1e7a8fb852606a3adf370c880/ruff-0.16.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d29c934357e45642fda2f34c0b1f4025b4a6c01e15e4bf0016879d60078a142c", size = 10869787, upload-time = "2026-09-24T20:37:28.35Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/b8/9c543074918061abbefc3bd139bee22de35abedb00dde0f2d27288838962/ruff-0.16.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a21713e629d3e5bdb2f5c2def1cc7f04f47fa8e1a7eb0571b4a28e1da64bc728", size = 10406494, upload-time = "2026-09-24T20:37:30.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/7a/5a8851bd146e7ccf8fd4b003f6c75c11f8fbdb0e60673b45097986b6bf41/ruff-0.16.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7baa24ef5fc8e77aa93879e1d3f43754a01ae488e869f1ae30cf431afd4d2452", size = 10590083, upload-time = "2026-09-24T20:37:32.439Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/f0/4c3467188f23f806960b46fa76575a7cd0514c9ba90562650b71efc96980/ruff-0.16.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a41aac6230aadfaa133bdfa1614488531ffa3e0837567ae04c0da2058a9c0f9e", size = 10119151, upload-time = "2026-09-24T20:37:34.581Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/34/5a4def5adea572ce6aea0bb64f21f928ee317b80e0db747d7979b01d7261/ruff-0.16.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c2529fb5896d49115b0e9aa8f887490b34bbe76baf879ec2264ac59406869ce7", size = 9911544, upload-time = "2026-09-24T20:37:36.796Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/5d/d15ebea7499eef9373318c0ee6ca127832927c6529731f6d48e18dce7ca9/ruff-0.16.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:41e3870277694177429b56406d65dfbdb2c2802c52b715edaf6a0b829c69d4ee", size = 10269884, upload-time = "2026-09-24T20:37:38.857Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/56/c5d3cd119ded7a3c7aba0e961b69cb3df701c91662c98ad694467d060ce1/ruff-0.16.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8adbe4e58af167f767d7b2ba5e83c42e878350796cf78c2f5e14ab9903a92588", size = 10749366, upload-time = "2026-09-24T20:37:41.042Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/fe/734ec7527029ac757ecf821f143c9f3fcf69149c21f53a044a899b430f5a/ruff-0.16.9-py3-none-win32.whl", hash = "sha256:0e1dbc2073624dee6618d41d0098690a7244654af746704b64759e12b6b6b385", size = 10152355, upload-time = "2026-09-24T20:37:43.025Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/21/26e4643629b3ebb44f0a06f9c9a53058d63d989415f63a9a3c28e2ee7f22/ruff-0.16.9-py3-none-win_amd64.whl", hash = "sha256:6bd40fec8cd4c8a3d4dd589bd8ad4e6320c13c29234159bfd959a40d529d597b", size = 10592965, upload-time = "2026-09-24T20:37:44.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/60/5fb1a39dbb5ae314d5f59bc7348a63c1d5c20f3cd83914c4b5cb0be31d2d/ruff-0.16.9-py3-none-win_arm64.whl", hash = "sha256:ed1a252039200f57a59eebc063b54beabea67bfbaaca0eeaa7f54b5fbcda2284", size = 10458649, upload-time = "2026-09-24T20:37:46.882Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shellingham"
|
||||
version = "1.5.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sonde"
|
||||
version = "0.2.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "griffe" },
|
||||
{ name = "rich" },
|
||||
{ name = "typer" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "pyrefly" },
|
||||
{ name = "pytest" },
|
||||
{ name = "ruff" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "griffe", specifier = ">=1.0" },
|
||||
{ name = "rich", specifier = ">=13.0" },
|
||||
{ name = "typer", specifier = ">=0.12" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "pyrefly", specifier = ">=0.16" },
|
||||
{ name = "pytest", specifier = ">=8.0" },
|
||||
{ name = "ruff", specifier = ">=0.6" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typer"
|
||||
version = "0.27.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-doc" },
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "rich" },
|
||||
{ name = "shellingham" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/16/f7/57713ba479fd405eb76de31404b2c744c289e336b2d999511ebf51e496f7/typer-0.27.2.tar.gz", hash = "sha256:269b7eb9d3c202ca84b4bc9618cb04ebb43d3d4d1e567e4c768607232c05f945", size = 204045, upload-time = "2026-08-28T10:26:55.046Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/bf/205d0004930ede8f542fb58f601526fccf4ae7626075ca1e6c4de5d3d652/typer-0.27.2-py3-none-any.whl", hash = "sha256:b3a5fc4342d5fc8fda8fc3010b1cf117e9249aab7fae800c2eff62fd3842d97d", size = 123130, upload-time = "2026-08-28T10:26:53.752Z" },
|
||||
]
|
||||
@@ -0,0 +1,104 @@
|
||||
---
|
||||
name: research
|
||||
description: Use it when doing explicit research work, not SWE work. Needs `knowledge-and-memory`, `common-tooling`, and `python` skills.
|
||||
---
|
||||
|
||||
**Rationale:** research needs exploration: rapid tests, hypothesis, experiments, but then we need to exploit the results: perform ablation, red team the results, until we are confident and can write a report. This is different from SWE as we don't know where we're heading.
|
||||
|
||||
**Solution:** Monorepo with threads. Different regime for each phase of the research process.
|
||||
|
||||
**Knowledge:** copy `references/schema.yaml` from this skill to `knowledge/schema.yaml` (once per repo; edit the copy, not this one).
|
||||
|
||||
## Monorepo structure
|
||||
|
||||
```sh
|
||||
monorepo/
|
||||
- vendor/ # cloned repositories from github, put there everything we don't want to rewrite, things we want to compare against, etc...
|
||||
- datasets/ # rule of three: share only if a third thread would use it unchanged
|
||||
- artifacts/ # rule of three: share only if a third thread would use it unchanged
|
||||
- knowledge/ # general knowledge, can be papers, blogs, atoms
|
||||
- threads/
|
||||
- thread_name/ # make it python importable, so phase scripts can run easily
|
||||
- scratch/ # scratchpad: .md / .py, whatever useful to keep for the agent, not the user, see below
|
||||
- experiments/ # standalone .py files to reproduce the important results -> written only when the experiment is promoted as "interesting"
|
||||
- datasets/
|
||||
- artifacts/
|
||||
- knowledge/ # knowledge of the thread: promoted to general knowledge by user; this knowledge can have all the memory the agent wants to keep; if something is important and should be known at each run, add a link in AGENTS.md and write the content as an atom in the knowledge/ folder
|
||||
- AGENTS.md
|
||||
- README.md # human readable global idea of the thread
|
||||
- REPORT.md # "blog" like report
|
||||
- CLAUDE.md # @AGENTS.md
|
||||
- AGENTS.md # general instructions: structure, tools, briefing
|
||||
- README.md # very brief global idea / research direction
|
||||
- PLAN.md # long term plan -> keep the horizon in sight, do not drift too much
|
||||
```
|
||||
|
||||
Scratch: **No number without a committed file.** Any number in a claim must be greppable to a file in git. If it is not in experiments, use scratch: ugly, cheap, verifiable. `python -c` stays legal ONLY for terminal work (a shape, a tokenizer id, a signature), general reckon, and is not science.
|
||||
|
||||
## Research phases
|
||||
|
||||
**Exploration.** Less rigorousness is accepted. Skipping rules from the skills is accepted.
|
||||
|
||||
**Exploitation.** Once enough evidence is accumulated, we start ablation experiments, benchmarks, and red teaming the results: we push counter arguments, and try to falsify the results.
|
||||
|
||||
- use ablations to find the most interesting piece of the work: remove the noise
|
||||
- more effort should be put to try to destroy the claims than to support them: honesty and quality are worth a hundred papers -> destroying every result and starting back at step 0 is OK and welcome if the research wasn't promising enough
|
||||
|
||||
**Reporting.** If the experiment/claim/result passed the red teaming part, only then we can write a report.
|
||||
|
||||
- in (user facing) reports, write maths as latex only
|
||||
- reports as lesswrong posts: they should be readable by any researcher in the field. The report should be readable without access to the code.
|
||||
- start with the context and main results/takeaways
|
||||
- present the main experiments, add figures when needed, detail the main methods
|
||||
- link/cite your hypothesis clearly
|
||||
- add examples: an example is worth a thousand words
|
||||
- add a details section: there you can add complex/technical stuff, failed experiments, everything you want to present that is not the main result
|
||||
- main experiments should be rewritten as standalone python files to make verification/replication straightforward. In these standalone files: explain the code with module docstrings, function docstrings, comments.
|
||||
|
||||
## Common methods when approaching the exploitation phase or when exploitation phase is reached
|
||||
|
||||
- **Every experiment carries a canary** — a measured quantity whose correct value is known _analytically, in advance_. If none can be constructed, that is evidence the measurement isn't understood well enough to run yet. Treat it as a stop, not a formality.
|
||||
- **Argue claim** into `knowledge/claim/*.md` with `status: open`. An experiment that names no claim does not run. A `question` above it is cheap and is what keeps the graph rooted in something you wanted to know.
|
||||
- Before running an experiment, write the pre-registration and ask yourself: _if this comes out either way, what changes in the top-level claim?_ if "nothing", don't run.
|
||||
- **Literature check.** Restate the experiment/result in a vocabulary that is not this project's and do a literature search to see if someone has already done it. -> Scheduled, not as a default at every experiment.
|
||||
- **Own the abstraction one level below what you study.** Studying gradients → own the backward pass. Studying activations and interventions → nnsight or TL hooks save real time. "Always use nnsight" is not a rule; this is.
|
||||
|
||||
## Pre-registration for main experiments (not scratch)
|
||||
|
||||
The experiment's spec lives in its own module docstring to avoid drift. `sonde prereg <script>` shows it and exits 1 while a section is missing: run it before implementing. Example:
|
||||
|
||||
```python
|
||||
"""Whether the per-layer AGOP spectrum is layer-local.
|
||||
|
||||
Question: Why do we want to know this? One paragraph, in the human's vocabulary, not the code's.
|
||||
Tests: [[h3-mean-covariance-split]] — and what changes if it goes either way.
|
||||
Table: (IMPORTANT) The empty results table. Rows, columns, units, and the sentence each cell would license. Written before the code. Important so we know what to expect. This can be followed by a prediction like "I expect the depth to increase ...".
|
||||
Canary: (IMPORTANT) A quantity whose value is known analytically in advance, and that value.
|
||||
Falsifier: What outcome kills this line of work, stated so it can fire.
|
||||
Cost: Rough compute, so "is this worth it" is answerable.
|
||||
"""
|
||||
|
||||
raise NotImplementedError
|
||||
```
|
||||
|
||||
## Artifacts
|
||||
|
||||
Write safetensors with metadata, never raw `torch.save`. `sonde.provenance` builds it (git commit, `-dirty` if the tree is, timestamp, config as JSON):
|
||||
|
||||
```python
|
||||
from dataclasses import asdict
|
||||
from safetensors.torch import save_file
|
||||
from sonde import provenance
|
||||
|
||||
save_file(
|
||||
tensors,
|
||||
path,
|
||||
metadata=provenance(
|
||||
asdict(config),
|
||||
# a tensor whose first axis means more than "sample index" gets labels
|
||||
labels={"acts": prompt_names},
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
`sonde meta <file or dir>` reads it back: provenance, config, shapes, labels. `sonde` must be importable in the repo: `uv add <path to the python skill>/sonde` (usually `~/.claude/skills/python/sonde`).
|
||||
@@ -0,0 +1,78 @@
|
||||
# This is the default schema for research: override if a schema.yaml exist in your repository. Tell the user whenever you feel like it is wrong, or needs an update.
|
||||
|
||||
# What a status means to the propagation check, for the statuses where it matters. Anything unlisted is in-progress and propagates nothing: an `open` claim is allowed to depend on a refuted one, because reacting to a refutation is exactly the work that status describes.
|
||||
status_classes:
|
||||
settled: [stable, supported, verified, done, accepted, answered, read]
|
||||
dead: [refuted, abandoned, dropped, aborted, superseded]
|
||||
|
||||
node_types:
|
||||
concept:
|
||||
statuses: [stub, draft, stable]
|
||||
diataxis: explanation
|
||||
gotcha:
|
||||
statuses: [stub, draft, stable]
|
||||
source:
|
||||
statuses: [unread, read]
|
||||
requires: [citekey]
|
||||
|
||||
question:
|
||||
statuses: [open, answered, dropped]
|
||||
claim:
|
||||
statuses: [open, supported, refuted, abandoned]
|
||||
evidence:
|
||||
statuses: [draft, verified]
|
||||
requires: [provenance]
|
||||
experiment:
|
||||
statuses: [planned, running, done, aborted]
|
||||
|
||||
relation_types:
|
||||
# Research: the discourse-graph edges. These are the contested ones — the ones worth promoting to their own node when you want to qualify them.
|
||||
# `concept` is in the domain and deliberately not in the range: a definition can be a reason, but it is not the kind of thing a measurement confirms.
|
||||
supports:
|
||||
inverse: supported_by
|
||||
domain: [evidence, claim, concept, gotcha]
|
||||
range: [claim, gotcha]
|
||||
contradicts:
|
||||
inverse: contradicted_by
|
||||
domain: [evidence, claim, concept, gotcha]
|
||||
range: [claim, gotcha]
|
||||
# `contradicts` is tension; `refutes` is a decision that has been taken, and it is the one the propagation check reacts to. Keeping them distinct is what lets a refuted claim stay in the graph instead of being deleted.
|
||||
refutes:
|
||||
inverse: refuted_by
|
||||
domain: [evidence, claim, concept, gotcha]
|
||||
range: [claim, gotcha]
|
||||
tests:
|
||||
inverse: tested_by
|
||||
domain: [experiment]
|
||||
range: [claim, question]
|
||||
answers:
|
||||
inverse: answered_by
|
||||
domain: [claim, evidence]
|
||||
range: [question]
|
||||
raises:
|
||||
inverse: raised_by
|
||||
domain: ["*"]
|
||||
range: [question]
|
||||
|
||||
# Structural: cheap, uncontested, stay in frontmatter forever.
|
||||
depends_on:
|
||||
inverse: required_by
|
||||
transitive: true
|
||||
refines:
|
||||
inverse: refined_by
|
||||
broader:
|
||||
inverse: narrower # SKOS-flavoured: not subclass, not transitive
|
||||
|
||||
# Citation, a frozen CiTO subset. Range is always a source node; citing a paper you have no node for is a `citekey`, not an edge.
|
||||
cites_as_evidence:
|
||||
range: [source]
|
||||
uses_method_in:
|
||||
range: [source]
|
||||
extends:
|
||||
range: [source]
|
||||
disagrees_with:
|
||||
range: [source]
|
||||
confirms:
|
||||
range: [source]
|
||||
background_from:
|
||||
range: [source]
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
name: swe
|
||||
description: Use it when doing explicit SWE work, not research. Needs `knowledge-and-memory`, and `common-tooling` skills
|
||||
---
|
||||
|
||||
**Rationale:** context is limited and precious, plus, code quantity is not the bottleneck anymore, verifiability is.
|
||||
|
||||
**Solution:** keep documentation as close as possible to the code (comments, docstrings). Write scripts as documentation if possible, or as tests/invariants. A rule that is enforced in a script we run before each commit is a rule that is not forgotten in an obscure readme.
|
||||
|
||||
**Knowledge:** copy `references/schema.yaml` from this skill to `knowledge/schema.yaml` (once per repo; edit the copy, not this one).
|
||||
|
||||
## Clarity and verifiability via workflows and property based testing
|
||||
|
||||
**Code can change, workflow don't.** Discuss with the user what the workflows are with examples, to make sure the task is clear. Use these workflows as input -> output specification, docs, tests. Prefer property based tests and workflow tests (written as scripts for instance) to unit tests: I don't care about how the code is written (language, tools, structure), I care about the properties and workflows.
|
||||
|
||||
**Write the verification before the code.** a test that verify a property and that can at each commit will make sure the property stays valid, even after some time, when we won't think about it anymore.
|
||||
|
||||
## General rules
|
||||
|
||||
- **YAGNI.** (MOST IMPORTANT) Do the minimum first. Use existing tools when possible. Write as few lines of code as possible. Only add new features if they are needed explicitly.
|
||||
- Try to use as much pure functions as possible: they are easier to verify.
|
||||
- Use invariants in modules and write scripts to enforce these invariants: each clean invariant will save time when debugging.
|
||||
- **TYPE EVERYTHING**
|
||||
- **A bug fix starts with the failing test.** If the test cannot be made to fail first, the bug is not understood yet, and the fix is a guess.
|
||||
|
||||
- Documentation:
|
||||
- `README.md` are user facing: simple, concise, contains workflows and setup
|
||||
- comments: every decision/docs about the code that can live as comment should live as comment, so they move with the code and don't rot.
|
||||
- Important knowledge goes to knowledge/ as atoms.
|
||||
- Everything else goes to recursive .md files, like recursive `AGENTS.md` inside subfolders when needed: again, as close as possible from the actual code.
|
||||
|
||||
## Avoid writing too many atoms in knowledge/
|
||||
|
||||
Everything feels worth recording and almost nothing is. The filter: **Write an atom for what you would otherwise rediscover.** If the next person — including you in three months — would reach the same wrong conclusion and spend the same hour, it is an atom. If the code says it plainly, it is not.
|
||||
|
||||
Do not write what can be inferred via CLI: write what should be search, with which command, and let the agent/user run the command at the time when it's needed, so it's up to date.
|
||||
@@ -0,0 +1,81 @@
|
||||
# This is the default schema for SWE: override if a schema.yaml exist in your repository. Tell the user whenever you feel like it is wrong, or needs an update.
|
||||
#
|
||||
## Diátaxis: four page kinds that must not be mixed
|
||||
##
|
||||
# Most documentation rot is a reference page that grew a tutorial inside it. The node type carries the kind, so it is visible without a lint:
|
||||
#
|
||||
# - `howto` — goal-oriented, assumes competence. "How to add a source."
|
||||
# - `reference` — information-oriented, dry, complete. "Every field of the feed record."
|
||||
# - `concept` — understanding-oriented, discursive, project-independent.
|
||||
# - tutorials are not a type here, because a solo repo has no newcomers. If one is ever needed it is the README's quickstart.
|
||||
|
||||
status_classes:
|
||||
settled: [stable, supported, verified, done, accepted, answered, read]
|
||||
dead: [refuted, abandoned, dropped, aborted, superseded]
|
||||
|
||||
node_types:
|
||||
concept:
|
||||
statuses: [stub, draft, stable]
|
||||
diataxis: explanation
|
||||
gotcha:
|
||||
statuses: [stub, draft, stable]
|
||||
source:
|
||||
statuses: [unread, read]
|
||||
requires: [citekey]
|
||||
howto:
|
||||
# a multi-step procedure is run occasionally and forgotten between times. Do not write when it's one command, which belongs in the README.md
|
||||
statuses: [stub, draft, stable]
|
||||
diataxis: how-to
|
||||
reference:
|
||||
statuses: [stub, draft, stable]
|
||||
diataxis: reference
|
||||
pattern:
|
||||
# rule of three
|
||||
statuses: [stub, draft, stable]
|
||||
decision:
|
||||
# A `decision` atom is immutable once its status is `accepted`. When it stops being right, write a new one and link `supersedes: [old-slug]`; the old one becomes `status: superseded` and stays in the graph forever. This is the one place the method insists on append-only, and the reason is that the _rejected_ alternative is the valuable half — an edited ADR silently deletes the reason somebody will otherwise re-propose in six months.
|
||||
statuses: [proposed, accepted, superseded]
|
||||
|
||||
relation_types:
|
||||
# The discourse-graph edges, aimed at what SWE argues about: decisions and gotchas. These are the contested ones — the ones worth promoting to their own node when you want to qualify them.
|
||||
supports:
|
||||
inverse: supported_by
|
||||
domain: [concept, gotcha]
|
||||
range: [decision, gotcha]
|
||||
contradicts:
|
||||
inverse: contradicted_by
|
||||
domain: [concept, gotcha]
|
||||
range: [decision, gotcha]
|
||||
# `contradicts` is tension; `refutes` is a decision that has been taken, and it is the one the propagation check reacts to. Keeping them distinct is what lets a refuted claim stay in the graph instead of being deleted.
|
||||
refutes:
|
||||
inverse: refuted_by
|
||||
domain: [concept, gotcha]
|
||||
range: [decision, gotcha]
|
||||
# Structural: cheap, uncontested, stay in frontmatter forever.
|
||||
depends_on:
|
||||
inverse: required_by
|
||||
transitive: true
|
||||
refines:
|
||||
inverse: refined_by
|
||||
broader:
|
||||
inverse: narrower # SKOS-flavoured: not subclass, not transitive
|
||||
|
||||
# Citation, a frozen CiTO subset. Range is always a source node; citing a
|
||||
# paper you have no node for is a `citekey`, not an edge.
|
||||
cites_as_evidence:
|
||||
range: [source]
|
||||
uses_method_in:
|
||||
range: [source]
|
||||
extends:
|
||||
range: [source]
|
||||
disagrees_with:
|
||||
range: [source]
|
||||
confirms:
|
||||
range: [source]
|
||||
background_from:
|
||||
range: [source]
|
||||
|
||||
supersedes:
|
||||
inverse: superseded_by
|
||||
domain: [decision]
|
||||
range: [decision]
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
name: toml-setup
|
||||
description: Small toml setup with Taplo config
|
||||
---
|
||||
|
||||
`taplo.toml`
|
||||
@@ -0,0 +1,16 @@
|
||||
[formatting]
|
||||
allowed_blank_lines = 1
|
||||
array_auto_collapse = true
|
||||
array_auto_expand = true
|
||||
array_trailing_comma = true
|
||||
column_width = 100
|
||||
compact_arrays = true
|
||||
compact_entries = true
|
||||
compact_inline_tables = true
|
||||
indent_entries = true
|
||||
indent_string = ' '
|
||||
indent_tables = true
|
||||
inline_table_expand = true
|
||||
reorder_arrays = true
|
||||
reorder_keys = true
|
||||
trailing_newline = true
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user