alchemic 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of alchemic might be problematic. Click here for more details.

Files changed (52) hide show
  1. alchemic-0.1.0/.github/actionlint.yaml +3 -0
  2. alchemic-0.1.0/.github/workflows/ci.yml +151 -0
  3. alchemic-0.1.0/.github/workflows/release.yml +93 -0
  4. alchemic-0.1.0/.gitignore +14 -0
  5. alchemic-0.1.0/CHANGELOG.md +5 -0
  6. alchemic-0.1.0/LICENSE +21 -0
  7. alchemic-0.1.0/PKG-INFO +382 -0
  8. alchemic-0.1.0/README.md +349 -0
  9. alchemic-0.1.0/SPEC.md +638 -0
  10. alchemic-0.1.0/docker-compose.yaml +29 -0
  11. alchemic-0.1.0/pyproject.toml +115 -0
  12. alchemic-0.1.0/src/alchemic/__init__.py +61 -0
  13. alchemic-0.1.0/src/alchemic/_constants.py +31 -0
  14. alchemic-0.1.0/src/alchemic/_fields.py +325 -0
  15. alchemic-0.1.0/src/alchemic/_mapped_schema.py +20 -0
  16. alchemic-0.1.0/src/alchemic/_metadata.py +45 -0
  17. alchemic-0.1.0/src/alchemic/_receivers.py +292 -0
  18. alchemic-0.1.0/src/alchemic/_relationships.py +307 -0
  19. alchemic-0.1.0/src/alchemic/_sqlalchemy_private.py +79 -0
  20. alchemic-0.1.0/src/alchemic/_typing.py +440 -0
  21. alchemic-0.1.0/src/alchemic/extensions.py +113 -0
  22. alchemic-0.1.0/src/alchemic/model.py +723 -0
  23. alchemic-0.1.0/src/alchemic/py.typed +0 -0
  24. alchemic-0.1.0/src/alchemic/session.py +235 -0
  25. alchemic-0.1.0/tests/conftest.py +139 -0
  26. alchemic-0.1.0/tests/test_async.py +176 -0
  27. alchemic-0.1.0/tests/test_column_limits.py +63 -0
  28. alchemic-0.1.0/tests/test_copy.py +39 -0
  29. alchemic-0.1.0/tests/test_core.py +180 -0
  30. alchemic-0.1.0/tests/test_declared_defaults.py +61 -0
  31. alchemic-0.1.0/tests/test_dirty_tracking.py +70 -0
  32. alchemic-0.1.0/tests/test_extensions.py +103 -0
  33. alchemic-0.1.0/tests/test_fields.py +212 -0
  34. alchemic-0.1.0/tests/test_identity.py +69 -0
  35. alchemic-0.1.0/tests/test_inheritance.py +160 -0
  36. alchemic-0.1.0/tests/test_init_false.py +63 -0
  37. alchemic-0.1.0/tests/test_loaded_instances.py +127 -0
  38. alchemic-0.1.0/tests/test_metadata_helpers.py +55 -0
  39. alchemic-0.1.0/tests/test_model_internals.py +140 -0
  40. alchemic-0.1.0/tests/test_multi_registry.py +136 -0
  41. alchemic-0.1.0/tests/test_postgres.py +210 -0
  42. alchemic-0.1.0/tests/test_postgres_features.py +140 -0
  43. alchemic-0.1.0/tests/test_private_api.py +129 -0
  44. alchemic-0.1.0/tests/test_receivers.py +342 -0
  45. alchemic-0.1.0/tests/test_references.py +73 -0
  46. alchemic-0.1.0/tests/test_related.py +232 -0
  47. alchemic-0.1.0/tests/test_relationship_optionality.py +102 -0
  48. alchemic-0.1.0/tests/test_session.py +208 -0
  49. alchemic-0.1.0/tests/test_typing.py +641 -0
  50. alchemic-0.1.0/tests/test_update.py +307 -0
  51. alchemic-0.1.0/tests/test_where.py +42 -0
  52. alchemic-0.1.0/tests/test_write_rules.py +619 -0
@@ -0,0 +1,3 @@
1
+ self-hosted-runner:
2
+ labels:
3
+ - steppestone-runners
@@ -0,0 +1,151 @@
1
+ name: CI
2
+
3
+ # Every job targets `steppestone-runners`, the org-scoped ARC runner set in the
4
+ # cluster. Do not use `ubuntu-latest`: GitHub-hosted minutes are unavailable on
5
+ # this account.
6
+ on:
7
+ push:
8
+ branches: [main]
9
+ pull_request:
10
+ workflow_call:
11
+
12
+ permissions:
13
+ contents: read
14
+
15
+ concurrency:
16
+ group: ci-${{ github.ref }}
17
+ cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}
18
+
19
+ # Uses the cluster's ci-postgres Deployment; runner pods do not run a Docker daemon
20
+ # for service containers.
21
+ #
22
+ # The Service is ClusterIP-only, so this hostname resolves from a runner pod and
23
+ # from nowhere else. It runs POSTGRES_HOST_AUTH_METHOD=trust, hence no password.
24
+ #
25
+ # Shared Postgres instance. tests/conftest.py creates an isolated
26
+ # `alchemic_ci_<hex>` database per process and drops it at exit.
27
+ env:
28
+ ALCHEMIC_POSTGRES_ADMIN_URL: postgresql+psycopg://postgres@ci-postgres.arc-runners.svc.cluster.local:5432/postgres
29
+
30
+ jobs:
31
+ test:
32
+ runs-on: steppestone-runners
33
+ timeout-minutes: 30
34
+ strategy:
35
+ fail-fast: false
36
+ matrix:
37
+ python-version: ["3.13", "3.14"]
38
+ steps:
39
+ - uses: actions/checkout@v4
40
+
41
+ # Check Postgres reachability via /dev/tcp before installing dependencies;
42
+ # the runner image includes neither nc nor psql.
43
+ - name: Verify ci-postgres is reachable
44
+ run: |
45
+ set -euo pipefail
46
+ host=ci-postgres.arc-runners.svc.cluster.local
47
+ port=5432
48
+ for attempt in 1 2 3 4 5; do
49
+ if timeout 5 bash -c "exec 3<>/dev/tcp/${host}/${port}" 2>/dev/null; then
50
+ echo "${host}:${port} is accepting connections"
51
+ exit 0
52
+ fi
53
+ echo "attempt ${attempt}/5: ${host}:${port} not reachable yet"
54
+ sleep 5
55
+ done
56
+ echo "::error::${host}:${port} is unreachable. The Postgres tests need it." >&2
57
+ echo "::error::Check it with: kubectl -n arc-runners get deploy,svc,networkpolicy ci-postgres" >&2
58
+ echo "::error::A hang here rather than a refusal points at a NetworkPolicy; a refusal points at the Deployment." >&2
59
+ exit 1
60
+
61
+ - uses: astral-sh/setup-uv@v5
62
+ with:
63
+ enable-cache: true
64
+
65
+ # tests/test_typing.py shells out to pyright and mypy under *this*
66
+ # interpreter, so pyright has to work here too, not just in the lint job.
67
+ - name: Install libatomic1 for pyright's bundled Node
68
+ run: |
69
+ set -euo pipefail
70
+ if command -v sudo >/dev/null 2>&1; then
71
+ sudo apt-get update && sudo apt-get install -y --no-install-recommends libatomic1
72
+ else
73
+ apt-get update && apt-get install -y --no-install-recommends libatomic1
74
+ fi
75
+
76
+ # Require type-checker tests and Postgres tests to run rather than skip.
77
+ - name: Run tests with coverage gate
78
+ env:
79
+ ALCHEMIC_REQUIRE_TYPECHECK: "1"
80
+ ALCHEMIC_REQUIRE_POSTGRES: "1"
81
+ run: uv run --python ${{ matrix.python-version }} --extra dev pytest --cov=alchemic --cov-report=term-missing --cov-fail-under=90
82
+
83
+ lint:
84
+ runs-on: steppestone-runners
85
+ timeout-minutes: 15
86
+ steps:
87
+ - uses: actions/checkout@v4
88
+ - uses: astral-sh/setup-uv@v5
89
+ with:
90
+ enable-cache: true
91
+
92
+ # Install libatomic1 required by pyright's bundled Node binary.
93
+ - name: Install libatomic1 for pyright's bundled Node
94
+ run: |
95
+ set -euo pipefail
96
+ if command -v sudo >/dev/null 2>&1; then
97
+ sudo apt-get update && sudo apt-get install -y --no-install-recommends libatomic1
98
+ else
99
+ apt-get update && apt-get install -y --no-install-recommends libatomic1
100
+ fi
101
+
102
+ - run: uv run --extra dev ruff check .
103
+ - run: uv run --extra dev black --check src tests
104
+ - run: uv run --extra dev pylint src/alchemic
105
+ - run: uv run --extra dev pyright
106
+ - run: uv run --extra dev pyright --verifytypes alchemic --ignoreexternal
107
+ - run: uv run --extra dev ty check
108
+
109
+ # The lowest SQLAlchemy and Pydantic that pyproject.toml allows. Installing the
110
+ # package alone with lowest-direct pins those two; the dev tools are then added
111
+ # at their latest without moving them.
112
+ minimum-versions:
113
+ runs-on: steppestone-runners
114
+ timeout-minutes: 30
115
+ steps:
116
+ - uses: actions/checkout@v4
117
+ - uses: astral-sh/setup-uv@v5
118
+ with:
119
+ enable-cache: true
120
+ - name: Install libatomic1 for pyright's bundled Node
121
+ run: |
122
+ set -euo pipefail
123
+ if command -v sudo >/dev/null 2>&1; then
124
+ sudo apt-get update && sudo apt-get install -y --no-install-recommends libatomic1
125
+ else
126
+ apt-get update && apt-get install -y --no-install-recommends libatomic1
127
+ fi
128
+ - name: Install the minimum versions
129
+ run: |
130
+ set -euo pipefail
131
+ uv venv --python 3.13
132
+ uv pip install --resolution lowest-direct -e .
133
+ uv pip install -e ".[dev]"
134
+ .venv/bin/python -c "import sqlalchemy, pydantic; print('SQLAlchemy', sqlalchemy.__version__, 'Pydantic', pydantic.VERSION)"
135
+ - name: Run tests
136
+ env:
137
+ ALCHEMIC_REQUIRE_TYPECHECK: "1"
138
+ ALCHEMIC_REQUIRE_POSTGRES: "1"
139
+ run: .venv/bin/python -m pytest
140
+
141
+ # Type-check package source with mypy in strict mode. Consumer code type safety
142
+ # is covered by tests/test_typing.py.
143
+ mypy:
144
+ runs-on: steppestone-runners
145
+ timeout-minutes: 15
146
+ steps:
147
+ - uses: actions/checkout@v4
148
+ - uses: astral-sh/setup-uv@v5
149
+ with:
150
+ enable-cache: true
151
+ - run: uv run --extra dev mypy --strict src
@@ -0,0 +1,93 @@
1
+ name: Release
2
+
3
+ # Pushing a tag `vX.Y.Z` releases version X.Y.Z: the full CI suite runs, then the
4
+ # package is built, published to PyPI through trusted publishing, and a GitHub
5
+ # Release is created with the matching CHANGELOG section as its notes.
6
+ #
7
+ # Jobs run on `steppestone-runners`, the barrenore org's ARC runner set. Its pods
8
+ # have no Docker daemon, so container actions are avoided.
9
+ on:
10
+ push:
11
+ tags: ["v*"]
12
+
13
+ permissions:
14
+ contents: read
15
+
16
+ jobs:
17
+ ci:
18
+ uses: ./.github/workflows/ci.yml
19
+
20
+ build:
21
+ needs: ci
22
+ runs-on: steppestone-runners
23
+ timeout-minutes: 10
24
+ steps:
25
+ - uses: actions/checkout@v4
26
+ - uses: astral-sh/setup-uv@v5
27
+
28
+ - name: Check the tag matches the package version and the changelog
29
+ run: |
30
+ set -euo pipefail
31
+ tag_version="${GITHUB_REF_NAME#v}"
32
+ project_version="$(uv run --no-project python -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')"
33
+ module_version="$(sed -n 's/^__version__ = "\(.*\)"$/\1/p' src/alchemic/__init__.py)"
34
+ echo "tag ${tag_version}, pyproject.toml ${project_version}, __version__ ${module_version}"
35
+ if [ "${tag_version}" != "${project_version}" ] || [ "${tag_version}" != "${module_version}" ]; then
36
+ echo "::error::Tag ${GITHUB_REF_NAME} does not match pyproject.toml (${project_version}) and __version__ (${module_version})" >&2
37
+ exit 1
38
+ fi
39
+ if ! grep -q "^## ${tag_version} " CHANGELOG.md; then
40
+ echo "::error::CHANGELOG.md has no '## ${tag_version} (date)' section" >&2
41
+ exit 1
42
+ fi
43
+
44
+ - run: uv build
45
+ - run: uvx twine check --strict dist/*
46
+ - uses: actions/upload-artifact@v4
47
+ with:
48
+ name: dist
49
+ path: dist/
50
+
51
+ publish:
52
+ needs: build
53
+ runs-on: steppestone-runners
54
+ timeout-minutes: 10
55
+ environment:
56
+ name: pypi
57
+ url: https://pypi.org/project/alchemic/
58
+ permissions:
59
+ id-token: write
60
+ steps:
61
+ - uses: actions/download-artifact@v4
62
+ with:
63
+ name: dist
64
+ path: dist/
65
+ - uses: astral-sh/setup-uv@v5
66
+ - run: uv publish --trusted-publishing always dist/*
67
+
68
+ github-release:
69
+ needs: publish
70
+ runs-on: steppestone-runners
71
+ timeout-minutes: 10
72
+ permissions:
73
+ contents: write
74
+ steps:
75
+ - uses: actions/checkout@v4
76
+ - uses: actions/download-artifact@v4
77
+ with:
78
+ name: dist
79
+ path: dist/
80
+ - name: Extract the release notes from CHANGELOG.md
81
+ run: |
82
+ set -euo pipefail
83
+ version="${GITHUB_REF_NAME#v}"
84
+ awk -v heading="## ${version} " '
85
+ index($0, heading) == 1 { found = 1; next }
86
+ found && /^## / { exit }
87
+ found { print }
88
+ ' CHANGELOG.md > release-notes.md
89
+ - uses: softprops/action-gh-release@v2
90
+ with:
91
+ name: ${{ github.ref_name }}
92
+ body_path: release-notes.md
93
+ files: dist/*
@@ -0,0 +1,14 @@
1
+ __pycache__/
2
+ *.egg-info/
3
+ .venv/
4
+ dist/
5
+ .pytest_cache/
6
+ .ruff_cache/
7
+ .mypy_cache/
8
+ .zlime/
9
+
10
+ dist/
11
+ .benchmarks/
12
+ .coverage
13
+ uv.lock
14
+ .pypi-api-key
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (2026-10-09)
4
+
5
+ Initial release.
alchemic-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Steppestone
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,382 @@
1
+ Metadata-Version: 2.5
2
+ Name: alchemic
3
+ Version: 0.1.0
4
+ Summary: SQLAlchemy 2.0 declarative models that are also Pydantic models, with Mapped[T] typing a type checker actually understands.
5
+ Project-URL: Repository, https://github.com/barrenore/alchemic
6
+ Project-URL: Issues, https://github.com/barrenore/alchemic/issues
7
+ Author: Steppestone
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: orm,pydantic,sqlalchemy,typing
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Classifier: Programming Language :: Python :: 3.14
14
+ Classifier: Typing :: Typed
15
+ Requires-Python: >=3.13
16
+ Requires-Dist: pydantic<3,>=2.11
17
+ Requires-Dist: sqlalchemy<2.1,>=2.0.31
18
+ Provides-Extra: dev
19
+ Requires-Dist: aiosqlite>=0.20; extra == 'dev'
20
+ Requires-Dist: alembic>=1.13; extra == 'dev'
21
+ Requires-Dist: black; extra == 'dev'
22
+ Requires-Dist: mypy; extra == 'dev'
23
+ Requires-Dist: psycopg[binary]>=3.2; extra == 'dev'
24
+ Requires-Dist: pylint; extra == 'dev'
25
+ Requires-Dist: pyright; extra == 'dev'
26
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
27
+ Requires-Dist: pytest-cov>=5; extra == 'dev'
28
+ Requires-Dist: pytest>=8; extra == 'dev'
29
+ Requires-Dist: ruff; extra == 'dev'
30
+ Requires-Dist: sqlalchemy[asyncio]; extra == 'dev'
31
+ Requires-Dist: ty==0.0.84; extra == 'dev'
32
+ Description-Content-Type: text/markdown
33
+
34
+ # alchemic
35
+
36
+ **alchemic** lets one class be both a SQLAlchemy 2.0 declarative model and a
37
+ validating Pydantic v2 model, declared once with `Mapped[T]`, and typed so that
38
+ pyright and mypy can tell a column from a value.
39
+
40
+ ```sh
41
+ pip install alchemic
42
+ ```
43
+
44
+ Requires Python 3.13 or later, SQLAlchemy 2.0.31 or later (not 2.1), and
45
+ Pydantic 2.11 or later.
46
+
47
+ ## Quickstart
48
+
49
+ ```python
50
+ import uuid
51
+
52
+ from sqlalchemy import String, create_engine
53
+
54
+ from alchemic import Mapped, Model, Session, mapped_column
55
+
56
+
57
+ class Character(Model):
58
+ __tablename__ = "characters"
59
+ id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
60
+ name: Mapped[str] = mapped_column(String(80))
61
+
62
+
63
+ engine = create_engine("sqlite://")
64
+ Model.metadata.create_all(engine)
65
+
66
+ with Session(engine) as session:
67
+ session.add(Character(name="Rick")) # validated by Pydantic
68
+ session.commit()
69
+ rick = session.exec(Character.where(Character.name == "Rick")).one() # a Character
70
+ ```
71
+
72
+ `Character.name` is a `TypedAttribute[str]`, an `InstrumentedAttribute` subclass:
73
+ queryable, with comparisons typed against `str`. `rick.name` is a `str`.
74
+
75
+ ## Why
76
+
77
+ SQLModel annotates columns as bare `str` or `UUID`, so a type checker cannot tell
78
+ a column from a value. Downstream that means generated stub files, type-checker
79
+ suppressions, and `where(Character.name in names)` silently compiling to
80
+ `WHERE false`. SQLModel does this because Pydantic cannot build a schema for
81
+ `Mapped[str]`. alchemic teaches Pydantic to read `Mapped[T]` as `T`, so both
82
+ frameworks read the same annotation, and a type checker sees the difference:
83
+
84
+ ```python
85
+ names = ["Rick", "Morty"]
86
+ character = Character(name="Rick")
87
+
88
+ Character.where(Character.name == "Rick") # accepted: the ergonomic idiom survives
89
+ Character.where(Character.name in names) # type error on both pyright and mypy
90
+ Character.where(Character.name == True) # type error: compared against the column's type
91
+ Character(nme="Rick") # type error: constructors are checked too
92
+ character.update(nme="Rick") # type error: so are partial updates
93
+
94
+
95
+ class Profile(Model):
96
+ __tablename__ = "profiles"
97
+ id: Mapped[int] = mapped_column(primary_key=True)
98
+ age: Mapped[int] = mapped_column(String(3)) # type error: column type vs. annotation
99
+ ```
100
+
101
+ Import `Mapped`, `mapped_column` and `relationship` from `alchemic`, not
102
+ `sqlalchemy.orm`. At runtime they are SQLAlchemy's; type checkers see versions
103
+ that check comparisons and constructors. `select`, `insert`, `update` and `delete`
104
+ are re-exported from SQLAlchemy for convenience.
105
+
106
+ ## Relationships
107
+
108
+ Relationships are declared once per side, and pair up by themselves:
109
+
110
+ ```python
111
+ from sqlalchemy import String
112
+
113
+ from alchemic import Mapped, Model, Related, link, mapped_column, path, prefetch
114
+ from alchemic import references, relationship
115
+
116
+
117
+ class Team(Model):
118
+ __tablename__ = "teams"
119
+ id: Mapped[int] = mapped_column(primary_key=True, init=False) # set by the database
120
+ name: Mapped[str] = mapped_column(String(40))
121
+ heroes: Related[list["Hero"]] = relationship()
122
+
123
+
124
+ class Hero(Model):
125
+ __tablename__ = "heroes"
126
+ id: Mapped[int] = mapped_column(primary_key=True, init=False)
127
+ name: Mapped[str] = mapped_column(String(80)) # 81 characters is a ValidationError
128
+ team_id: Mapped[int | None] = mapped_column(
129
+ references(Team.id, ondelete="CASCADE"), default=None
130
+ )
131
+ team: Related[Team | None] = relationship() # without | None: refused, team_id is nullable
132
+ powers: Related[list["Power"]] = relationship()
133
+
134
+
135
+ class Power(Model):
136
+ __tablename__ = "powers"
137
+ id: Mapped[int] = mapped_column(primary_key=True, init=False)
138
+ heroes: Related[list[Hero]] = relationship()
139
+
140
+
141
+ hero_power = link(Hero.powers, Power.heroes) # the many-to-many table, wired to both sides
142
+
143
+ session.exec(Team.where(Team.name == "red", prefetch=[path(Team.heroes, Hero.powers)]))
144
+ session.get(Hero, 1, options=prefetch(Hero, Hero.team))
145
+ path(Team.heroes, Power.heroes) # type error: Power.heroes does not start at Hero
146
+ ```
147
+
148
+ They are SQLAlchemy's own relationships, so `Hero.team` also works in `join()`,
149
+ `selectinload()` and `any()`.
150
+
151
+ `references(Team.id)` is a foreign key checked against the annotation:
152
+ `team_id: Mapped[str] = mapped_column(references(Team.id))` is a type error, since
153
+ `Team.id` is an `int`. A NOT NULL key that is usually set through its relationship
154
+ takes `default=None`, as in
155
+ `team_id: Mapped[int] = mapped_column(references(Team.id), default=None)`, so both
156
+ `Hero(team=red)` and `Hero(team_id=1)` work.
157
+
158
+ A many-to-many needs `link()`, which creates the association table: one column
159
+ per primary-key column of each side, each a foreign key with `ON DELETE CASCADE`.
160
+ `name=` names the table, and `prefixes=` prefixes its columns, which is required
161
+ when both sides are the same class. Call it before the first query.
162
+
163
+ ## Construction and validation
164
+
165
+ Unlike SQLModel's `table=True` models, construction validates: wrong types,
166
+ constraint violations and `@field_validator` all apply. Loading a row does not
167
+ re-validate, so validation costs nothing on the read path and legacy rows stay
168
+ readable.
169
+
170
+ A constructor argument is optional exactly when its declaration gives a default,
171
+ `mapped_column(default=None)` included. A nullable or `server_default` column is
172
+ still required without one, the same rule as Pydantic's `x: str | None`.
173
+ `init=False` leaves a column out of the constructor, for values the database
174
+ sets, such as an autoincrementing primary key.
175
+
176
+ An instance is a row: it equals only itself, and can go in a `set`. Compare data
177
+ with `model_dump()`. Copying an instance raises, since a copy would share the
178
+ original's SQLAlchemy state; construct a new instance, or `update` this one.
179
+
180
+ ## Writing
181
+
182
+ Writes are checked the same way:
183
+
184
+ ```python
185
+ hero.update(name="Rick") # validated, all or nothing
186
+ hero.update(patch) # a Pydantic model or a mapping, for PATCH
187
+ session.exec(Hero.assign(name="Rick").where(Hero.id == 1)) # a typed UPDATE
188
+ team = await hero.load(Hero.team) # an async lazy load, typed Team | None
189
+ ```
190
+
191
+ ## Values the model maintains
192
+
193
+ Values the model maintains itself are declared on the column. `default=` runs
194
+ when the row is inserted and `onupdate=` whenever an `UPDATE` changes it, bulk
195
+ ones included. Both take any function, checked against the annotation:
196
+
197
+ ```python
198
+ import uuid
199
+ from datetime import UTC, date, datetime
200
+
201
+ from sqlalchemy import Date, DateTime
202
+
203
+ from alchemic import Mapped, Model, mapped_column
204
+
205
+
206
+ def utcnow() -> datetime:
207
+ return datetime.now(UTC)
208
+
209
+
210
+ class Document(Model):
211
+ __tablename__ = "documents"
212
+ id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4, readonly=True)
213
+ created_at: Mapped[datetime] = mapped_column(
214
+ DateTime(timezone=True), default=utcnow, readonly=True
215
+ )
216
+ updated_at: Mapped[datetime] = mapped_column(
217
+ DateTime(timezone=True), default=utcnow, onupdate=utcnow
218
+ )
219
+ touched_on: Mapped[date] = mapped_column(Date, default=date.today, onupdate=date.today)
220
+ version: Mapped[int] = mapped_column(version=True, init=False) # optimistic locking
221
+ ```
222
+
223
+ `readonly=True` fields are set by the constructor. Changing one later, by
224
+ assignment or `update()`, raises a `ValidationError`. Writing back the value it
225
+ holds is allowed, and so is filling in a `None`. They are `readOnly` in the JSON
226
+ schema.
227
+
228
+ A `version=True` column is SQLAlchemy's `version_id_col`, starting at 1: a write
229
+ based on a stale read raises `StaleDataError`. It is read-only, unless the
230
+ application writes the version itself (`version_id_generator=False` in
231
+ `__mapper_args__`).
232
+
233
+ A preset is a plain function, and stays checked:
234
+
235
+ ```python
236
+ def updated_at() -> Mapped[datetime]:
237
+ return mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
238
+ ```
239
+
240
+ ## Hooks
241
+
242
+ For logic across fields, override a hook: `before_insert`, `before_update` or
243
+ `before_delete`. It runs inside the flush, under an `AsyncSession` too, so it is
244
+ a plain method; an `async def` hook is refused. `has_changed` tells what this
245
+ flush changes:
246
+
247
+ ```python
248
+ class Task(Model):
249
+ __tablename__ = "tasks"
250
+ id: Mapped[int] = mapped_column(primary_key=True, init=False)
251
+ status: Mapped[str] = mapped_column(String(20))
252
+ completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None)
253
+
254
+ def before_update(self) -> None:
255
+ if self.has_changed(Task.status): # a misspelled attribute is a type error
256
+ self.update(completed_at=utcnow() if self.status == "done" else None)
257
+
258
+ def before_delete(self) -> None:
259
+ if self.status != "done":
260
+ raise PermissionError("only finished tasks can be deleted")
261
+ ```
262
+
263
+ A bulk `UPDATE` runs no per-object code, so `Task.assign(...)` is refused for a
264
+ model with a `before_update` hook or `on_commit` update receivers.
265
+ `sqlalchemy.update(Task)` is the escape hatch: it bypasses both.
266
+
267
+ ## Running code after commit
268
+
269
+ Code that should run once the data is committed, such as a broadcast or a cache
270
+ invalidation, is an `on_commit` receiver. It runs after the outermost transaction
271
+ commits, never for a rollback or a rolled-back savepoint, and
272
+ `await session.commit()` awaits an async one:
273
+
274
+ ```python
275
+ from alchemic import Change, Deleted, Inserted, Updated, on_commit
276
+
277
+
278
+ async def publish(message: str) -> None: ...
279
+
280
+
281
+ @on_commit(Task)
282
+ async def broadcast(task: Task, change: Change) -> None:
283
+ match change: # Change = Inserted | Updated | Deleted
284
+ case Inserted() | Deleted():
285
+ await publish(f"task {task.id}")
286
+ case Updated() if change.has_changed(Task.status):
287
+ await publish(f"task {task.id} is {task.status}")
288
+
289
+
290
+ @on_commit(Task, Deleted) # the receiver is told a Deleted
291
+ def forget(task: Task, change: Deleted) -> None: ...
292
+ ```
293
+
294
+ The receiver is checked against the model and the kinds: one taking a `Hero`
295
+ for `Team`, or an `Updated` where only `Deleted` is registered, is a type error,
296
+ and a `match` that misses a kind fails `assert_never`. A receiver on a mixin
297
+ hears every model that inherits it. Any SQLAlchemy session runs receivers. If
298
+ receivers raise, all of them still run, and `commit()` then raises an
299
+ `ExceptionGroup`; the data is committed by then. Receivers are not durable:
300
+ work that must happen belongs in a job queue.
301
+
302
+ ## Sessions
303
+
304
+ `alchemic.Session` and `alchemic.AsyncSession` are SQLAlchemy's, plus `exec`,
305
+ which types the result by what the statement selects, for any SQLAlchemy
306
+ statement. SQLModel's `exec` does that only for its own `select()`: a plain
307
+ `sqlalchemy.select` is typed as tuples but runs as rows. The async session adds
308
+ `stream_exec` for large results, and does not expire on commit. Build it with
309
+ `alchemic.async_sessionmaker`, which keeps that default.
310
+
311
+ ## Field metadata
312
+
313
+ Metadata for forms, admin screens or API schemas can be attached to a field in
314
+ its `Annotated` type, and read back with `get_field_metadata`:
315
+
316
+ ```python
317
+ from typing import Annotated
318
+
319
+ from alchemic import Extension, FieldExtension, get_field_metadata
320
+
321
+
322
+ class Upload(Model):
323
+ __tablename__ = "uploads"
324
+ id: Mapped[int] = mapped_column(primary_key=True, init=False)
325
+ path: Mapped[Annotated[str, Extension(file_field=True, allow_upload=True)]] = mapped_column()
326
+ owner: Mapped[Annotated[str, FieldExtension(widget="user-picker")]] = mapped_column()
327
+
328
+
329
+ get_field_metadata(Upload, "path") # {'file_field': True, 'allow_upload': True}
330
+ ```
331
+
332
+ `Extension` has typed keys (`editable`, `file_field`, `allow_upload`, and `extra`
333
+ for anything else); `FieldExtension` takes any keyword arguments.
334
+ `get_field_metadata` merges the column's `info`, then `Annotated` metadata, then
335
+ `json_schema_extra`, a later source winning on a shared key.
336
+
337
+ ## Base classes and SQLModel
338
+
339
+ Every model inherits from `Model`, which has its own registry and `MetaData`. To
340
+ use a different one, with SQLModel during a migration for instance, declare a
341
+ base:
342
+
343
+ ```python
344
+ from sqlalchemy.orm import registry
345
+
346
+ shared_registry = registry()
347
+
348
+
349
+ class Base(Model):
350
+ __abstract__ = True
351
+ registry = shared_registry # or: metadata = MetaData() for a separate one
352
+ ```
353
+
354
+ `create_model_base(registry=..., metadata=..., name=...)` builds the same class
355
+ at runtime, but mypy does not accept a variable as a base class, so declaring it
356
+ is the portable form.
357
+
358
+ `references()` also takes a plain SQLAlchemy model's column. SQLModel declares
359
+ its columns as plain values (`Scene.id: UUID`), so a SQLModel column type-checks
360
+ there only with stubs that type it as a column; without them, use
361
+ `ForeignKey("scenes.id")`.
362
+
363
+ ## What importing alchemic changes
364
+
365
+ Two things outside alchemic's own classes:
366
+
367
+ - SQLAlchemy's `Mapped` gets a `__get_pydantic_core_schema__`, so Pydantic
368
+ validates `Mapped[T]` as `T`, unless SQLAlchemy already provides one.
369
+ - SQLAlchemy's `Session` gets `after_commit`, `after_soft_rollback` and
370
+ `after_transaction_end` listeners, which is how every session runs `on_commit`
371
+ receivers. They cost a dictionary lookup when nothing is pending.
372
+
373
+ ## Status
374
+
375
+ Alpha. The core is implemented and tested, but the API may still change, and a
376
+ 0.x minor release may break it. See
377
+ [CHANGELOG.md](https://github.com/barrenore/alchemic/blob/main/CHANGELOG.md) for
378
+ release notes and [SPEC.md](https://github.com/barrenore/alchemic/blob/main/SPEC.md)
379
+ for the design and its rationale. Report bugs at
380
+ [github.com/barrenore/alchemic/issues](https://github.com/barrenore/alchemic/issues).
381
+
382
+ MIT licensed.