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.
- alchemic-0.1.0/.github/actionlint.yaml +3 -0
- alchemic-0.1.0/.github/workflows/ci.yml +151 -0
- alchemic-0.1.0/.github/workflows/release.yml +93 -0
- alchemic-0.1.0/.gitignore +14 -0
- alchemic-0.1.0/CHANGELOG.md +5 -0
- alchemic-0.1.0/LICENSE +21 -0
- alchemic-0.1.0/PKG-INFO +382 -0
- alchemic-0.1.0/README.md +349 -0
- alchemic-0.1.0/SPEC.md +638 -0
- alchemic-0.1.0/docker-compose.yaml +29 -0
- alchemic-0.1.0/pyproject.toml +115 -0
- alchemic-0.1.0/src/alchemic/__init__.py +61 -0
- alchemic-0.1.0/src/alchemic/_constants.py +31 -0
- alchemic-0.1.0/src/alchemic/_fields.py +325 -0
- alchemic-0.1.0/src/alchemic/_mapped_schema.py +20 -0
- alchemic-0.1.0/src/alchemic/_metadata.py +45 -0
- alchemic-0.1.0/src/alchemic/_receivers.py +292 -0
- alchemic-0.1.0/src/alchemic/_relationships.py +307 -0
- alchemic-0.1.0/src/alchemic/_sqlalchemy_private.py +79 -0
- alchemic-0.1.0/src/alchemic/_typing.py +440 -0
- alchemic-0.1.0/src/alchemic/extensions.py +113 -0
- alchemic-0.1.0/src/alchemic/model.py +723 -0
- alchemic-0.1.0/src/alchemic/py.typed +0 -0
- alchemic-0.1.0/src/alchemic/session.py +235 -0
- alchemic-0.1.0/tests/conftest.py +139 -0
- alchemic-0.1.0/tests/test_async.py +176 -0
- alchemic-0.1.0/tests/test_column_limits.py +63 -0
- alchemic-0.1.0/tests/test_copy.py +39 -0
- alchemic-0.1.0/tests/test_core.py +180 -0
- alchemic-0.1.0/tests/test_declared_defaults.py +61 -0
- alchemic-0.1.0/tests/test_dirty_tracking.py +70 -0
- alchemic-0.1.0/tests/test_extensions.py +103 -0
- alchemic-0.1.0/tests/test_fields.py +212 -0
- alchemic-0.1.0/tests/test_identity.py +69 -0
- alchemic-0.1.0/tests/test_inheritance.py +160 -0
- alchemic-0.1.0/tests/test_init_false.py +63 -0
- alchemic-0.1.0/tests/test_loaded_instances.py +127 -0
- alchemic-0.1.0/tests/test_metadata_helpers.py +55 -0
- alchemic-0.1.0/tests/test_model_internals.py +140 -0
- alchemic-0.1.0/tests/test_multi_registry.py +136 -0
- alchemic-0.1.0/tests/test_postgres.py +210 -0
- alchemic-0.1.0/tests/test_postgres_features.py +140 -0
- alchemic-0.1.0/tests/test_private_api.py +129 -0
- alchemic-0.1.0/tests/test_receivers.py +342 -0
- alchemic-0.1.0/tests/test_references.py +73 -0
- alchemic-0.1.0/tests/test_related.py +232 -0
- alchemic-0.1.0/tests/test_relationship_optionality.py +102 -0
- alchemic-0.1.0/tests/test_session.py +208 -0
- alchemic-0.1.0/tests/test_typing.py +641 -0
- alchemic-0.1.0/tests/test_update.py +307 -0
- alchemic-0.1.0/tests/test_where.py +42 -0
- alchemic-0.1.0/tests/test_write_rules.py +619 -0
|
@@ -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/*
|
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.
|
alchemic-0.1.0/PKG-INFO
ADDED
|
@@ -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.
|