Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
e8c49cc
chore(docs): AGENTS.md I-8 for the lambda-only API, I-14 docstrings, …
0x054 Oct 19, 2026
20d9f97
fix(cli): keep <placeholders> in --help (#702) (#726)
0x054 Oct 19, 2026
a6fa522
feat(docs): docs examples harness — prelude fences, docstring example…
0x054 Oct 19, 2026
f1bf2a4
feat(docs): site configuration fixes and a strict docs build (#701) (…
0x054 Oct 19, 2026
4e49af3
docs(get-started): accurate home, README, Why Ferro, installation and…
0x054 Oct 19, 2026
606e579
docs: project pages, FAQ, roadmap, contributing and changelog (#706) …
0x054 Oct 19, 2026
853aaac
docs(guide): Queries guide and typed predicates on one ledger (#709) …
0x054 Oct 19, 2026
f8aa4b2
docs: schema pages fixed against the generator, quoted step files pin…
0x054 Oct 19, 2026
b8dbf12
docs(api): core API docstrings as reference entries with runnable exa…
0x054 Oct 19, 2026
5001767
docs(guide): Models and fields — one running example, generated field…
0x054 Oct 19, 2026
3482c64
docs(guide): sessions-first connections, mutations, transactions and …
0x054 Oct 19, 2026
8fe7610
fix(docs): repoint two models-and-fields links renamed by the core gu…
0x054 Oct 19, 2026
3fc13f7
docs: split the RLS guide into a how-to, an explanation and a referen…
0x054 Oct 19, 2026
a10707f
fix(docs): repoint two connections links to the RLS concepts page
0x054 Oct 19, 2026
a7cbca6
docs(guide): Relationships and Raw SQL on real table names, one runni…
0x054 Oct 19, 2026
acd1127
docs(guide): rewrite aggregations guide and examples (#710) (#740)
0x054 Oct 19, 2026
6c7126f
docs(guide): upgrade guide covers 0.16 to 0.25 (#707) (#741)
0x054 Oct 19, 2026
6f8e7ca
docs(guide): rewrite the SQLAlchemy migration how-to (#717) (#743)
0x054 Oct 19, 2026
b0e5348
docs(api): migrations, pass report and CLI docstrings as Google-style…
0x054 Oct 19, 2026
b340fee
docs(howto): testing, pagination, timestamps and soft-delete how-tos …
0x054 Oct 19, 2026
7bf0e39
docs(concepts): concept pages checked against the code, one running e…
0x054 Oct 19, 2026
94b04ae
docs: split the migrations guide into the loop, renames and retiremen…
0x054 Oct 19, 2026
5c2bdd5
docs(api): connection, session, field, raw SQL, row security and sett…
0x054 Oct 19, 2026
09714e8
docs(api): every export has one API entry under its public path (#722…
0x054 Oct 19, 2026
de9eaff
docs: six-tab nav, page descriptions and redirects for moved pages (#…
0x054 Oct 19, 2026
c1fcefb
test(docs): enforce the docs guards as gates (#724) (#750)
0x054 Oct 19, 2026
cfc060e
Merge origin/main into feat/docs-rewrite (GLOSSARY.md rename, ADR-0057)
0x054 Oct 21, 2026
d070e31
Merge origin/main into feat/docs-rewrite (SecretStr fix, statement lo…
0x054 Oct 21, 2026
7c19ce7
docs: Terminal under its public path, one-line record rows, and a sta…
0x054 Oct 21, 2026
00dd767
docs: show the SQL each query and mutation sends, regenerated by its …
0x054 Oct 21, 2026
2a0205b
style: blank line after the TYPE_CHECKING else in the query builder (…
0x054 Oct 21, 2026
c80e3eb
fix: panel round 1 on #777 (src) — restore status flags and PoolConfi…
0x054 Oct 21, 2026
8ae9aee
docs: panel round 1 on #777 (harness and pages) — output blocks regen…
0x054 Oct 21, 2026
f20bcca
docs: panel round 2 on #777 — green suite, honest Postgres fences, on…
0x054 Oct 21, 2026
0b72ba5
docs: 0.26 notes state the secret-column change; RunRefused exported …
0x054 Oct 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 52 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,54 @@ jobs:
run: |
uv run --no-sync --python 3.13 prek run --all-files

docs:
name: Docs build and docs tests
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.13'

- name: Install UV
uses: astral-sh/setup-uv@v5
with:
enable-cache: true

- name: Set up Rust
uses: dtolnay/rust-toolchain@stable

- name: Cache Rust build
uses: Swatinem/rust-cache@v2
with:
prefix-key: v1
cache-on-failure: true

- name: Install documentation and test dependencies
run: |
uv sync --only-group ci-test --only-group docs --no-install-project --python 3.13

- name: Build Rust extension
run: |
uv run maturin develop

- name: Build Zensical site
run: |
uv run --no-sync zensical build --clean --strict

- name: Run the docs lint
run: |
uv run --no-sync python -m tests._docs_checks docs/pages docs/examples src/ferro

- name: Run the docs tests
# Every docs test module is named test_doc*.py, which
# tests/test_docs_site.py pins, so a new one runs here unlisted.
run: |
uv run --no-sync pytest --no-cov -p no:cacheprovider tests/test_doc*.py

test-rust:
name: Rust tests
runs-on: ubuntu-latest
Expand Down Expand Up @@ -119,7 +167,7 @@ jobs:

- name: Install dependencies
run: |
uv sync --only-group ci-test --no-install-project --python 3.13
uv sync --only-group ci-test --only-group docs --no-install-project --python 3.13

- name: Build Rust extension
run: |
Expand Down Expand Up @@ -170,7 +218,7 @@ jobs:

- name: Install dependencies
run: |
uv sync --only-group ci-test --no-install-project --python ${{ matrix.python-version }}
uv sync --only-group ci-test --only-group docs --no-install-project --python ${{ matrix.python-version }}

- name: Build Rust extension
run: |
Expand Down Expand Up @@ -296,7 +344,7 @@ jobs:

all-checks:
name: All Checks Passed
needs: [lint-and-format, test-python-pr, test-python-main, test-python-backend-matrix, test-rust]
needs: [lint-and-format, docs, test-python-pr, test-python-main, test-python-backend-matrix, test-rust]
runs-on: ubuntu-latest
if: always()
steps:
Expand All @@ -310,5 +358,6 @@ jobs:
if ! ok "${{ needs.test-python-main.result }}"; then exit 1; fi
if ! ok "${{ needs.test-python-backend-matrix.result }}"; then exit 1; fi
if ! ok "${{ needs.test-rust.result }}"; then exit 1; fi
if ! ok "${{ needs.docs.result }}"; then exit 1; fi

echo "All checks passed!"
2 changes: 1 addition & 1 deletion .github/workflows/publish-docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ jobs:

- name: Build Zensical site
run: |
uv run --no-sync zensical build --clean
uv run --no-sync zensical build --clean --strict

- name: Upload Pages artifact
uses: actions/upload-pages-artifact@v3
Expand Down
127 changes: 101 additions & 26 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -422,38 +422,47 @@ preferred declaration style.

---

## I-8: Lambda predicates are the official query style
## I-8: Queries are lambda predicates named after the model

Documentation and examples use the lambda predicate style for all queries:
A query filters with a lambda whose parameter is named after the model, in
lowercase singular:

```python
adults = await User.where(lambda user: user.age >= 18).all()
recent = await Post.order_by(lambda post: post.published_at, "desc").all()
```

Lambdas are the only predicate form. `where()` raises `TypeError` on anything
that is not a callable; the `col()` and operator-style predicates were removed
in 0.14.

Rules:

- **Every query example** in docs, docstring `Examples:` sections, and
`docs/examples/` scripts uses lambda predicates.
- Name the lambda parameter after the model in **lowercase singular**
(`user` for `User`, `post` for `Post`) so filters read naturally.
- When the predicate styles themselves are documented, present them in
order **lambda > `col()` > operator**, with lambda labeled the officially
recommended style.
- **Operator style** (`User.where(User.age >= 18)`) is compatible today but
is slated for deprecation in a future release and fails static type
checking (`User.age >= 18` types as `bool`; `where()` expects
`QueryNode | Predicate`). Docs say so explicitly wherever the style is
shown.
- **`order_by` is not a predicate**, but its lambda selector
(`order_by(lambda u: u.age, "desc")`) is validated and is the documented
style — and it is the ONLY way to order by a related column
(`order_by(lambda t: t.account.label)`), which relation traversal requires.
Attribute style is not accepted — `order_by` takes a column-name string or
a lambda selector (anything else raises `TypeError`).

The canonical comparisons live in `docs/pages/guide/queries.md`
("Predicate Styles") and `docs/pages/concepts/query-typing.md`; everywhere
else uses lambda without restating the trade-offs.
- **Every query example** in docs pages, docstring `Examples:` sections and
`docs/examples/` scripts passes lambdas to query methods (`where`,
`select`, `order_by`, `include`, `exists`, `count`, `aggregate`,
`group_by`, `after`, `before` and every other method that takes a
callable).
- **Name the parameter after the model in lowercase singular**: `user` for
`User`, `post` for `Post`, `line_item` for `LineItem`, so the predicate
reads as the sentence it means. A traversal keeps the root model's name
(`lambda transaction: transaction.account.label`).
- **`order_by`** takes a column-name string (`order_by("created_at",
"desc")`) or a lambda selector (`order_by(lambda post: post.created_at,
"desc")`); anything else raises `TypeError`. The lambda is the documented
form, and the only one that orders by a related column
(`order_by(lambda transaction: transaction.account.label)`).
- **The lint** `uv run --no-sync python -m tests._docs_checks PATH...` lists
every lambda passed to a query method whose parameter is one character,
as `path:line: message`, and exits 1. It reads Python files, Python fences
in Markdown and docstrings. `tests/test_docs_lint.py` runs it over
`docs/pages`, `docs/examples` and `src/ferro`, so a violation fails the
suite; run it yourself over every docs page, example and docstring you
touch.

`docs/pages/guide/queries.md` and `docs/pages/concepts/query-typing.md`
explain how lambda predicates are evaluated and typed; every other page uses
them without restating that.

---

Expand Down Expand Up @@ -528,8 +537,8 @@ key:
pinned_at = datetime(2026, 3, 1, 15, 0, tzinfo=UTC)
await Message(id=2, pinned_at=pinned_at, ...).save()
page = await (
Message.order_by(lambda m: m.pinned_at)
.order_by(lambda m: m.id)
Message.order_by(lambda message: message.pinned_at)
.order_by(lambda message: message.id)
.after((pinned_at, 2))
.all()
)
Expand All @@ -556,6 +565,72 @@ Architecture review: I-22 in #428. See PRD #430 and

---

## I-14: Docstrings are short Google-style reference entries

The API reference is rendered from docstrings by mkdocstrings and griffe, so
a docstring is a reference entry, written once in one form:

```python
async def get(cls, pk: Any, *, session: Session | None = None) -> Self:
"""Fetch one record by primary key.

Args:
pk: The primary key value to look up.
session: The session to run in. Defaults to the one opened by the
enclosing `async with engines.session():`.

Returns:
The record whose primary key is `pk`.

Raises:
ModelDoesNotExist: No row has that key. Use
[`get_or_none`][ferro.Model.get_or_none] to get `None` instead.

Examples:
```python {test=run prelude=reference}
author = await Author.get(1)
print(author)
```
"""
```

Rules:

- **Google style as griffe parses it**: `Args:`, `Returns:`, `Yields:`,
`Raises:`, `Attributes:`, `Examples:`, each a section griffe recognises,
with entries indented under it. A section griffe drops or splits into
table rows is a bug in the docstring.
- **The summary is one line ending with a period.** A function or method
summary is an infinitive ("Fetch one record by primary key."); a class
summary is a noun phrase ("A lazy query over one model's rows."). The
body starts after a blank line.
- **Every parameter is in `Args:`**, keyword-only ones included
(`session`, `using`, `nulls`), with the default stated when it is not
obvious from the signature.
- **Every public dataclass or Pydantic field** has a one-line docstring: a
string literal on the line below the field.
- **Cross-references are Markdown autorefs**:
`` [`Model.where`][ferro.Model.where] `` or `[Model][ferro.Model]`.
Sphinx roles (`:class:`, `:func:`, `:meth:`, `:data:`) render as literal
text.
- **Every reference is one the reader can follow.** Docstrings carry no ADR
numbers, issue numbers, internal codenames or Rust internals (crate,
function and FFI names); a link to a docs page carries the reasoning.
- **Examples use lambda predicates (I-8) against the shared reference
prelude** (`docs/examples/_preludes/reference.py`: `Author` and `Post`,
connected, inside a session), in a fence marked
`{test=run prelude=reference}` so the docs harness
(`tests/test_docstring_examples.py`) runs it.
- **An explanation longer than one paragraph lives in a guide or concepts
page**, and the docstring links to it.

`docs/solutions/patterns/docstring-conventions.md` shows each rule as a
before/after pair. Page-level conventions (running examples, snippet
markers, the session wrapper, the Annotated idiom for I-7 twins) are in
`docs/solutions/patterns/docs-page-conventions.md`.

---

## Agent skills

### Issue tracker
Expand Down
Loading
Loading