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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions docs/examples/_migrations_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@
from contextlib import contextmanager
from pathlib import Path

# The docs harness's region reader, so a region reads the same here as on a page.
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from tests._docs_shared import snippet_regions # noqa: E402

__all__ = ["Project", "project"]

FERRO_TOML = 'models = ["blog.models"]\ndialects = ["postgres", "sqlite"]\n'
Expand All @@ -42,10 +46,10 @@


def _region(script: Path, name: str = "models") -> str:
source = script.read_text()
opening, closing = f"# --8<-- [start:{name}]", f"# --8<-- [end:{name}]"
start = source.index(opening) + len(opening)
return source[start : source.index(closing)].strip("\n") + "\n"
regions = snippet_regions(script.read_text(), where=str(script))
if name not in regions:
raise ValueError(f"{script} has no snippet region {name!r}")
return regions[name].body.strip("\n") + "\n"


def _before(region: str) -> str:
Expand Down
19 changes: 6 additions & 13 deletions docs/examples/_preludes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
points at the docs page or docstring, not at this runner. With
``FERRO_DOCS_DATABASE_URL`` set (a ``backend=postgres`` fence), every
``connect()`` the prelude or the block makes goes to that database instead of
the URL it names.
the URL it names, keeping the PostgreSQL query options that URL carries
(``tests._docs_shared.point_connect_at``).
"""

from __future__ import annotations
Expand All @@ -30,6 +31,10 @@

PRELUDES = Path(__file__).resolve().parent

# The docs harness's connect redirect, the one the SQL capture installs too.
sys.path.insert(0, str(PRELUDES.parents[2]))
from tests._docs_shared import point_connect_at # noqa: E402


def load_prelude(name: str) -> ModuleType:
"""Import ``<name>.py`` from this directory, refusing a name that has none."""
Expand Down Expand Up @@ -84,18 +89,6 @@ async def run_block(prelude: str, source: str, path: str, first_line: int) -> No
await result


def point_connect_at(url: str) -> None:
"""Send every ``ferro.connect`` call to ``url``, whatever URL it names."""
import ferro

connect = ferro.connect

async def connect_to(_url: str, *args: object, **kwargs: object) -> None:
await connect(url, *args, **kwargs)

ferro.connect = connect_to


def main(argv: list[str]) -> None:
prelude, path, first_line = argv
url = os.environ.get("FERRO_DOCS_DATABASE_URL")
Expand Down
43 changes: 43 additions & 0 deletions docs/examples/_preludes/invoices.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"""Invoices of two tenants, for the docstrings of session settings.

A block marked ``{test=run prelude=invoices backend=postgres}`` runs on
PostgreSQL, where a session can send settings (``set_config``). ``opened()``
connects, adds three invoices and does not open a session: the block opens
its own, with the settings it is about. ``resolve_tenant(request)`` stands for
the application's own lookup and returns ``"acme"``.
"""

from collections.abc import AsyncIterator
from contextlib import asynccontextmanager

import ferro
from ferro import Field, Model, connect, engines


class Invoice(Model):
id: int | None = Field(default=None, primary_key=True)
tenant: str
total: float


request = object()
"""The incoming request a web handler would hold."""


async def resolve_tenant(request: object) -> str:
"""The tenant the request belongs to."""
return "acme"


@asynccontextmanager
async def opened() -> AsyncIterator[None]:
"""Connect and add the invoices; the block opens its own session."""
await connect("postgresql://localhost/app", auto_migrate=True)
try:
async with engines.session():
await Invoice.create(tenant="acme", total=250.0)
await Invoice.create(tenant="acme", total=40.0)
await Invoice.create(tenant="globex", total=900.0)
yield
finally:
ferro.reset_engine()
34 changes: 34 additions & 0 deletions docs/examples/_preludes/jwt_claims.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"""The Row-level security guide's JSON claims: a Postgres connection named
``"app"`` and the ``auth_uid()`` function the guide creates.

A block marked ``{test=run prelude=jwt_claims backend=postgres}`` runs on
PostgreSQL. ``opened()`` connects under the name ``"app"`` and creates
``auth_uid()`` with the guide's own statement; the block opens its own
session, with the claims it sends.
"""

from collections.abc import AsyncIterator
from contextlib import asynccontextmanager

import ferro
from ferro import engines, execute

AUTH_UID = """\
CREATE OR REPLACE FUNCTION auth_uid() RETURNS uuid
LANGUAGE sql STABLE
AS $$
SELECT (NULLIF(current_setting('request.jwt.claims', true), '')::json ->> 'sub')::uuid
$$"""
"""The function the guide creates in a migration, as the guide shows it."""


@asynccontextmanager
async def opened() -> AsyncIterator[None]:
"""Connect as ``"app"`` and create ``auth_uid()``."""
await ferro.connect("postgresql://localhost/app", name="app")
async with engines.session("app"):
await execute(AUTH_UID)
try:
yield
finally:
ferro.reset_engine()
44 changes: 44 additions & 0 deletions docs/examples/_preludes/label_addition.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""The Auto-migrate page's label addition: ``Plan`` gained ``PRO``, and the
database is from before.

The models are ``docs/examples/enum_labels.py``'s. A block marked
``{test=run prelude=label_addition backend=postgres}`` runs on PostgreSQL,
where ``opened()`` first builds what an earlier release built: the native
type ``plan`` with only ``'free'``, and ``author`` with a ``plan`` column of
it, as that release's create pass left them. It does not leave a
connection open: the block connects itself, with the flag it is about.
"""

import sys
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parents[1]))

import ferro # noqa: E402
from enum_labels import Author, Plan # noqa: E402, F401 - names the blocks use
from ferro import engines, execute, fetch_all # noqa: E402, F401

BEFORE = [
"""CREATE TYPE "plan" AS ENUM ('free')""",
"""CREATE TABLE "author" ( "email" varchar NOT NULL, "id" serial PRIMARY KEY"""
""" NOT NULL, "name" varchar NOT NULL, "nickname" varchar, "plan" plan NOT NULL )""",
"""CREATE INDEX "idx_author_nickname" ON "author" ("nickname")""",
"""CREATE UNIQUE INDEX "uq_author_email" ON "author" ("email")""",
]
"""What the create pass sends for ``Author`` while ``Plan`` has only ``FREE``."""


@asynccontextmanager
async def opened() -> AsyncIterator[None]:
"""Build the earlier release's schema, then run the block."""
await ferro.connect("postgresql://localhost/app")
async with engines.session():
for statement in BEFORE:
await execute(statement)
ferro.reset_engine()
try:
yield
finally:
ferro.reset_engine()
2 changes: 1 addition & 1 deletion docs/examples/auto_migrate_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ async def required_column(url: str) -> None:
try:
await connect(url, migrate_updates=True)
except ValueError as error:
print(error)
print(f"{type(error).__name__}: {error}")
# --8<-- [end:required-column]


Expand Down
46 changes: 46 additions & 0 deletions docs/examples/enum_labels.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# ruff: noqa: E402 - the models come first, as the docs page shows them
"""Runnable companion for label addition (docs/pages/guide/schema/auto-migrate.md).

``Plan`` after it gained ``PRO``. The page's connect block runs these models
on PostgreSQL against a database whose ``plan`` type has only ``'free'``
(``docs/examples/_preludes/enum_labels.py``). On its own the script saves an
author on SQLite, where an enum is stored as text and has no type to grow.
"""

# --8<-- [start:models]
from enum import StrEnum

from ferro import Field, Model


class Plan(StrEnum):
FREE = "free"
PRO = "pro" # new: the live type has only 'free'


class Author(Model):
id: int | None = Field(default=None, primary_key=True)
name: str
email: str = Field(unique=True)
nickname: str | None = Field(default=None, index=True)
plan: Plan = Plan.FREE


# --8<-- [end:models]

import asyncio

from ferro import connect, engines


async def main() -> None:
await connect("sqlite::memory:", auto_migrate=True)
async with engines.session():
author = await Author.create(
name="Ada Lovelace", email="ada@example.com", plan=Plan.PRO
)
assert (await Author.get(author.id)).plan is Plan.PRO


if __name__ == "__main__":
asyncio.run(main())
47 changes: 47 additions & 0 deletions docs/examples/enum_labels_annotated.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# ruff: noqa: E402 - the models come first, as the docs page shows them
"""Runnable companion for label addition (docs/pages/guide/schema/auto-migrate.md).

``Plan`` after it gained ``PRO``. The page's connect block runs these models
on PostgreSQL against a database whose ``plan`` type has only ``'free'``
(``docs/examples/_preludes/enum_labels.py``). On its own the script saves an
author on SQLite, where an enum is stored as text and has no type to grow.
"""

# --8<-- [start:models]
from enum import StrEnum
from typing import Annotated

from ferro import Field, Model


class Plan(StrEnum):
FREE = "free"
PRO = "pro" # new: the live type has only 'free'


class Author(Model):
id: Annotated[int | None, Field(primary_key=True)] = None
name: str
email: Annotated[str, Field(unique=True)]
nickname: Annotated[str | None, Field(index=True)] = None
plan: Plan = Plan.FREE


# --8<-- [end:models]

import asyncio

from ferro import connect, engines


async def main() -> None:
await connect("sqlite::memory:", auto_migrate=True)
async with engines.session():
author = await Author.create(
name="Ada Lovelace", email="ada@example.com", plan=Plan.PRO
)
assert (await Author.get(author.id)).plan is Plan.PRO


if __name__ == "__main__":
asyncio.run(main())
Loading
Loading