AGENTS.md · git:20261018.ff01bb1 · 2026-10-18 · sha256 e3ac01fe92801b21
AGENTS.md git:20261018.ff01bb1A
Immutable. This exact content never changes and is served at /api/v1/blob/e3ac01fe92801b21.
# noWorkflow — instructions for agents
noWorkflow captures the provenance of Python scripts without changes to the
script: the `now` command runs the script, records what it defined, executed
and read or wrote, and stores everything in a `.noworkflow/` directory next to
it. The package lives under `src/noworkflow/`, the Flask visualization and the
Jupyter extension are built from `src/frontend/`. Versions of Python and of
the dependencies come from `pyproject.toml`, `uv.lock` and
`src/noworkflow/resources/version.txt` — do not reproduce them here.
## Existing documentation
Before writing a procedure, check what already exists:
- `README.md`: installing noWorkflow as a **user** (`pip install`) and the
usage of every command. It is not how to set up a development
environment.
- `CONTRIBUTING.md`: the development environment (what to install on a new
machine, and how to set up `.venv`), the commands to run locally, and the
contribution cycle (issue, branch, commit, PR).
- `SECURITY.md`: how security problems are reported.
- `src/frontend/development.md`: building the visualization bundle and the
Jupyter extension.
- The [wiki](https://github.com/gems-uff/noworkflow/wiki): tutorial videos
and related material.
Point to these pages instead of duplicating them. If something there is out
of date, fix it there.
## Working conventions
- One issue, one `issue_NNN` branch, always based on `main`. The cycle is in
`CONTRIBUTING.md`.
- **The commit subject starts with `Issue #NNN: `**, with the number of the
branch's issue. GitHub turns the number into a link, and the subject is what
appears in the commit list and in `git log --oneline`. After the prefix
about 60 columns remain: imperative mood, no trailing period, and the why in
the body. Two commits go without the prefix: one a maintainer puts directly
on `main` for work that belongs to no issue, and a Dependabot update a
maintainer integrates, which keeps the bot's subject (`Bump <package> from
<a> to <b>`) and gains the PR's number at the end, `(#NNN)`, when the
maintainer rewrites it.
- **An issue is closed when it is integrated into `main` with nothing left
to do.** `main` is the baseline and releases are cut from it, so integrated
and finished is done; an issue delivered in part stays open. Which issues
a release ships comes from its commits: the `Issue #NNN:` prefixes in
`git log <previous tag>..main`.
- **Reference the issue by the prefix alone, never with `Fixes` or
`Closes`.** Those words close the issue as soon as the first such commit
reaches `main`, while an issue often takes several commits, and whether
anything is left is the maintainer's call (`gh issue close <N> --reason
completed`).
- **Security work follows the same cycle as everything else.** noWorkflow runs
on the user's own machine, never as a service, so a public issue does not
expose anyone. Open the issue, describe the problem plainly, and prove the
fix with a test named after the property it guarantees (for example
"rejects a restore target outside the project directory") that is red before
the fix and green after it. See `SECURITY.md`.
- **Definition of done:** `uv run pytest` green from the repository root;
the test count at or above the floor of every leg (`min-tests` in
`.github/workflows/ci.yml`, `pre-release.yml` and `experimental.yml`, the one in
`flaky.yml` and the one of the `paper-example` job; the reference leg also
counts the goldens, which the other legs deselect), **and the floors go
up in the same PR that adds tests**;
`uv run ruff check .` clean; the bandit step of `checks` green, so every
site bandit flags either carries `# nosec Bxxx` with the reason on the line
above (intended) or has an issue and its entry in `bandit-baseline.json`
(real; the entry leaves with the fix), as "Running locally" in
`CONTRIBUTING.md` says; CI green at the tip of the branch; no new
CodeQL alert in the PR, or each one fixed or explained there for a
maintainer to dismiss (the `CodeQL` check, described in "What a pull
request needs" in `CONTRIBUTING.md`, and read as step 6 of `review-pr`
says); for a commit that reaches `main` without a PR, no open alert of
`main` created by its analysis, once that analysis has finished (`gh api
"repos/gems-uff/noworkflow/code-scanning/alerts?ref=refs/heads/main&state=open&sort=created&direction=desc"`
lists the newest first); and, whenever
`src/frontend` changes, `src/noworkflow/now/vis/static/dist/bundle.js`
rebuilt and committed in the same commit (the `frontend` job rebuilds it
and fails on any difference). The `bundle.js.map` the build rewrites is
not committed: it embeds the absolute path of the checkout. The commands
are in "The frontend" in `CONTRIBUTING.md`.
- Run the suite from the root. It runs from a scratch directory (see the root
`conftest.py`), so nothing it writes lands in the checkout; if you ever see
a `.noworkflow/` or `.last_trial` in the tree, a test leaked, and neither
is ever committed. The leak detector in the same file fails a test that
leaves such a file, or the collector's process state (the `open`s, the
builtins, `sys.meta_path`, `sys.argv`, `__main__`, the cwd), or a child
process it started and never waited for, behind; it kills that child.
`NOW_LEAK_CHECK=warn` only reports while you investigate.
- **Installing software on the machine needs the human's go-ahead.** That
covers uv, a Python interpreter, Node, a container engine, Rosetta, a
system package. uv counts too when it downloads an interpreter that is
missing, which `uv sync --python`, `uv run --python` and `uv python
install` do (`uv python list --only-installed` shows what is there).
Filling `.venv` from `uv.lock` (`uv sync --locked`) or `node_modules`
from the npm lock (`npm ci`) stays inside the checkout and is not an
install on the machine. To prepare an environment, check first what is
installed, and at which version, against "Development environment" in
`CONTRIBUTING.md`; then propose the commands, what each one costs (disk,
memory, a background VM) and what it unlocks, and wait. Never settle for a
worse workaround (a CI round trip instead of a local container, a check
left unrun) without saying that an install would avoid it.
- **Do not commit on your own.** Leave the change in the working tree, show
the diff and the result of the suite, and wait for whoever drives the
session. Commit, tag, push and new branches are theirs, also in step-by-step
work where "one commit per step" seems to authorize committing alone.
- Documentation and comments say **"the maintainer"**, never a person's name:
the role outlives whoever holds it.
- **Before opening an issue, search the existing ones, closed included,**
with [issuefinder](https://github.com/gems-uff/issuefinder) over the draft
of the new one: `uv run --project ../issuefinder issuefinder query -f
<draft>` from the checkout. It matches by meaning across English and
Portuguese (older issues and comments are in both) and by exact tokens (an
error message, a name, a path), and it syncs the issues before answering.
It lives in a sibling directory of the checkout; when it is missing, clone
it there (`gh repo clone gems-uff/issuefinder ../issuefinder`). Its
environment and its model (about 3 GB) land outside the checkout, so
setting it up is an install and needs the human's go-ahead; until then,
search with `gh issue list --state all --search "<term>"`, in both
languages. When a related issue exists, also read the commits that mention
it (`git log --all --grep="#NNN"`): that is where the product decision, the
reverted attempt or the partial fix is.
- **An issue is born with its Type set:** `gh issue create --type Bug`, or
`gh issue edit N --type Feature` afterwards. The three values are `Bug`
(the tool does badly what it already does), `Feature` (a new capability or a
requested change of behavior) and `Task` (dependency, infrastructure,
documentation, refactoring, test). Between Bug and Feature, look at the
effect on the user, not at the size of the fix: lost provenance, an ignored
flag, a crash, data exposed to the wrong place are Bugs even when fixing
them means building what does not exist yet.
- **Type says what the issue is, the milestone (when there is one) says
which version the work is aimed at, labels say area and resolution.** Labels in use: `collection` (area),
`question`, `duplicate`, `invalid`, `wontfix` (resolution), `good first
issue`, and the ones Dependabot sets. Do not create a label for something a
Type or a milestone already expresses.
- **Do not claim that existing provenance stores still open without opening
one.** There is no schema migration: a change to the models or to the
content engines either keeps old `.noworkflow/` directories readable or
breaks them, and only a real store created by the previous version tells
which. Write what you measured; the release note and the version number
follow from it.
- **Everything reachable from the CLI counts as in use.** Do not condition a
fix on "if anyone uses this command" or "if this flag matters": there is no
dispensable command, and the question only serves to shrink what gets
verified.
- **Before keeping a fact in memory, ask: would another agent, on another
machine, with only `git clone`, get it?** If so, it is a convention and
lives versioned, here or in the skill it belongs to. Local memory does not
cross machines: keep in it only what dies with the machine (a local path,
this machine's sandbox settings, the state of a session).
- **Do not duplicate in memory what is already versioned.** The two copies
diverge at the first change, and the one that misleads is the one nobody
reviews. If the fact is in the repository, the right memory is none.
- **When a command fails because the sandbox blocked it, try again outside
the sandbox.** That triggers the permission question; the human decides.
- **Three failed attempts at the same goal: stop, explain, recommend.** When
the same goal fails three times in a row (a test or a CI leg that stays
red, a command that keeps failing, a change the human rejects), do not
start a fourth variation. Stop and tell the human what was tried, why each
attempt failed, and what to change for the next one: a stronger model
among those available (Sonnet → Opus → Fable), a higher reasoning effort,
or ultracode on (or off, when the fan-out is what keeps failing). When the
cause is not the model (a missing tool, the sandbox, the network, an
ambiguous requirement), say that instead: a stronger model would fail the
same way. Switching is the human's decision.
## Repository skills
In `.claude/skills/`. Claude Code discovers them by itself, and each one
carries its own description; this index is for humans and for other agents,
in the order of the cycle.
- `review-pr` — takes a pull request from start to finish. **Start here**; it
points to the others.
- `merge-downstream` — brings `main` into an issue branch and checks that it
came in whole.
- `dependencies` — declares a dependency in `pyproject.toml`, judges whether
a pin still has a reason, and carries out an update.
- `prove-candidate` — gives a verdict to a suspected defect: confirmed,
refuted or plausible.
- `safe-refactor` — measures a change by the local suite, before and after.
- `golden-run` — runs the same corpus through `now` on two versions and
compares what they print, what they record and what the vis serves.
- `python-matrix` — runs the suite on every supported Python and keeps the
CI matrix in step with python.org.
- `rebuild-vis` — rebuilds the vis bundle from `src/frontend`, proves that
the committed bundle matches its sources, and smoke-tests `now vis`.
- `release` — cuts a version from an integrated `main`: the bump, the wheel,
the tag, PyPI and the GitHub release.
### A skill is a procedure, not a logbook
When writing something learned into a skill, the test is: **would a future
run do something different because of this line?** The mechanism and the
consequence pass ("`now` prints the error and may still exit with 0, so check
the output, not the status"). *When* it was measured, in which version, which
issue found it, and that the problem is already fixed do not pass: `git log`
keeps that, and in a skill it only accumulates.
That context has a place to go: **a comment on the issue**. Measurements
before and after, the version where the defect appeared, the path a run used
to reproduce it, the trap that cost time — all useful, and there it stays
next to the work that produced it, within reach of whoever reopens the
subject.
Two practical consequences:
- **Do not stack rounds.** A new note on the same subject **replaces** the old
one; it does not become a paragraph "already fixed, do not look for it".
A list of known noise is revised in the round that uses it, and an entry
that disappeared leaves in the same commit.
- **An example with a version number rots.** Prefer the generic form; when
the concrete value is necessary, cite the file instead of copying the
value.
A reusable script is legitimate skill content and lives **in the skill's
directory**, never referenced by the path of a dated capture, which is
disposable.
## Dependencies
One dependency per step, to the latest patch of the same series; **never a
major or minor bump as a side effect** of something else. The runtime set is
in `[project.dependencies]`, the tools in `[dependency-groups]` (with
`paper`, what the tests of the paper's running example import), and
`uv.lock` is the truth that CI installs with `uv sync --locked`: a change to
`pyproject.toml` without `uv lock` fails `uv lock --check` before anything
else runs. Known advisories that a pin keeps alive are listed, with the reason
and the issue that removes them, next to the `pip-audit` step in
`.github/workflows/ci.yml`; never add one without both. The frontend has its
own lock in `src/frontend/package-lock.json`, and any change there means
rebuilding the bundle. **A Dependabot PR is never merged as it stands:**
`main` is the baseline, and only what a maintainer tested reaches it. The PR
is its own record and needs no issue. A maintainer turns its branch into the
commit that will land — rebased on `main`, the bundle rebuilt in the same
commit for npm, the lock redone for uv — pushes it to the PR's branch so that
CI tests that exact commit, and with CI green fast-forwards `main` and pushes.
The rest — judging a pin, the versions the lock
resolves per Python version, the audit, the Dependabot PRs, the npm lock and
the pinned actions — is in the `dependencies` skill.
## Blind spots of the suite
Most of the suite imports `noworkflow` and runs the collector in-process,
against in-memory stores; the end-to-end tests (`src/noworkflow/tests/e2e/`)
run `now` as a process on a real store in a temporary directory. What
neither executes:
- **Commands the e2e tests do not run:** `vis` (the server; its routes run
in process, see below), `push`, `pull`, `kernel`, `debug`, `evaluation`
(its query options run through `clean`), and `demo` outside the reference
leg (see below). For the commands they do run, only the flags their tests
name are covered.
- **`now vis`:** the server itself (`now vis` as a process: `cmd_vis.py`,
the port, `--host`, `--dir`); the write routes (experiments, groups,
annotations, remotes, the collaboration import and the files it receives)
and push and pull against them (#256); what the `now` that the
`/commands/*` routes start actually does; the JavaScript. The read routes,
`/db/query` included, run in process through Flask's test client
(`src/noworkflow/tests/vis/`) on stores that `now run` builds in a
temporary directory; the rows only push and pull create are added through
the models or in SQL. The `/commands/*` routes that start `now` run with
`subprocess.run` replaced, so only the argv they build and how they answer
are checked. A PDF out of `flow.pdf` is checked only where Graphviz's
`dot` is on the PATH, which no CI leg installs, and
`prospective.dot` is drawn for a handful of scripts. The `frontend` job
proves that the committed bundle matches the sources, that the extension
type-checks and that the count of `tsc` errors of the vis does not grow.
By hand, `golden-run` (step 4) and the smoke of `rebuild-vis` serve a
corpus store over a real port and fetch the routes `read_store.py` lists.
- **Windows and macOS:** a push runs the matrix on Ubuntu only, for every
Python version. macOS runs every version in `pre-release.yml`, by hand
before a release; Windows runs the floor and the newest as experimental
legs that do not block and, while `experimental.yml` is suspended (its
header says until when), run only by hand too. Path separators and
encodings differ there: a change that touches them runs
`pre-release.yml` (and `experimental.yml`) on its branch before it
reaches `main`.
- **Syntax per Python version:** the collector rewrites the AST, and each
Python version adds nodes. The syntax corpus of the e2e tests
(`corpus/syntax/`, `test_syntax_corpus.py`) traces one script per
construct on every leg, and its marks are the map of what the collector
cannot handle yet; a construct outside it and outside the examples is not
covered, on any version.
- **What the oracle compares:** `test_oracle.py` runs each script of
`corpus/oracle/` with plain Python and with `now run`, under the
harness's synthetic UTF-8 environment and with the flags its cases name;
an encoding the legacy locale chooses, another flag of `now run` or a
construct no script uses is compared nowhere. Its process scripts compare
what the children of a traced script print and write, never what the
store records about them; the ones that need `os.fork`, `preexec_fn` or
`os.posix_spawn` skip on Windows, and spawn and forkserver (#325, #334)
run nowhere.
- **External processes and compiled code:** `test_external_processes.py`
pins what the store records when a traced script starts a process, and
`test_compiled_modules.py` how an extension module of the standard
library and a library loaded through `ctypes` are recorded (`_dbm` on
POSIX; on Windows `winsound`, a choice no leg has measured). #202 and
#203 change these characterizations on purpose. The leak detector sees
the children of the pytest process only, never what a `now` run as a
process leaves behind.
- **The exports off the reference leg:** goldens compare `prolog`, `prov`,
`dataflow` and `prospective` on the reference leg only (Linux, the newest
blocking Python); on every other leg nothing compares their output.
`export ast` fails from Python 3.14 on (#241), so no golden compares it,
and only a structural test checks it on 3.11 to 3.13.
- **The paper's running example off the reference platform:** the
`paper-example` job replays it on Linux with the reference leg's Python
only; the claims that depend on noWorkflow's numbering, and its goldens,
are skipped anywhere else, and no other leg runs it at all.
- **The optional content engines** (`pygit`, `gitdb`, `dulwich`): the
contract tests of `src/noworkflow/tests/persistence/` run `plain` and
`puregit` (which calls the `git` binary) and the in-process suite pins
`plain`; the others open a git repository on connect and run nowhere
(#255), and neither do the process-based wrappers of `parallel.py`.
- **`rules.pl` without SWI-Prolog:** the patterns tests compare each rule
with its `rules.pl` twin over `now export prolog -r`, and the Prolog-fact
tests read exports back, only where `swipl` is on the PATH; they skip
elsewhere. In CI the legs of the `test` job and the `paper-example` job
install it, the experimental Windows legs do not.
- **Jupyter:** the magics run in an in-process IPython shell
(`src/noworkflow/tests/ipython/`), never under a kernel: the kernel in
`src/noworkflow/kernel/` is only imported (#315), so `now clean` never
meets a notebook trial, and the lab extension has no automated test.
`%%now_run --interactive` gets past #313's import only through a stand-in
of the tests. `%%dot` and the diagrams of `%now_schema` skip where
Graphviz's `dot` is missing, `%%now_prolog` without `pyswip`, which no
leg installs, and the tagging helpers that import pandas where the
`paper` group is not synced (it is in the reference container only).
- **`push`, `pull`, `demo`:** a remote server and the demo projects are
exercised by hand or not at all; the exception is `demo 1` (marked `slow`),
which the reference leg runs because it syncs the `demo` extra
(matplotlib), and which skips on every other leg and on `uv sync --locked`
alone.
- **Deployment provenance:** what gets recorded about the environment
(`now show -e`) depends on the machine; the e2e tests assert only the
names of a few attributes and the synthetic user, never the values.
- **What bandit does not see:** it reads the Python of `src/noworkflow`
without the tests, the demo scripts and `py2module.py` (`[tool.bandit]`
in `pyproject.toml`), and it resolves names within one file only, so a
name imported from another module escapes it: the graph cache's
`pickle.loads` (`models/graphs/structures.py`) takes `pickle` from
`utils/cross_version.py`, and the B403 entries of that module are its
only trace. A program named in a variable argv escapes B607 (the bare
`now` of the vis routes), and no check flags a `subprocess` call without
a timeout. The frontend and the workflows are CodeQL's.
When a change touches one of these, run it by hand and write what you ran,
and what came out, in the PR.
## Reproduction traps
- **Inside `now run`, `open` is not `open`.** The collector replaces
`builtins.open`, `io.open`, `codecs.open` and `os.open` with wrappers
(`src/noworkflow/now/collection/prov_execution/execution.py`) and nothing
restores them when the script finishes; a script that compares them with
the originals, or a test that runs the collector and then opens a file, sees
the wrappers. The originals are kept in
`src/noworkflow/now/persistence/content/safeopen.py`.
- **The module table holds only modules imported for the first time.** A
module already in `sys.modules` when the script starts never reaches the
import finder, and `now` loads about a thousand before it (json through
SQLAlchemy, subprocess through `pkg_resources`), more or fewer by how it
starts and by the interpreter's build (#380). A test of the module table
imports a module measured absent at the top of a traced script, such as
`_dbm`.
- **A child of `os.fork()` in a traced script runs `now`'s epilogue** when
it leaves through `sys.exit` or by returning: it stores the trial again,
prints `[now]` lines and exits 0 (#375). A corpus script ends its forked
children with `os._exit`, unless that is its subject, and bounds every
wait on them.
- **The in-process suite never writes a store to disk.** `noworkflow.tests`
connects the persistence layer to in-memory stores on import
(`src/noworkflow/tests/helpers/persistence.py`). A defect in the SQLite
file, in the content directory or in anything that only exists on disk is
reproduced with an e2e test (`NowProject` in
`src/noworkflow/tests/e2e/harness.py` runs `now` in `tmp_path` with a
synthetic environment), or by hand with a real `now run` in a scratch
directory (`cd <scratch> && uv run --project <checkout> now run
script.py`: `now` is not on the PATH, and `uv run` only finds it from
inside the checkout). In-process code that reads a store (the models, the
patterns, the vis views) runs against a real one under the
`connected_two_trials` fixture or `connected_to(path)`, which put the
in-memory stores back afterwards.
- **`now run` writes the store next to the script** (or in `--dir`), not in
the cwd, while the files the script opens by relative path resolve against
the cwd. A store cannot be moved either: trials record absolute paths
(`trial.path`, `module.path`), which a copy keeps pointing at the original
directory. Build each store where it is used.
- **Automatic tags count the whole store:** the major goes up for every new
code hash of any script, and the command line, flags included, decides
the minor. An assertion on a tag needs a fresh store.
- **`python -m pytest` is not `uv run pytest`:** under `-m`,
`sys.modules['__main__']` is pytest's own, and the two tests in
`prov_execution/test_script.py` that assert its repr fail.
- **Every run of the suite has its own order** (pytest-randomly). A red that
a rerun does not repeat may be the order, not the code: the header line
`Using --randomly-seed=N` (absent under `-q`) and `--randomly-seed=N`
replay it, `-p no:randomly` runs in file order. A test red under some
seed depends on another test; the fix is that pair, never a fixed order
for the suite. Under pytest-xdist (`-n 4`, as the CI legs run) the seed
fixes the collection, not which worker runs what after what: replay a
red that the seed does not repeat with `-n 0` and the seed.
- **Trial ids are UUIDs**, not the integers of older versions. A reproduction
that passes `1` as a trial id is talking to `sequence_key`, not to the id.
- **A version-specific bug is proven with that version:** `uv run --isolated
--locked --no-python-downloads --python <version> pytest ...` (without the
flag uv downloads a missing interpreter, which is an install; without
`--isolated` it recreates `.venv` on that version and leaves it there). "It
works here" on another Python proves nothing.
- **`now` prints an error and may still exit with 0.** Several commands
report failure through `print_msg` and return normally; check the output,
not only the status.
- **Two trials of the same script differ by design:** the environment table
records the whole `os.environ`, the start time, the pid. A diff that shows
only those is "no change". `src/noworkflow/tests/e2e/normalize.py` masks
what differs between two identical runs; anything it leaves is a real
difference, with one known exception: on Python 3.11 the order of the
blocks `now diff` prints can change from run to run (#248: it iterates
sets whose hash includes `None`, which is address-based before 3.12).
- **`now demo` depends on `sys.argv[0]` being the console script:** it calls
`now` through the shell with that path (`src/noworkflow/now/cmd/cmd_demo.py`).
Through `python -m noworkflow` or from a test, `sys.argv[0]` is something
else and the demo cannot run itself.
- **On arm64 Macs greenlet never installs,** because SQLAlchemy's marker does
not select it there; a SQLAlchemy or greenlet build failure only shows up on
x86_64, so a green `uv sync` on a maintainer's Mac is not evidence for the
CI legs.
## Patches of third-party internals
Points where noWorkflow depends on the internals of Python or of a library,
and therefore the first suspects when an upgrade breaks something:
- `src/noworkflow/now/collection/prov_execution/execution.py`: replaces the
four `open`s with wrappers, injects `__noworkflow__`, `now_tag_cell` and
`now_tag_variable` into `builtins`, and inserts a finder at the front of
`sys.meta_path` around the `exec` of the script. The finder is removed only
on the normal path; a script that raises leaves it installed.
- `src/noworkflow/now/persistence/content/safeopen.py`: keeps the original
`open`s and restores them in a context manager; also pokes at
`__builtins__["open"]`, which is a dict or a module depending on who imports
it.
- `src/noworkflow/now/collection/prov_deployment/py2module.py`: the Python 2
module finder, written against `imp`. It does not parse as Python 3 and is
excluded from lint, coverage and SAST; the Python 3 branch of
`prov_definition/definition.py` never imports it.
- `src/noworkflow/now/ipython/magics/now_run.py`: swaps `sys.argv`,
`sys.path[0]` and `sys.modules["__main__"]` for the shell's user module
while the cell runs, and swaps them back by hand.
- `pkg_resources` (`utils/functions.py`, for its resource functions): the
reason for the `setuptools<72` pin; its move to `importlib.resources` is
part of #225. Importing it also appends `setuptools/_vendor` to
`sys.path`, which a traced script then sees (#419).
- `importlib.metadata` (`prov_deployment/deployment.py`): `get_version`
looks a module's distribution up from inside the import finder, while the
script runs. Before 3.15 a distribution whose metadata has no version
gives `None` instead of raising, PEP 503 folds the dots of a module name
into `-`, and the lookup asks every finder in `sys.meta_path`, the
script's own included (#417). Whatever reading metadata imports
(`importlib.metadata._adapters`, `email.policy`...) must already be in
`sys.modules` when the script starts, or it gets a module row as the
script's: `load_metadata_modules` reads noWorkflow's own metadata on
import for that, instead of relying on another import of `now` to bring
them (`test_the_version_lookup_records_no_module_of_its_own`).
- SQLAlchemy 1.4: `relational_database.py` uses the 1.4 declarative and
session API, and the pin keeps the 2.0 series out.
- `pyposast` is a sibling project (`JoaoFelipe/pyposast`): the AST positions
that the collector relies on come from it, and a fix there only reaches here
through a release of that package.
- `future` (`future.utils.viewitems`, `viewvalues`) is still imported in
about forty modules, including `patterns/` and the tests.
## The paper's running example
The noWorkflow 2 paper uses a credit card fraud detection pipeline as its
running example. The example lives in three places, each with its own
purpose:
1. **The paper**, which states what noWorkflow records for it: the script,
the trial history, the output of `now show` and `now diff`, the queries
and their results, the graphs. Its source is kept by the authors, outside
this organization.
2. **The reproducibility package**,
[`dew-uff/noworkflow-analysis`](https://github.com/dew-uff/noworkflow-analysis),
which the paper cites by tag: the script, the patches and the
`run_trials.sh` that rebuild the trial history.
3. **The tests of this repository**:
`src/noworkflow/tests/e2e/test_paper_example.py` replays that sequence
on the copy in `src/noworkflow/tests/e2e/corpus/paper_example/` (its
README names the tag) and checks what the paper shows, one test per
figure or listing, in the `paper-example` job of `ci.yml`, so that a
change here cannot contradict the paper without a red build.
A change to the example, or to what the paper shows of it, in any of the
three is carried to the other two. When the test of the example turns red,
the maintainer decides whether the change regressed or the paper and the
package must follow it; the expectation is never edited just to make the
test pass. When the package gets a new tag, the copy in the tests follows in
the same commit.
## Sensitive data
A provenance store records the whole environment of the machine that ran the
script: every variable in `os.environ` (`PATH` included), the user name, the
hostname, the pid, the OS release. `now show -e` prints it, `now export
prolog` emits an `environment/3` fact per attribute, and the vis serves it
too. **Never paste a real `now show -e` output, a real
Prolog export, a real `environment.json` or a real `.noworkflow/` into an
issue, a PR, a skill or a golden file.** Reproduce with the test corpus, and
when an environment attribute matters, name it ("the `USER` attribute")
instead of quoting the value. The e2e harness runs `now` with a synthetic
environment (user `nowtest`, a temporary home), the Prolog goldens drop the
`environment` predicate, and `test_normalize.py` fails if a golden or a
tracked notebook contains an environment fact, a home directory or the
hostname. The same goes for a user's store sent with a
bug report: look at it locally and describe what you found. A failing
test's report gets pasted too: the root `conftest.py` replaces this
machine's hostname with `<HOSTNAME>` in every report pytest prints
(`src/noworkflow/tests/helpers/reports.py` lists what it cannot reach),
and a test still never puts a store's environment or user id in an
assertion message or a test id.
## Language
Everything versioned is in English: code, comments, docstrings, docs, skills,
issues, PRs, commit messages. Discussion with the maintainer may happen in
Portuguese; what gets written to the repository is translated.
## Python versions
The supported range is `requires-python` in `pyproject.toml`; the versions
that are actually run are the legs of `.github/workflows/ci.yml` (blocking)
and `.github/workflows/experimental.yml` (observed, not blocking; run by
hand while suspended). A version
is supported when it has a blocking leg, and a classifier in
`pyproject.toml` is added only then. Locally, `uv run --isolated --locked
--no-python-downloads --python <version> pytest` runs the suite on any of
them without touching `.venv`.
The rule that decides the range and the matrix (the record of the decision,
with the measurements behind it, is issue #237):
- **Range:** the versions python.org maintains on the day of the release
(bugfix or security, see <https://devguide.python.org/versions/>). A
version that reaches end of life leaves in the next minor release, never
in a patch release. `requires-python`, the classifiers and the CI matrix
change in the same commit.
- **Matrix:** on every push, Linux runs every supported minor, blocking,
with four pytest-xdist workers per leg. Before a release, `pre-release.yml`
runs Linux and macOS on every supported minor, by hand; Windows runs the
floor and the newest in `experimental.yml` until the model doctests stop
assuming POSIX paths (#374), and then joins `pre-release.yml`. The next
Python runs on Linux as an experimental leg from its first beta (May) and
is promoted when the suite passes on it, with the October release as the
target. The record of the move of macOS out of the push, with the
measurements behind it, is issue #426.
- **Reference leg** (coverage, goldens, `flaky.yml`, and the Python of the
`checks` and `paper-example` jobs): Linux on the newest blocking version.
It moves when a new version is promoted, and the goldens are regenerated
in the same step:
`src/noworkflow/tests/e2e/goldens/REFERENCE` names the platform they were
recorded on, and `.github/reference-leg/run.sh` reads the Python from
`ci.yml`. The
diff of that regeneration is what the new Python changes in noWorkflow's
output; a line it does not explain is a defect.
- **Runner images:** the `-latest` labels, which GitHub moves to the newest
generally available image.
Why macOS only before a release: defects specific to a system (paths,
encodings, the file system) rarely depend on the Python version, and in 99
runs of `ci.yml` with macOS on every push no Python version failed alone and
macOS failed alone once, while its legs, on a 3-core runner where workers
barely help, set the pace of every push (#426). Why Windows only at the
ends while it is experimental: the floor catches what became obsolete, the
newest what is new. On Windows the two ends
also read files differently: from 3.15 UTF-8 mode is on by default (PEP 686),
so an `open()` without `encoding=` reads the locale encoding (usually cp1252)
on older versions and UTF-8 on newer ones.
Why every minor on Linux: the ends cover the middle only while no code
branches on a middle version and no dependency resolves differently there
(the forks in `resolution-markers` of `uv.lock`), and nothing would say when
that stops being true. Linux legs cost nothing on a public repository.