cygnet-orm 1.0.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.
- cygnet_orm-1.0.0/.github/workflows/ci.yml +311 -0
- cygnet_orm-1.0.0/.github/workflows/publish.yml +55 -0
- cygnet_orm-1.0.0/.gitignore +39 -0
- cygnet_orm-1.0.0/ARCHITECTURE.md +97 -0
- cygnet_orm-1.0.0/LICENSE +21 -0
- cygnet_orm-1.0.0/PKG-INFO +1090 -0
- cygnet_orm-1.0.0/README.md +1056 -0
- cygnet_orm-1.0.0/THEORY.md +297 -0
- cygnet_orm-1.0.0/bench/__init__.py +0 -0
- cygnet_orm-1.0.0/bench/_compare.py +98 -0
- cygnet_orm-1.0.0/bench/_summary.py +37 -0
- cygnet_orm-1.0.0/bench/comparison/__init__.py +0 -0
- cygnet_orm-1.0.0/bench/comparison/apps.py +16 -0
- cygnet_orm-1.0.0/bench/comparison/models.py +36 -0
- cygnet_orm-1.0.0/bench/comparison/test_comparison.py +352 -0
- cygnet_orm-1.0.0/bench/conftest.py +190 -0
- cygnet_orm-1.0.0/bench/test_e2e.py +139 -0
- cygnet_orm-1.0.0/bench/test_overhead.py +152 -0
- cygnet_orm-1.0.0/bench/test_render.py +146 -0
- cygnet_orm-1.0.0/cygnet/__init__.py +521 -0
- cygnet_orm-1.0.0/cygnet/annotations.py +86 -0
- cygnet_orm-1.0.0/cygnet/arrays.py +111 -0
- cygnet_orm-1.0.0/cygnet/builders.py +1070 -0
- cygnet_orm-1.0.0/cygnet/cte.py +321 -0
- cygnet_orm-1.0.0/cygnet/executor.py +1442 -0
- cygnet_orm-1.0.0/cygnet/expression.py +611 -0
- cygnet_orm-1.0.0/cygnet/fts.py +125 -0
- cygnet_orm-1.0.0/cygnet/functions.py +101 -0
- cygnet_orm-1.0.0/cygnet/jsonb.py +112 -0
- cygnet_orm-1.0.0/cygnet/meta.py +264 -0
- cygnet_orm-1.0.0/cygnet/predicate.py +263 -0
- cygnet_orm-1.0.0/cygnet/proxy.py +168 -0
- cygnet_orm-1.0.0/cygnet/psycopg_db.py +185 -0
- cygnet_orm-1.0.0/cygnet/py.typed +0 -0
- cygnet_orm-1.0.0/cygnet/stubs.py +170 -0
- cygnet_orm-1.0.0/justfile +134 -0
- cygnet_orm-1.0.0/pyproject.toml +98 -0
- cygnet_orm-1.0.0/tests/__init__.py +0 -0
- cygnet_orm-1.0.0/tests/conftest.py +144 -0
- cygnet_orm-1.0.0/tests/integration/__init__.py +4 -0
- cygnet_orm-1.0.0/tests/integration/conftest.py +36 -0
- cygnet_orm-1.0.0/tests/integration/test_advanced_queries.py +285 -0
- cygnet_orm-1.0.0/tests/integration/test_column_defaults.py +95 -0
- cygnet_orm-1.0.0/tests/integration/test_pg_helpers.py +333 -0
- cygnet_orm-1.0.0/tests/integration/test_pg_types.py +818 -0
- cygnet_orm-1.0.0/tests/integration/test_roundtrip.py +802 -0
- cygnet_orm-1.0.0/tests/test_bench_compare.py +139 -0
- cygnet_orm-1.0.0/tests/test_bench_summary.py +55 -0
- cygnet_orm-1.0.0/tests/test_builders.py +1665 -0
- cygnet_orm-1.0.0/tests/test_cross_table_dml.py +230 -0
- cygnet_orm-1.0.0/tests/test_cte.py +218 -0
- cygnet_orm-1.0.0/tests/test_error_messages.py +143 -0
- cygnet_orm-1.0.0/tests/test_expression.py +195 -0
- cygnet_orm-1.0.0/tests/test_functions.py +141 -0
- cygnet_orm-1.0.0/tests/test_lateral.py +165 -0
- cygnet_orm-1.0.0/tests/test_locking.py +214 -0
- cygnet_orm-1.0.0/tests/test_mapping.py +188 -0
- cygnet_orm-1.0.0/tests/test_meta.py +335 -0
- cygnet_orm-1.0.0/tests/test_on_conflict.py +267 -0
- cygnet_orm-1.0.0/tests/test_pg_helpers.py +207 -0
- cygnet_orm-1.0.0/tests/test_predicate.py +166 -0
- cygnet_orm-1.0.0/tests/test_set_ops.py +182 -0
- cygnet_orm-1.0.0/tests/test_streaming.py +115 -0
- cygnet_orm-1.0.0/tests/test_stubs.py +95 -0
- cygnet_orm-1.0.0/tests/test_subquery.py +196 -0
- cygnet_orm-1.0.0/tests/test_transaction.py +255 -0
- cygnet_orm-1.0.0/tests/test_windows.py +145 -0
- cygnet_orm-1.0.0/uv.lock +613 -0
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
# Weekly audit run (S17). Monday 04:00 UTC is a low-contention slot
|
|
8
|
+
# — post-weekend, before East-Coast business hours — so CVE findings
|
|
9
|
+
# land in inboxes before any reviewer is on shift. The audit job
|
|
10
|
+
# runs on this trigger; the other jobs are gated below with
|
|
11
|
+
# `if: github.event_name != 'schedule'` so the cron doesn't burn CI
|
|
12
|
+
# minutes re-running unit / integration / build / bench against
|
|
13
|
+
# unchanged code.
|
|
14
|
+
schedule:
|
|
15
|
+
- cron: "0 4 * * 1"
|
|
16
|
+
|
|
17
|
+
# Opt every JS-based action into Node 24 ahead of the September 2026
|
|
18
|
+
# Node 20 removal. GitHub honours this env var on the runner and
|
|
19
|
+
# applies it to actions/checkout, actions/setup-python, codecov, etc.
|
|
20
|
+
# without us needing to bump each action's major version individually.
|
|
21
|
+
# Once the actions ship Node-24-native versions, this can come out.
|
|
22
|
+
env:
|
|
23
|
+
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
|
24
|
+
|
|
25
|
+
jobs:
|
|
26
|
+
|
|
27
|
+
# ── Unit tests (no database) ──────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
unit:
|
|
30
|
+
name: Unit tests — Python ${{ matrix.python-version }}
|
|
31
|
+
runs-on: ubuntu-latest
|
|
32
|
+
# Skip on the weekly schedule (audit-only trigger — see top-level
|
|
33
|
+
# `on:`). Code hasn't changed since the last push, so re-running
|
|
34
|
+
# unit tests would be pure CI cost.
|
|
35
|
+
if: github.event_name != 'schedule'
|
|
36
|
+
strategy:
|
|
37
|
+
fail-fast: false
|
|
38
|
+
matrix:
|
|
39
|
+
python-version: ["3.12", "3.13"]
|
|
40
|
+
|
|
41
|
+
steps:
|
|
42
|
+
- uses: actions/checkout@v4
|
|
43
|
+
|
|
44
|
+
# OQ3 migration: uv is now canonical (justfile + ci.yml both run
|
|
45
|
+
# through it). setup-uv@v6 installs uv, installs the matrix
|
|
46
|
+
# Python via uv, and caches the global uv download cache keyed on
|
|
47
|
+
# uv.lock so re-runs against unchanged deps are near-instant.
|
|
48
|
+
- uses: astral-sh/setup-uv@v6
|
|
49
|
+
with:
|
|
50
|
+
python-version: ${{ matrix.python-version }}
|
|
51
|
+
enable-cache: true
|
|
52
|
+
|
|
53
|
+
- name: Install dependencies
|
|
54
|
+
# --locked: fail loudly if uv.lock drifted from pyproject.toml
|
|
55
|
+
# (CI should never silently resolve a different graph than what
|
|
56
|
+
# was committed). --extra dev pulls in pytest / ruff / mypy /
|
|
57
|
+
# psycopg.
|
|
58
|
+
run: uv sync --locked --extra dev
|
|
59
|
+
|
|
60
|
+
- name: Format check
|
|
61
|
+
run: uv run ruff format --check cygnet tests
|
|
62
|
+
|
|
63
|
+
- name: Lint
|
|
64
|
+
run: uv run ruff check cygnet tests
|
|
65
|
+
|
|
66
|
+
- name: Type check
|
|
67
|
+
run: uv run mypy cygnet
|
|
68
|
+
|
|
69
|
+
- name: Unit tests
|
|
70
|
+
run: |
|
|
71
|
+
uv run pytest tests/ --ignore=tests/integration \
|
|
72
|
+
-v \
|
|
73
|
+
--cov=cygnet \
|
|
74
|
+
--cov-report=xml \
|
|
75
|
+
--cov-report=term-missing
|
|
76
|
+
|
|
77
|
+
- name: Upload coverage
|
|
78
|
+
uses: codecov/codecov-action@v4
|
|
79
|
+
if: matrix.python-version == '3.12'
|
|
80
|
+
with:
|
|
81
|
+
files: coverage.xml
|
|
82
|
+
fail_ci_if_error: false
|
|
83
|
+
|
|
84
|
+
# ── Integration tests (live PostgreSQL via Docker service) ────────────────
|
|
85
|
+
|
|
86
|
+
integration:
|
|
87
|
+
name: Integration tests — PG ${{ matrix.pg-version }}
|
|
88
|
+
runs-on: ubuntu-latest
|
|
89
|
+
if: github.event_name != 'schedule'
|
|
90
|
+
strategy:
|
|
91
|
+
fail-fast: false
|
|
92
|
+
matrix:
|
|
93
|
+
pg-version: ["14", "15", "16", "17", "18"]
|
|
94
|
+
|
|
95
|
+
services:
|
|
96
|
+
postgres:
|
|
97
|
+
image: postgres:${{ matrix.pg-version }}-alpine
|
|
98
|
+
env:
|
|
99
|
+
POSTGRES_USER: cygnet
|
|
100
|
+
POSTGRES_PASSWORD: cygnet
|
|
101
|
+
POSTGRES_DB: cygnet_test
|
|
102
|
+
ports:
|
|
103
|
+
- 5432:5432
|
|
104
|
+
options: >-
|
|
105
|
+
--health-cmd "pg_isready -U cygnet"
|
|
106
|
+
--health-interval 5s
|
|
107
|
+
--health-timeout 3s
|
|
108
|
+
--health-retries 10
|
|
109
|
+
|
|
110
|
+
steps:
|
|
111
|
+
- uses: actions/checkout@v4
|
|
112
|
+
|
|
113
|
+
- uses: astral-sh/setup-uv@v6
|
|
114
|
+
with:
|
|
115
|
+
python-version: "3.12"
|
|
116
|
+
enable-cache: true
|
|
117
|
+
|
|
118
|
+
- name: Install dependencies
|
|
119
|
+
run: uv sync --locked --extra dev
|
|
120
|
+
|
|
121
|
+
- name: Integration tests
|
|
122
|
+
env:
|
|
123
|
+
CYGNET_TEST_DSN: postgresql://cygnet:cygnet@localhost:5432/cygnet_test
|
|
124
|
+
run: |
|
|
125
|
+
uv run pytest tests/integration \
|
|
126
|
+
-v \
|
|
127
|
+
-m integration \
|
|
128
|
+
--cov=cygnet \
|
|
129
|
+
--cov-report=xml
|
|
130
|
+
|
|
131
|
+
- name: Upload coverage
|
|
132
|
+
uses: codecov/codecov-action@v4
|
|
133
|
+
if: matrix.pg-version == '16'
|
|
134
|
+
with:
|
|
135
|
+
files: coverage.xml
|
|
136
|
+
fail_ci_if_error: false
|
|
137
|
+
|
|
138
|
+
# ── Build check ───────────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
build:
|
|
141
|
+
name: Build
|
|
142
|
+
runs-on: ubuntu-latest
|
|
143
|
+
needs: [unit, integration]
|
|
144
|
+
if: github.event_name != 'schedule'
|
|
145
|
+
|
|
146
|
+
steps:
|
|
147
|
+
- uses: actions/checkout@v4
|
|
148
|
+
|
|
149
|
+
- uses: astral-sh/setup-uv@v6
|
|
150
|
+
with:
|
|
151
|
+
python-version: "3.12"
|
|
152
|
+
enable-cache: true
|
|
153
|
+
|
|
154
|
+
# `uvx --from hatch hatch build` runs hatch in an ephemeral
|
|
155
|
+
# isolated environment — no need to install hatch into the
|
|
156
|
+
# project venv (the build job doesn't need the project's deps).
|
|
157
|
+
- name: Build wheel and sdist
|
|
158
|
+
run: uvx --from hatch hatch build
|
|
159
|
+
|
|
160
|
+
- name: Upload artifacts
|
|
161
|
+
uses: actions/upload-artifact@v4
|
|
162
|
+
with:
|
|
163
|
+
name: dist
|
|
164
|
+
path: dist/
|
|
165
|
+
|
|
166
|
+
# ── Dependency audit ──────────────────────────────────────────────────────
|
|
167
|
+
# pip-audit scans installed dependencies (psycopg[binary] and dev tools)
|
|
168
|
+
# against the PyPI Advisory Database. Runs on every push/PR so a CVE
|
|
169
|
+
# surface is caught before release. Non-blocking exit so an advisory
|
|
170
|
+
# doesn't immediately wedge unrelated PRs — review the job log instead.
|
|
171
|
+
|
|
172
|
+
audit:
|
|
173
|
+
name: Dependency audit
|
|
174
|
+
runs-on: ubuntu-latest
|
|
175
|
+
|
|
176
|
+
steps:
|
|
177
|
+
- uses: actions/checkout@v4
|
|
178
|
+
|
|
179
|
+
- uses: astral-sh/setup-uv@v6
|
|
180
|
+
with:
|
|
181
|
+
python-version: "3.12"
|
|
182
|
+
enable-cache: true
|
|
183
|
+
|
|
184
|
+
- name: Install dependencies
|
|
185
|
+
# pip-audit scans the project venv, so it needs to be installed
|
|
186
|
+
# alongside the project deps (not via uvx, which would isolate
|
|
187
|
+
# it from what we want to audit).
|
|
188
|
+
run: |
|
|
189
|
+
uv sync --locked --extra dev
|
|
190
|
+
uv pip install pip-audit
|
|
191
|
+
|
|
192
|
+
- name: Run pip-audit
|
|
193
|
+
# --skip-editable skips the local cygnet-orm editable install (it
|
|
194
|
+
# isn't on PyPI, so it can't be audited). Without --strict the
|
|
195
|
+
# skipped editable is a notice rather than a hard failure, so the
|
|
196
|
+
# job exits non-zero ONLY when pip-audit actually finds a CVE in
|
|
197
|
+
# a dep — which is the point. The previous `--strict || true`
|
|
198
|
+
# combo was contradictory: --strict failed on the editable skip,
|
|
199
|
+
# and `|| true` swallowed both that AND any real CVE.
|
|
200
|
+
run: uv run pip-audit --skip-editable
|
|
201
|
+
|
|
202
|
+
# ── Benchmarks (advisory) ─────────────────────────────────────────────────
|
|
203
|
+
# Runs the perf suite against a fresh PG instance and uploads the
|
|
204
|
+
# JSON output as an artifact. Advisory-only: a regression doesn't
|
|
205
|
+
# block merge — the artifact (and the in-job summary table) are the
|
|
206
|
+
# signal. PR comparisons against main's baseline are a separate
|
|
207
|
+
# follow-up; today the artifact is the source of truth.
|
|
208
|
+
|
|
209
|
+
bench:
|
|
210
|
+
name: Benchmarks (advisory)
|
|
211
|
+
runs-on: ubuntu-latest
|
|
212
|
+
needs: [unit, integration]
|
|
213
|
+
if: github.event_name != 'schedule'
|
|
214
|
+
# Job continues even if benchmarks regress or hit a transient
|
|
215
|
+
# variance issue; the artifact upload still happens via if: always().
|
|
216
|
+
continue-on-error: true
|
|
217
|
+
|
|
218
|
+
services:
|
|
219
|
+
postgres:
|
|
220
|
+
image: postgres:16-alpine
|
|
221
|
+
env:
|
|
222
|
+
POSTGRES_USER: cygnet
|
|
223
|
+
POSTGRES_PASSWORD: cygnet
|
|
224
|
+
POSTGRES_DB: cygnet_test
|
|
225
|
+
ports:
|
|
226
|
+
- 5432:5432
|
|
227
|
+
options: >-
|
|
228
|
+
--health-cmd "pg_isready -U cygnet"
|
|
229
|
+
--health-interval 5s
|
|
230
|
+
--health-timeout 3s
|
|
231
|
+
--health-retries 10
|
|
232
|
+
|
|
233
|
+
steps:
|
|
234
|
+
- uses: actions/checkout@v4
|
|
235
|
+
|
|
236
|
+
- uses: astral-sh/setup-uv@v6
|
|
237
|
+
with:
|
|
238
|
+
python-version: "3.12"
|
|
239
|
+
enable-cache: true
|
|
240
|
+
|
|
241
|
+
- name: Install dependencies
|
|
242
|
+
run: uv sync --locked --extra dev --extra bench
|
|
243
|
+
|
|
244
|
+
- name: Run benchmarks
|
|
245
|
+
env:
|
|
246
|
+
CYGNET_TEST_DSN: postgresql://cygnet:cygnet@localhost:5432/cygnet_test
|
|
247
|
+
run: |
|
|
248
|
+
uv run pytest bench/ \
|
|
249
|
+
--benchmark-only \
|
|
250
|
+
--benchmark-json=bench-result.json \
|
|
251
|
+
--benchmark-columns=median,min,max,iqr,ops \
|
|
252
|
+
--benchmark-sort=name
|
|
253
|
+
|
|
254
|
+
- name: Summary table in job output
|
|
255
|
+
if: always()
|
|
256
|
+
run: |
|
|
257
|
+
if [ -f bench-result.json ]; then
|
|
258
|
+
echo "## Benchmark results" >> $GITHUB_STEP_SUMMARY
|
|
259
|
+
uv run python -m bench._summary bench-result.json >> $GITHUB_STEP_SUMMARY
|
|
260
|
+
fi
|
|
261
|
+
|
|
262
|
+
# ── PR-only: pull main's last bench artifact and diff ──────────────
|
|
263
|
+
# On pull_request events, fetch the most recent successful main-branch
|
|
264
|
+
# bench-results artifact and emit a per-benchmark delta against the
|
|
265
|
+
# current run. Graceful when the baseline is missing (first PR after
|
|
266
|
+
# a fresh repo, retention expired, etc.) — the `|| true` guarantees
|
|
267
|
+
# the step doesn't fail the job, in keeping with the advisory-only
|
|
268
|
+
# contract.
|
|
269
|
+
|
|
270
|
+
- name: Download main baseline (PR only)
|
|
271
|
+
if: github.event_name == 'pull_request'
|
|
272
|
+
env:
|
|
273
|
+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
274
|
+
run: |
|
|
275
|
+
LATEST=$(gh run list \
|
|
276
|
+
--branch main \
|
|
277
|
+
--workflow CI \
|
|
278
|
+
--status success \
|
|
279
|
+
--limit 1 \
|
|
280
|
+
--json databaseId \
|
|
281
|
+
--jq '.[0].databaseId' || echo "")
|
|
282
|
+
if [ -n "$LATEST" ]; then
|
|
283
|
+
mkdir -p baseline
|
|
284
|
+
gh run download "$LATEST" --name bench-results --dir baseline \
|
|
285
|
+
--repo "${{ github.repository }}" \
|
|
286
|
+
|| echo "no baseline artifact found"
|
|
287
|
+
fi
|
|
288
|
+
|
|
289
|
+
- name: Compare against baseline (PR only)
|
|
290
|
+
if: github.event_name == 'pull_request'
|
|
291
|
+
run: |
|
|
292
|
+
if [ -f baseline/bench-result.json ] && [ -f bench-result.json ]; then
|
|
293
|
+
echo "" >> $GITHUB_STEP_SUMMARY
|
|
294
|
+
echo "## Δ vs main baseline" >> $GITHUB_STEP_SUMMARY
|
|
295
|
+
echo "" >> $GITHUB_STEP_SUMMARY
|
|
296
|
+
uv run python -m bench._compare \
|
|
297
|
+
baseline/bench-result.json \
|
|
298
|
+
bench-result.json \
|
|
299
|
+
>> $GITHUB_STEP_SUMMARY
|
|
300
|
+
echo "" >> $GITHUB_STEP_SUMMARY
|
|
301
|
+
echo "_Bold deltas indicate >15% slower than baseline; CI runner noise typically falls within ±10%._" >> $GITHUB_STEP_SUMMARY
|
|
302
|
+
else
|
|
303
|
+
echo "_No main baseline available for comparison (first run, expired artifact, or upstream missing)._" >> $GITHUB_STEP_SUMMARY
|
|
304
|
+
fi
|
|
305
|
+
|
|
306
|
+
- name: Upload benchmark JSON
|
|
307
|
+
if: always()
|
|
308
|
+
uses: actions/upload-artifact@v4
|
|
309
|
+
with:
|
|
310
|
+
name: bench-results
|
|
311
|
+
path: bench-result.json
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
# Trusted publishing (OIDC): no API token is stored anywhere. The job mints a
|
|
4
|
+
# short-lived OIDC identity token that PyPI exchanges for a one-shot upload
|
|
5
|
+
# credential, and PEP 740 Sigstore attestations linking the artifacts to this
|
|
6
|
+
# exact workflow run are generated automatically.
|
|
7
|
+
#
|
|
8
|
+
# Publishing a GitHub Release is the deliberate human gate on an irreversible
|
|
9
|
+
# step: PyPI is append-only, so a given name==version (cygnet-orm==1.0.0) can
|
|
10
|
+
# never be re-uploaded or replaced — only yanked. Creating the release is the
|
|
11
|
+
# trigger; nothing publishes on a plain push.
|
|
12
|
+
on:
|
|
13
|
+
release:
|
|
14
|
+
types: [published]
|
|
15
|
+
|
|
16
|
+
# Match ci.yml: opt JS-based actions into Node 24 ahead of the Node 20 removal.
|
|
17
|
+
env:
|
|
18
|
+
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
|
19
|
+
|
|
20
|
+
jobs:
|
|
21
|
+
publish:
|
|
22
|
+
name: Build and publish to PyPI
|
|
23
|
+
runs-on: ubuntu-latest
|
|
24
|
+
|
|
25
|
+
# The `pypi` environment must match the one named in the PyPI pending
|
|
26
|
+
# publisher. Scoping the job to an environment also lets you add required
|
|
27
|
+
# reviewers / branch rules to gate the publish in repo settings.
|
|
28
|
+
environment:
|
|
29
|
+
name: pypi
|
|
30
|
+
url: https://pypi.org/p/cygnet-orm
|
|
31
|
+
|
|
32
|
+
permissions:
|
|
33
|
+
# REQUIRED for trusted publishing — lets the job request the OIDC token.
|
|
34
|
+
# Without it there is no credential to exchange and the upload fails.
|
|
35
|
+
id-token: write
|
|
36
|
+
|
|
37
|
+
steps:
|
|
38
|
+
- uses: actions/checkout@v4
|
|
39
|
+
|
|
40
|
+
- uses: astral-sh/setup-uv@v6
|
|
41
|
+
with:
|
|
42
|
+
python-version: "3.12"
|
|
43
|
+
enable-cache: true
|
|
44
|
+
|
|
45
|
+
# Build with the project's own backend (hatchling) via uv, so the release
|
|
46
|
+
# artifacts are produced exactly like `uv build` locally and the CI build
|
|
47
|
+
# job — sdist + wheel into dist/.
|
|
48
|
+
- name: Build sdist + wheel
|
|
49
|
+
run: uv build
|
|
50
|
+
|
|
51
|
+
# The blessed PyPA action consumes dist/ and uploads via trusted
|
|
52
|
+
# publishing. No `with: password:` — the OIDC token is the credential,
|
|
53
|
+
# and attestations are on by default.
|
|
54
|
+
- name: Publish to PyPI
|
|
55
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
.eggs/
|
|
6
|
+
dist/
|
|
7
|
+
build/
|
|
8
|
+
.venv/
|
|
9
|
+
*.egg
|
|
10
|
+
|
|
11
|
+
# Environment
|
|
12
|
+
.env
|
|
13
|
+
|
|
14
|
+
# Test / coverage
|
|
15
|
+
.pytest_cache/
|
|
16
|
+
.coverage
|
|
17
|
+
coverage.xml
|
|
18
|
+
htmlcov/
|
|
19
|
+
|
|
20
|
+
# Mypy
|
|
21
|
+
.mypy_cache/
|
|
22
|
+
|
|
23
|
+
# Ruff
|
|
24
|
+
.ruff_cache/
|
|
25
|
+
|
|
26
|
+
# macOS
|
|
27
|
+
.DS_Store
|
|
28
|
+
|
|
29
|
+
# Editor
|
|
30
|
+
.idea/
|
|
31
|
+
.vscode/
|
|
32
|
+
*.swp
|
|
33
|
+
|
|
34
|
+
# Codebase-memory graph artifacts (ADR notes; graph DB lives in central store)
|
|
35
|
+
.codebase-memory/
|
|
36
|
+
|
|
37
|
+
# Local working artifacts: code-review outputs and superpowers plans/specs.
|
|
38
|
+
# Design rationale worth keeping has been folded into THEORY.md.
|
|
39
|
+
docs/
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# Architecture
|
|
2
|
+
|
|
3
|
+
Cygnet is a small, async, **PostgreSQL-only** ORM that maps plain dataclasses to
|
|
4
|
+
tables and keeps SQL visible. The one fact to hold first: there is no query DSL
|
|
5
|
+
behind which SQL hides — every query verb builds an AST of **`SQLRenderable`**
|
|
6
|
+
nodes that render, in a single left-to-right pass over one shared params list, to
|
|
7
|
+
parameterised PG SQL (`$1, $2, …`). Almost everything else falls out of that.
|
|
8
|
+
|
|
9
|
+
For *why* any of this is shaped the way it is, see [THEORY.md](THEORY.md). For
|
|
10
|
+
install/build/test/usage, see [README.md](README.md). Open issues are tracked in the
|
|
11
|
+
project's [GitHub issues](https://github.com/Xof/cygnet/issues).
|
|
12
|
+
|
|
13
|
+
## Component map
|
|
14
|
+
|
|
15
|
+
Authoritative module enumeration. Dependency direction is strictly downward at
|
|
16
|
+
module load: `__init__` → `builders` → `executor` →
|
|
17
|
+
`proxy`/`cte`/`predicate`/`expression` → `meta` → `annotations`, with no cycles.
|
|
18
|
+
Would-be cycles are broken by *deferred* (in-function) imports instead of
|
|
19
|
+
module-load ones — notably `executor.run_save` → `builders` (the save→builder
|
|
20
|
+
edge) and `predicate.__invert__` → `expression.PrefixOp` (the reverse of
|
|
21
|
+
`expression`'s module-load import of `Predicate`, needed only for `~`).
|
|
22
|
+
|
|
23
|
+
| Module | Responsibility | Key symbols |
|
|
24
|
+
|---|---|---|
|
|
25
|
+
| `__init__.py` | Public API surface; query-verb factories | `Table`, `SELECT`/`INSERT`/`UPDATE`/`DELETE`/`TRUNCATE`, `get`/`save`/`create`/`follow`, `lit`/`op`/`ops`/`exists`, `transaction`, `cte`/`recursive_cte`/`lateral` |
|
|
26
|
+
| `annotations.py` | Passive metadata markers, introspected by `meta` | `DBKey`, `AppKey`, `Column`, `ForeignKey`, `@table` |
|
|
27
|
+
| `meta.py` | Dataclass → `TableMeta`/`FieldMeta` introspection | `TableMeta` (WeakValueDict-cached per class) |
|
|
28
|
+
| `proxy.py` | Attribute access → predicate AST | `TableProxy[T]`, `ColumnProxy[FT]` (per-class singletons; `.AS()` bypasses cache) |
|
|
29
|
+
| `predicate.py` | Comparison/predicate AST + the operator-overload menu | `Predicate`, `Literal`, `_All`, **`_InfixOps`** mixin |
|
|
30
|
+
| `expression.py` | `SQLRenderable` protocol + expression nodes | `SQLRenderable`, `op`/`ops`, `PrefixOp`/`SuffixOp`, `FunctionCall`, `WindowExpression`, `_Exists`, `exists`/`not_exists` |
|
|
31
|
+
| `builders.py` | Fluent, awaitable query builders | `SelectBuilder`, `InsertBuilder`, `UpdateBuilder`, `DeleteBuilder`, `_LockClause` |
|
|
32
|
+
| `executor.py` | Render + execute each verb; row→object mapping | `render_*`/`run_*` per verb, hydration |
|
|
33
|
+
| `cte.py` | WITH-clause / lateral sources that duck-type `TableProxy` | `CTE`, `RecursiveCTE`, `Lateral` |
|
|
34
|
+
| `functions.py` | Curated PG function wrappers | `count`, `sum`, `coalesce`, `row_number`, `lag`/`lead`, … + `fn(name)` escape hatch |
|
|
35
|
+
| `jsonb.py` / `arrays.py` / `fts.py` | Operator/function helpers for JSONB, arrays, full-text | `->`/`->>`/`@>`, `&&`/`ANY`/`ALL`, `to_tsvector`/`@@`/`ts_rank` |
|
|
36
|
+
| `psycopg_db.py` | Reference psycopg3 adapter | `PsycopgDB` (the **only** module that imports psycopg; gated behind `[psycopg]` extra) |
|
|
37
|
+
| `stubs.py` | `python -m cygnet.stubs` codegen for IDE autocomplete | — |
|
|
38
|
+
| `py.typed` | PEP 561 typed-library marker | — |
|
|
39
|
+
|
|
40
|
+
Tests: `tests/` unit suite against `tests/conftest.py:FakeDB` (no DB);
|
|
41
|
+
`tests/integration/` real-PG round-trips (CI matrix PG 14–18); `bench/` advisory
|
|
42
|
+
pytest-benchmark + cross-ORM comparison.
|
|
43
|
+
|
|
44
|
+
## Invariants
|
|
45
|
+
|
|
46
|
+
Load-bearing rules. THEORY.md §"Invariants, and why they hold" supplies the *why*;
|
|
47
|
+
this is the enumeration.
|
|
48
|
+
|
|
49
|
+
- **`UPDATE` and `DELETE` must call `.WHERE()`.** Breaks: raises `ValueError` at render *and* `.sql()` time. Opt out of the rail with `WHERE(cygnet.all)`; mixing `cygnet.all` with a real predicate also raises.
|
|
50
|
+
- **`ColumnProxy.__eq__` (and `__ne__`/`__lt__`/arithmetic) return a `Predicate`, not a `bool`; `__hash__` is `None`.** Breaks: a proxy comparison used in a boolean/`if` context is always truthy (it's an AST node) — a silent logic bug, not an error. Proxies are unhashable on purpose (can't go in a set/dict key).
|
|
51
|
+
- **One shared `params: list` threads through the whole render, numbered monotonically in document order (`$N = len(params)` after append).** Breaks: every renderable must append in left-to-right textual order; any out-of-order or two-pass rendering corrupts `$N` numbering and the adapter's `$N`→`%s` translation.
|
|
52
|
+
- **Anything in a WHERE/SELECT-list/ORDER BY/HAVING/SET-RHS position must implement `render_sql(self, params) -> str`.** Breaks: non-renderables don't compose; this protocol is the only contract the executor relies on.
|
|
53
|
+
- **`TableProxy`/`TableMeta` are per-class singletons (WeakValueDictionary); code compares them by identity (`b._table is X`).** Breaks: a non-singleton proxy fails identity checks in the executor. `.AS(alias)` deliberately returns a *fresh* proxy (self-joins need two); the canonical `Table(cls)` stays singleton.
|
|
54
|
+
- **Exactly one `DBKey` or `AppKey` field per model.** Breaks: `meta._introspect` raises; composite PKs are unsupported by design.
|
|
55
|
+
- **`DBKey` + `frozen=True` is rejected at introspection time.** Breaks: post-INSERT the executor `setattr`s the generated PK; a frozen instance would raise `FrozenInstanceError` deep in insert — caught early so the error names the model.
|
|
56
|
+
- **`AppKey` + `None` at INSERT raises; empty `UPDATE … SET` raises; unknown `SET`/INSERT/`DO UPDATE` kwargs raise.** Breaks: these are anti-silent-no-op rails — a typo'd field name must fail loudly, not emit a no-op.
|
|
57
|
+
- **The `db` object satisfies a duck-typed protocol — `execute`, `execute_one`, optional `stream`, and a `_in_transaction: bool` flag — and is per-task.** Breaks: `_in_transaction` is per-`db`-instance, not task-local; sharing one connection across `asyncio` tasks corrupts SAVEPOINT nesting.
|
|
58
|
+
- **Core never imports a driver; only `psycopg_db.py` imports psycopg.** Breaks: pulling psycopg into core contradicts the bring-your-own-adapter design and the driver-free core install.
|
|
59
|
+
|
|
60
|
+
## Landmines
|
|
61
|
+
|
|
62
|
+
Non-obvious things a cold worker gets wrong. Each prevents a specific wrong action.
|
|
63
|
+
|
|
64
|
+
- **`cygnet.lit(sql)` is raw, trusted, and *still* passes through the adapter's `$N`→`%s` regex.** A literal containing `$1` (or a `%`) gets rewritten. No parameter substitution happens inside `lit()` — it is a SQL-injection surface; never build it from untrusted input.
|
|
65
|
+
- **Awaiting a builder twice re-renders and re-executes it.** Builders hold no rendered SQL/params between calls — intentional, so `.sql()`-then-`await` is safe, but a reused builder is a fresh query each time.
|
|
66
|
+
- **`save()` does NOT refresh non-PK columns on the upsert branch.** `INSERT … ON CONFLICT DO UPDATE` emits no `RETURNING`; the in-memory object diverges from the row if the table has triggers / generated columns / `DEFAULT` on update. The *fresh-INSERT* branch does refresh. (Deferred tradeoff; OQ1 in the ADR.)
|
|
67
|
+
- **`GROUP_BY` requires explicit columns: `SELECT(db, col1, …)`.** Bare `SELECT(db)` + `GROUP_BY` raises `ValueError`.
|
|
68
|
+
- **Window-frame strings are interpolated verbatim** (e.g. `ROWS BETWEEN …`) — trusted, not parameterised. Another injection surface.
|
|
69
|
+
- **`CTE`/`Lateral` duck-type `TableProxy`** by stamping `ColumnProxy` attrs on themselves with `# type: ignore`; they are not yet a formal `TableSourceProtocol` (OQ4). Treat `_sql_name`/`_meta`/`_alias` as the surface the executor reads.
|
|
70
|
+
- **`_Exists` is dedicated, not `PrefixOp("EXISTS", …)`** — because `SelectBuilder.render_sql` already wraps itself in parens, reusing `PrefixOp` would emit `EXISTS ((SELECT …))`. `~exists(b)` toggles `EXISTS ↔ NOT EXISTS` rather than wrapping in `NOT (…)`.
|
|
71
|
+
|
|
72
|
+
## Flow
|
|
73
|
+
|
|
74
|
+
`cygnet.SELECT(db, …)` returns a `SelectBuilder`; fluent methods (`FROM`, `WHERE`,
|
|
75
|
+
`JOIN`, `ORDER_BY`, …) mutate it and return `self`. The terminal action is
|
|
76
|
+
`await builder` → `__await__` → `_execute()` → `Executor.render_<verb>(b, params)`
|
|
77
|
+
(single pass, document order) → `db.execute(sql, params)` → row tuples →
|
|
78
|
+
`Executor` maps rows back to dataclass instances (single-source) or
|
|
79
|
+
`(left, right, …)` tuples (multi-source JOIN; `None` on a side signals an
|
|
80
|
+
outer-join miss). `.sql()` renders without executing; `.stream()` async-iterates a
|
|
81
|
+
server-side portal cursor instead of buffering. A `SelectBuilder` is itself a
|
|
82
|
+
`SQLRenderable`, so it drops into any expression position as a subquery with no
|
|
83
|
+
wrapper.
|
|
84
|
+
|
|
85
|
+
## Where to change X
|
|
86
|
+
|
|
87
|
+
- **Add a curated PG function** → `functions.py` (or reach `fn("name")` at call site).
|
|
88
|
+
- **Add a JSONB / array / FTS operator** → `jsonb.py` / `arrays.py` / `fts.py`.
|
|
89
|
+
- **Add or alter a SQL verb / clause** → fluent method in `builders.py` + render branch in `executor.py`.
|
|
90
|
+
- **Change row→object hydration** → `executor.py` mapping path.
|
|
91
|
+
- **Add a new expression node** → implement `render_sql(self, params)`; it composes everywhere automatically (the operator-overload menu is the `_InfixOps` mixin in `predicate.py`).
|
|
92
|
+
- **Add PK/FK/column semantics** → marker in `annotations.py` + introspection in `meta.py`.
|
|
93
|
+
- **Add a `db` adapter** → satisfy the four-method protocol; `psycopg_db.py` is the reference.
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
For the reasoning behind all of the above, see [THEORY.md](THEORY.md).
|
cygnet_orm-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Christophe Pettus
|
|
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.
|