ferro-orm 0.3.0__tar.gz → 0.3.1__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.
Files changed (121) hide show
  1. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/.github/PERMISSIONS.md +61 -32
  2. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/.github/workflows/release.yml +103 -42
  3. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/.gitignore +1 -2
  4. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/CHANGELOG.md +32 -4
  5. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/Cargo.lock +1 -1
  6. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/Cargo.toml +1 -1
  7. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/PKG-INFO +3 -4
  8. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/README.md +2 -3
  9. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/docs/TEST_RESULTS.md +4 -2
  10. ferro_orm-0.3.1/docs/api/fields.md +21 -0
  11. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/docs/coming-soon.md +6 -2
  12. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/docs/concepts/architecture.md +4 -2
  13. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/docs/concepts/performance.md +7 -3
  14. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/docs/getting-started/tutorial.md +13 -13
  15. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/docs/guide/migrations.md +35 -7
  16. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/docs/guide/models-and-fields.md +39 -26
  17. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/docs/guide/queries.md +3 -3
  18. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/docs/guide/relationships.md +1 -1
  19. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/docs/howto/pagination.md +5 -3
  20. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/docs/index.md +3 -3
  21. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/docs/migration-sqlalchemy.md +9 -6
  22. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/pyproject.toml +5 -2
  23. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/src/ferro/__init__.py +2 -1
  24. ferro_orm-0.3.1/src/ferro/_annotation_utils.py +28 -0
  25. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/src/ferro/base.py +50 -1
  26. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/src/ferro/composite_uniques.py +3 -2
  27. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/src/ferro/fields.py +18 -0
  28. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/src/ferro/metaclass.py +7 -1
  29. ferro_orm-0.3.1/src/ferro/migrations/alembic.py +345 -0
  30. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/src/ferro/models.py +18 -4
  31. ferro_orm-0.3.1/tests/test_alembic_autogenerate.py +190 -0
  32. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/tests/test_alembic_bridge.py +21 -1
  33. ferro_orm-0.3.1/tests/test_alembic_nullability.py +270 -0
  34. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/tests/test_alembic_type_mapping.py +3 -1
  35. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/tests/test_composite_unique.py +16 -16
  36. ferro_orm-0.3.1/tests/test_field_wrapper.py +95 -0
  37. ferro_orm-0.3.0/docs/api/fields.md +0 -21
  38. ferro_orm-0.3.0/src/ferro/migrations/alembic.py +0 -233
  39. ferro_orm-0.3.0/tests/test_alembic_autogenerate.py +0 -94
  40. ferro_orm-0.3.0/tests/test_field_wrapper.py +0 -47
  41. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  42. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  43. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/.github/PYPI_CHECKLIST.md +0 -0
  44. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/.github/PYPI_SETUP.md +0 -0
  45. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/.github/generated/wheels.generated.yml +0 -0
  46. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/.github/pull_request_template.md +0 -0
  47. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/.github/workflows/ci.yml +0 -0
  48. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/.github/workflows/packaging-smoke.yml +0 -0
  49. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/.github/workflows/publish-docs.yml +0 -0
  50. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/.github/workflows/publish.yml +0 -0
  51. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/.pre-commit-config.yaml +0 -0
  52. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/.python-version +0 -0
  53. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/CONTRIBUTING.md +0 -0
  54. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/LICENSE +0 -0
  55. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/docs/api/model.md +0 -0
  56. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/docs/api/query.md +0 -0
  57. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/docs/api/relationships.md +0 -0
  58. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/docs/api/transactions.md +0 -0
  59. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/docs/api/utilities.md +0 -0
  60. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/docs/changelog.md +0 -0
  61. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/docs/concepts/identity-map.md +0 -0
  62. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/docs/concepts/type-safety.md +0 -0
  63. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/docs/contributing.md +0 -0
  64. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/docs/faq.md +0 -0
  65. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/docs/getting-started/installation.md +0 -0
  66. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/docs/getting-started/next-steps.md +0 -0
  67. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/docs/guide/database.md +0 -0
  68. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/docs/guide/mutations.md +0 -0
  69. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/docs/guide/transactions.md +0 -0
  70. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/docs/howto/multiple-databases.md +0 -0
  71. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/docs/howto/soft-deletes.md +0 -0
  72. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/docs/howto/testing.md +0 -0
  73. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/docs/howto/timestamps.md +0 -0
  74. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/docs/stylesheets/extra.css +0 -0
  75. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/docs/why-ferro.md +0 -0
  76. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/justfile +0 -0
  77. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/mkdocs.yml +0 -0
  78. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/scripts/demo_queries.py +0 -0
  79. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/src/connection.rs +0 -0
  80. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/src/ferro/_core.pyi +0 -0
  81. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/src/ferro/_shadow_fk_types.py +0 -0
  82. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/src/ferro/migrations/__init__.py +0 -0
  83. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/src/ferro/py.typed +0 -0
  84. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/src/ferro/query/__init__.py +0 -0
  85. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/src/ferro/query/builder.py +0 -0
  86. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/src/ferro/query/nodes.py +0 -0
  87. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/src/ferro/relations/__init__.py +0 -0
  88. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/src/ferro/relations/descriptors.py +0 -0
  89. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/src/ferro/state.py +0 -0
  90. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/src/lib.rs +0 -0
  91. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/src/operations.rs +0 -0
  92. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/src/query.rs +0 -0
  93. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/src/schema.rs +0 -0
  94. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/src/state.rs +0 -0
  95. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/tests/conftest.py +0 -0
  96. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/tests/test_aggregation.py +0 -0
  97. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/tests/test_auto_migrate.py +0 -0
  98. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/tests/test_bulk_update.py +0 -0
  99. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/tests/test_connection.py +0 -0
  100. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/tests/test_constraints.py +0 -0
  101. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/tests/test_crud.py +0 -0
  102. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/tests/test_deletion.py +0 -0
  103. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/tests/test_docs_examples.py +0 -0
  104. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/tests/test_documentation_features.py +0 -0
  105. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/tests/test_helpers.py +0 -0
  106. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/tests/test_hydration.py +0 -0
  107. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/tests/test_metaclass_internals.py +0 -0
  108. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/tests/test_metadata.py +0 -0
  109. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/tests/test_models.py +0 -0
  110. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/tests/test_one_to_one.py +0 -0
  111. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/tests/test_query_builder.py +0 -0
  112. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/tests/test_refresh.py +0 -0
  113. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/tests/test_relationship_engine.py +0 -0
  114. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/tests/test_schema.py +0 -0
  115. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/tests/test_schema_constraints.py +0 -0
  116. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/tests/test_shadow_fk_types.py +0 -0
  117. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/tests/test_string_search.py +0 -0
  118. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/tests/test_structural_types.py +0 -0
  119. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/tests/test_temporal_types.py +0 -0
  120. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/tests/test_transactions.py +0 -0
  121. {ferro_orm-0.3.0 → ferro_orm-0.3.1}/uv.lock +0 -0
@@ -45,7 +45,37 @@ All workflows use explicit, fine-grained permissions (principle of least privile
45
45
 
46
46
  **Trigger:** Manual workflow dispatch
47
47
 
48
- **Permissions:**
48
+ **Job graph (top to bottom is execution order):**
49
+
50
+ ```
51
+ ci-gate packaging-smoke
52
+ \ /
53
+ prepare-release (computes bump, writes release.patch; no pushes)
54
+ / | | \
55
+ build-wheels build-sdist test-wheels verify-docs
56
+ \ | /
57
+ promote-pypi (PyPI Trusted Publishing; first irreversible step)
58
+ |
59
+ promote-git (commit bump, push main, tag, create GH Release + assets)
60
+ |
61
+ promote-docs (deploys MkDocs site pinned to the new tag)
62
+ ```
63
+
64
+ Nothing is pushed, tagged, or published until every validation job above
65
+ `promote-pypi` has succeeded. PyPI is published first so that if anything
66
+ fails, `main` is never advanced to an "orphan" version.
67
+
68
+ **Permissions (prepare-release):**
69
+ ```yaml
70
+ permissions:
71
+ contents: read
72
+ issues: read
73
+ pull-requests: read
74
+ ```
75
+
76
+ Prepare-release only needs read access because it just computes the bump and uploads a patch artifact — it does not push anything.
77
+
78
+ **Permissions (promote-git):**
49
79
  ```yaml
50
80
  permissions:
51
81
  contents: write
@@ -53,32 +83,23 @@ permissions:
53
83
  pull-requests: write
54
84
  ```
55
85
 
56
- **Why These Permissions:**
57
- - `contents: write` - Allows the workflow to:
58
- - Commit version bumps to pyproject.toml and Cargo.toml
86
+ - `contents: write` - Allows the job to:
87
+ - Commit version bumps to `pyproject.toml` and `Cargo.toml`
59
88
  - Push commits to the `main` branch
60
- - Create and push git tags (e.g., `v0.2.0`)
61
- - Create GitHub releases
89
+ - Create and push git tags (e.g., `v0.3.1`)
90
+ - Create GitHub Releases
62
91
 
63
- - `issues: write` - Allows the workflow to:
64
- - Update issue references in release notes
65
- - Close issues automatically via commit messages
66
- - Add labels or comments to issues
92
+ - `issues: write` / `pull-requests: write` - Allows semantic-release to
93
+ update issue/PR references in generated release notes.
67
94
 
68
- - `pull-requests: write` - Allows the workflow to:
69
- - Update PR references in release notes
70
- - Close PRs automatically via commit messages
71
- - Add labels or comments to PRs
72
-
73
- **What It Does:**
74
- - Analyzes conventional commits
75
- - Determines next version
76
- - Updates version in both Python and Rust files
77
- - Finalizes CHANGELOG.md
78
- - Creates git tag
79
- - Creates GitHub release
80
- - Triggers publish workflow
81
- - Reports whether release commits include `CHANGELOG.md`
95
+ **What the release flow does:**
96
+ - Analyzes conventional commits since the latest tag (fetched explicitly via `fetch-tags: true`)
97
+ - Fails fast if the computed version collides with an existing tag (indicates a tag-fetch bug)
98
+ - Bumps version in `pyproject.toml` and `Cargo.toml` and finalizes `CHANGELOG.md`
99
+ - Validates the bumped tree across wheel builds, sdist, install smoke tests, and `mkdocs build`
100
+ - Publishes to PyPI before touching `main`
101
+ - Commits bump to `main`, creates the tag, creates the GitHub Release with generated notes, attaches wheels + sdist
102
+ - Deploys the MkDocs site pinned to the new tag
82
103
 
83
104
  **Required Secrets:**
84
105
  - `RELEASE_DEPLOY_KEY` (private SSH key for a write-enabled deploy key)
@@ -88,15 +109,15 @@ permissions:
88
109
 
89
110
  ### 2. Build & Publish (jobs in `release.yml`)
90
111
 
91
- **Trigger:** Part of `release.yml` after the release job (no reusable workflow).
112
+ **Trigger:** Part of `release.yml` all build and publish jobs live in the same workflow file.
92
113
 
93
114
  Build, test, and publish jobs are defined directly in `release.yml` so PyPI Trusted Publishing receives a token with `workflow_ref: release.yml` (reusable workflows are not supported by PyPI).
94
115
 
95
116
  **Permissions:**
96
117
 
97
- **For build-wheels, build-sdist, test-wheels:** (default - read-only)
118
+ **For build-wheels, build-sdist, test-wheels, verify-docs:** (default - read-only)
98
119
 
99
- **For publish-pypi job:**
120
+ **For promote-pypi job:**
100
121
  ```yaml
101
122
  permissions:
102
123
  id-token: write
@@ -106,10 +127,10 @@ permissions:
106
127
  - `id-token: write` - Allows the job to request an OIDC token and authenticate with PyPI using Trusted Publishing.
107
128
 
108
129
  **What It Does:**
109
- - Builds wheels for multiple platforms from the release tag
110
- - Builds source distribution
111
- - Tests built packages
112
- - Publishes to PyPI using OIDC authentication
130
+ - Builds wheels for multiple platforms against the patched (bumped) tree
131
+ - Builds source distribution against the patched tree
132
+ - Installs wheels and runs a smoke import + in-memory SQLite check
133
+ - `promote-pypi` publishes the validated wheels + sdist to PyPI via OIDC
113
134
 
114
135
  ---
115
136
 
@@ -268,10 +289,18 @@ Permission to manage GitHub Pages deployments:
268
289
  **Cause:** Missing `id-token: write` permission
269
290
 
270
291
  **Solution:**
271
- - Ensure `publish-pypi` job has `id-token: write`
292
+ - Ensure `promote-pypi` job has `id-token: write`
272
293
  - Verify PyPI trusted publisher is configured correctly
273
294
  - Check environment name matches (`pypi`)
274
295
 
296
+ ### Release says "0.3.0 has already been released!" for a version that was already shipped
297
+
298
+ **Cause:** `actions/checkout@v4` runs `git fetch --no-tags` by default, even with `fetch-depth: 0`. Without tags, `semantic-release` picks an older baseline tag, computes a minor/patch bump, and lands on the same version number as a prior release.
299
+
300
+ **Solution:**
301
+ - The `prepare-release` job sets `fetch-tags: true` on its checkout step and then runs a `git describe` vs `semantic-release --print-last-released-tag` comparison as a guard. Both must be present.
302
+ - If this check fails in a run, confirm no one has removed `fetch-tags: true` from the checkout step.
303
+
275
304
  ---
276
305
 
277
306
  ## Verification
@@ -24,7 +24,7 @@ jobs:
24
24
 
25
25
  # Bump versions and changelog locally only; export a patch so every later job
26
26
  # validates the exact tree that would be released. Nothing is pushed until
27
- # publish-github-release after wheels, sdist, tests, and docs all pass.
27
+ # promote-* jobs run, after wheels, sdist, tests, and docs all pass.
28
28
  prepare-release:
29
29
  name: Prepare release (no push)
30
30
  needs: [ci-gate, packaging-smoke]
@@ -48,6 +48,11 @@ jobs:
48
48
  uses: actions/checkout@v4
49
49
  with:
50
50
  fetch-depth: 0
51
+ # actions/checkout@v4 passes --no-tags by default, even with
52
+ # fetch-depth: 0. semantic-release needs tags to pick the right
53
+ # baseline version; without them it may bump from an older tag and
54
+ # land on a version that has already been released.
55
+ fetch-tags: true
51
56
 
52
57
  - name: Set up Python
53
58
  uses: actions/setup-python@v5
@@ -63,6 +68,28 @@ jobs:
63
68
  run: |
64
69
  uv sync --only-group release --no-install-project --python 3.13
65
70
 
71
+ # Fail fast if the checkout did not materialize tags. Without this guard
72
+ # semantic-release silently picks an older baseline and can compute an
73
+ # already-released version (see PSR note: "No release will be made, X has
74
+ # already been released!").
75
+ - name: Verify tag visibility
76
+ env:
77
+ GH_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
78
+ run: |
79
+ set -euo pipefail
80
+ GIT_LATEST_TAG="$(git describe --tags --abbrev=0 2>/dev/null || echo '')"
81
+ PSR_LATEST_TAG="$(uv run semantic-release version --print-last-released-tag 2>/dev/null | tail -n1 || echo '')"
82
+
83
+ echo "git describe --tags --abbrev=0 -> '${GIT_LATEST_TAG}'"
84
+ echo "semantic-release --print-last-released-tag -> '${PSR_LATEST_TAG}'"
85
+
86
+ if [[ -n "${GIT_LATEST_TAG}" && "${GIT_LATEST_TAG}" != "${PSR_LATEST_TAG}" ]]; then
87
+ echo "ERROR: git and semantic-release disagree on the latest tag."
88
+ echo "This usually means actions/checkout did not fetch tags."
89
+ echo "Confirm 'fetch-tags: true' on the checkout step."
90
+ exit 1
91
+ fi
92
+
66
93
  - name: Run semantic-release (local bump only)
67
94
  env:
68
95
  GH_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
@@ -105,11 +132,21 @@ jobs:
105
132
  fi
106
133
 
107
134
  VERSION="$(python3 -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])")"
135
+ TAG="v${VERSION}"
136
+
137
+ # Final guard: if the tag already exists on the remote, bail before
138
+ # any irreversible promote-* step runs (PyPI, main push, GH Release).
139
+ if git ls-remote --tags origin "refs/tags/${TAG}" | grep -q .; then
140
+ echo "ERROR: tag ${TAG} already exists on the remote."
141
+ echo "This means semantic-release computed a version that is already released."
142
+ exit 1
143
+ fi
144
+
108
145
  echo "release_created=true" >> "$GITHUB_OUTPUT"
109
146
  echo "changelog_validated=passed" >> "$GITHUB_OUTPUT"
110
- echo "release_tag=v${VERSION}" >> "$GITHUB_OUTPUT"
147
+ echo "release_tag=${TAG}" >> "$GITHUB_OUTPUT"
111
148
  echo "release_version=${VERSION}" >> "$GITHUB_OUTPUT"
112
- echo "Prepared v${VERSION} as patch against ${{ github.sha }}"
149
+ echo "Prepared ${TAG} as patch against ${{ github.sha }}"
113
150
 
114
151
  - name: Upload release patch
115
152
  if: steps.verify_release.outputs.release_created == 'true'
@@ -359,11 +396,50 @@ jobs:
359
396
  run: |
360
397
  uv run --no-sync mkdocs build
361
398
 
362
- publish-github-release:
363
- name: Commit, tag, and create GitHub Release
399
+ # ---------------------------------------------------------------------------
400
+ # Promote phase: every validation above has passed. Irreversible steps now
401
+ # run in a strict order:
402
+ # promote-pypi -> publishes to PyPI (first irreversible action)
403
+ # promote-git -> commits bump, pushes main, creates tag, creates GH Release
404
+ # promote-docs -> deploys MkDocs site pinned to the new tag
405
+ # If promote-pypi fails, main is untouched and the run can be re-triggered.
406
+ # ---------------------------------------------------------------------------
407
+
408
+ promote-pypi:
409
+ name: Publish to PyPI
364
410
  needs: [prepare-release, build-wheels, build-sdist, test-wheels, verify-docs]
365
411
  if: needs.prepare-release.outputs.release_created == 'true'
366
412
  runs-on: ubuntu-latest
413
+ environment:
414
+ name: pypi
415
+ url: https://pypi.org/p/ferro-orm
416
+ permissions:
417
+ id-token: write
418
+ steps:
419
+ - name: Download sdist
420
+ uses: actions/download-artifact@v4
421
+ with:
422
+ name: sdist
423
+ path: dist
424
+
425
+ - name: Download wheels
426
+ uses: actions/download-artifact@v4
427
+ with:
428
+ pattern: wheels-*
429
+ path: dist
430
+ merge-multiple: true
431
+
432
+ - name: Publish to PyPI
433
+ uses: pypa/gh-action-pypi-publish@release/v1
434
+ with:
435
+ skip-existing: true
436
+ verbose: true
437
+
438
+ promote-git:
439
+ name: Commit, tag, and create GitHub Release
440
+ needs: [prepare-release, promote-pypi]
441
+ if: needs.prepare-release.outputs.release_created == 'true'
442
+ runs-on: ubuntu-latest
367
443
  permissions:
368
444
  contents: write
369
445
  issues: write
@@ -372,13 +448,15 @@ jobs:
372
448
  - name: Disable Git CRLF conversion before checkout
373
449
  shell: bash
374
450
  run: git config --global core.autocrlf false
451
+
375
452
  - name: Checkout repository
376
453
  uses: actions/checkout@v4
377
454
  with:
378
455
  ref: ${{ needs.prepare-release.outputs.release_base_sha }}
379
456
  fetch-depth: 0
457
+ fetch-tags: true
380
458
  ssh-key: ${{ secrets.RELEASE_DEPLOY_KEY }}
381
- # Required so the deploy key stays loaded for git push in the next step.
459
+ # Required so the deploy key stays loaded for git push below.
382
460
  persist-credentials: true
383
461
 
384
462
  - name: Download release patch
@@ -401,8 +479,10 @@ jobs:
401
479
  git config user.name "github-actions[bot]"
402
480
  git remote set-url origin "git@github.com:${{ github.repository }}.git"
403
481
 
482
+ # Re-check remote (prepare-release already checked, but the window
483
+ # between jobs is non-zero).
404
484
  if git ls-remote --tags origin "refs/tags/${TAG}" | grep -q .; then
405
- echo "ERROR: tag ${TAG} already exists on the remote."
485
+ echo "ERROR: tag ${TAG} appeared on the remote between jobs."
406
486
  exit 1
407
487
  fi
408
488
 
@@ -412,7 +492,7 @@ jobs:
412
492
  -m "Automatically generated by python-semantic-release"
413
493
  git push origin "HEAD:refs/heads/${{ github.ref_name }}"
414
494
 
415
- git tag "${TAG}"
495
+ git tag -a "${TAG}" -m "Release ${TAG}"
416
496
  git push origin "refs/tags/${TAG}"
417
497
 
418
498
  # semantic-release matches branch config (e.g. branches.main); detached HEAD fails with
@@ -430,57 +510,38 @@ jobs:
430
510
  with:
431
511
  enable-cache: true
432
512
 
433
- - name: Set up Rust
434
- uses: dtolnay/rust-toolchain@stable
435
-
436
- - name: Cache Rust dependencies
437
- uses: Swatinem/rust-cache@v2
438
- with:
439
- prefix-key: "v1-rust"
440
-
441
513
  - name: Install dependencies
442
514
  run: |
443
515
  uv sync --only-group release --no-install-project --python 3.13
444
516
 
445
- - name: Publish GitHub release (assets + VCS release)
446
- env:
447
- GH_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
448
- run: |
449
- uv run semantic-release publish --tag "${{ needs.prepare-release.outputs.release_tag }}"
450
-
451
- publish-pypi:
452
- name: Publish to PyPI
453
- needs: [prepare-release, publish-github-release]
454
- if: needs.prepare-release.outputs.release_created == 'true'
455
- runs-on: ubuntu-latest
456
- environment:
457
- name: pypi
458
- url: https://pypi.org/p/ferro-orm
459
- permissions:
460
- id-token: write
461
- steps:
462
- - name: Download sdist
517
+ # semantic-release publish only uploads assets to an existing GitHub
518
+ # Release. Create the release with generated notes first, then attach
519
+ # wheels + sdist from the validation jobs.
520
+ - name: Download sdist for GitHub Release assets
463
521
  uses: actions/download-artifact@v4
464
522
  with:
465
523
  name: sdist
466
524
  path: dist
467
525
 
468
- - name: Download wheels
526
+ - name: Download wheels for GitHub Release assets
469
527
  uses: actions/download-artifact@v4
470
528
  with:
471
529
  pattern: wheels-*
472
530
  path: dist
473
531
  merge-multiple: true
474
532
 
475
- - name: Publish to PyPI
476
- uses: pypa/gh-action-pypi-publish@release/v1
477
- with:
478
- skip-existing: true
479
- verbose: true
533
+ - name: Create GitHub Release and upload assets
534
+ env:
535
+ GH_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
536
+ run: |
537
+ set -euo pipefail
538
+ TAG="${{ needs.prepare-release.outputs.release_tag }}"
539
+ uv run semantic-release changelog --post-to-release-tag "$TAG"
540
+ uv run semantic-release publish --tag "$TAG"
480
541
 
481
- deploy-docs:
542
+ promote-docs:
482
543
  name: Deploy Documentation
483
- needs: [prepare-release, publish-pypi]
544
+ needs: [prepare-release, promote-git]
484
545
  if: needs.prepare-release.outputs.release_created == 'true'
485
546
  uses: ./.github/workflows/publish-docs.yml
486
547
  permissions:
@@ -243,5 +243,4 @@ playground.ipynb
243
243
  IMPLEMENTATION.md
244
244
  Cargo.lock
245
245
  demo.db
246
- docs/superpowers/plans/2026-04-22-shadow-fk-types.md
247
- docs/superpowers/specs/2026-04-22-shadow-fk-types-design.md
246
+ docs/superpowers
@@ -1,7 +1,38 @@
1
1
  # CHANGELOG
2
2
 
3
3
 
4
- ## v0.3.0 (2026-04-22)
4
+ ## v0.3.1 (2026-04-23)
5
+
6
+ ### Bug Fixes
7
+
8
+ - Alembic autogenerate named SQLAlchemy enums for PostgreSQL
9
+ ([`25a00e8`](https://github.com/syn54x/ferro-orm/commit/25a00e84502ae1f8ba502718934d93eedfa4ce09))
10
+
11
+ - **migrations**: Align nullable inference with field types
12
+ ([`885f0fe`](https://github.com/syn54x/ferro-orm/commit/885f0fe155dfa643e29b9425ff1ede62f3f0b269))
13
+
14
+ - **migrations**: Propagate ForeignKey(unique=True) to Alembic metadata
15
+ ([#22](https://github.com/syn54x/ferro-orm/pull/22),
16
+ [`9329e8f`](https://github.com/syn54x/ferro-orm/commit/9329e8fba2f0efd201bea4545393654c7d1dd34e))
17
+
18
+ ### Continuous Integration
19
+
20
+ - Fix release
21
+ ([`e2822f6`](https://github.com/syn54x/ferro-orm/commit/e2822f6c9bacc6fc955e56b2ca8e120cc22b0b72))
22
+
23
+ - Fix release
24
+ ([`e5c1adc`](https://github.com/syn54x/ferro-orm/commit/e5c1adcc10eb44845ef95d78226840ecdbfd0ebd))
25
+
26
+ - Fix release
27
+ ([`688d01b`](https://github.com/syn54x/ferro-orm/commit/688d01bdae0aff1f82e4a1bb60dd1b8ab35e1d01))
28
+
29
+ ### Documentation
30
+
31
+ - Prefer Field over FerroField
32
+ ([`3385cfa`](https://github.com/syn54x/ferro-orm/commit/3385cfadf0951f80827dac1aa08f73430a02023f))
33
+
34
+
35
+ ## v0.3.0 (2026-04-23)
5
36
 
6
37
  ### Bug Fixes
7
38
 
@@ -18,9 +49,6 @@
18
49
 
19
50
  ### Continuous Integration
20
51
 
21
- - Fix release
22
- ([`688d01b`](https://github.com/syn54x/ferro-orm/commit/688d01bdae0aff1f82e4a1bb60dd1b8ab35e1d01))
23
-
24
52
  - Fix release
25
53
  ([`249e460`](https://github.com/syn54x/ferro-orm/commit/249e46058bac87215920083a9d45557f3c58b62f))
26
54
 
@@ -294,7 +294,7 @@ dependencies = [
294
294
 
295
295
  [[package]]
296
296
  name = "ferro"
297
- version = "0.3.0"
297
+ version = "0.3.1"
298
298
  dependencies = [
299
299
  "dashmap",
300
300
  "once_cell",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "ferro"
3
- version = "0.3.0"
3
+ version = "0.3.1"
4
4
  edition = "2024"
5
5
  readme = "README.md"
6
6
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ferro-orm
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Requires-Dist: pydantic>=2.0
5
5
  License-File: LICENSE
6
6
  Summary: A high-performance, Rust-backed ORM for Python.
@@ -55,11 +55,10 @@ pip install "ferro-orm[alembic]"
55
55
 
56
56
  ```python
57
57
  import asyncio
58
- from typing import Annotated
59
- from ferro import Model, FerroField, connect
58
+ from ferro import Field, Model, connect
60
59
 
61
60
  class User(Model):
62
- id: Annotated[int, FerroField(primary_key=True)]
61
+ id: int | None = Field(default=None, primary_key=True)
63
62
  username: str
64
63
  is_active: bool = True
65
64
 
@@ -45,11 +45,10 @@ pip install "ferro-orm[alembic]"
45
45
 
46
46
  ```python
47
47
  import asyncio
48
- from typing import Annotated
49
- from ferro import Model, FerroField, connect
48
+ from ferro import Field, Model, connect
50
49
 
51
50
  class User(Model):
52
- id: Annotated[int, FerroField(primary_key=True)]
51
+ id: int | None = Field(default=None, primary_key=True)
53
52
  username: str
54
53
  is_active: bool = True
55
54
 
@@ -27,7 +27,7 @@ All field types and constraints work as documented:
27
27
  - Basic model definition ✅
28
28
  - All documented field types (str, int, Decimal, date, dict, Enum) ✅
29
29
  - Enum field type with proper serialization/deserialization ✅
30
- - Field() Pydantic-style constraints ✅
30
+ - Field() Pydantic-style constraints ✅ (preferred in user-facing docs)
31
31
  - FerroField() Annotated-style constraints ✅
32
32
  - Unique constraints ✅
33
33
 
@@ -131,8 +131,10 @@ await User.where(User.role.in_([UserRole.ADMIN.value, UserRole.MODERATOR.value])
131
131
  **Working Pattern**: Primary keys should be optional with None default:
132
132
 
133
133
  ```python
134
+ from ferro import Field, Model
135
+
134
136
  class User(Model):
135
- id: Annotated[int | None, FerroField(primary_key=True)] = None
137
+ id: int | None = Field(default=None, primary_key=True)
136
138
  username: str
137
139
  ```
138
140
 
@@ -0,0 +1,21 @@
1
+ # Fields API
2
+
3
+ Complete reference for field types and metadata. In application models, use **`ferro.Field`** in either the **assignment** or **annotation** pattern described in the [models & fields guide](../guide/models-and-fields.md#field-constraints). **`FerroField`** is the lower-level metadata type; `Field(...)` is normalized into the same shape internally.
4
+
5
+ ## Field
6
+
7
+ ::: ferro.fields.Field
8
+ options:
9
+ show_source: false
10
+ heading_level: 3
11
+
12
+ ## FerroField
13
+
14
+ ::: ferro.base.FerroField
15
+ options:
16
+ show_source: false
17
+ heading_level: 3
18
+
19
+ ## See Also
20
+
21
+ - [Models & Fields Guide](../guide/models-and-fields.md)
@@ -429,12 +429,16 @@ Many-to-many relationships are defined with `ManyToManyField`, but the join tabl
429
429
 
430
430
  **Example (Partially Working):**
431
431
  ```python
432
+ from typing import Annotated
433
+
434
+ from ferro import BackRef, Field, ManyToManyField, Model
435
+
432
436
  class Post(Model):
433
- id: Annotated[int | None, FerroField(primary_key=True)] = None
437
+ id: int | None = Field(default=None, primary_key=True)
434
438
  tags: Annotated[list["Tag"], ManyToManyField(related_name="posts")] = None
435
439
 
436
440
  class Tag(Model):
437
- id: Annotated[int | None, FerroField(primary_key=True)] = None
441
+ id: int | None = Field(default=None, primary_key=True)
438
442
  posts: BackRef[list["Post"]] | None = None
439
443
 
440
444
  # Models created, but join table 'post_tags' is NOT auto-created
@@ -192,9 +192,11 @@ sequenceDiagram
192
192
 
193
193
  Python model:
194
194
  ```python
195
+ from ferro import Field, Model
196
+
195
197
  class User(Model):
196
- id: Annotated[int, FerroField(primary_key=True)]
197
- username: Annotated[str, FerroField(unique=True)]
198
+ id: int | None = Field(default=None, primary_key=True)
199
+ username: str = Field(unique=True)
198
200
  email: str
199
201
  ```
200
202
 
@@ -102,10 +102,14 @@ await User.where(User.is_active == False).update(status="archived")
102
102
  ### 3. Index Frequently Filtered Fields
103
103
 
104
104
  ```python
105
+ from datetime import datetime
106
+
107
+ from ferro import Field, Model
108
+
105
109
  class User(Model):
106
- email: Annotated[str, FerroField(unique=True, index=True)]
107
- status: Annotated[str, FerroField(index=True)]
108
- created_at: Annotated[datetime, FerroField(index=True)]
110
+ email: str = Field(unique=True, index=True)
111
+ status: str = Field(index=True)
112
+ created_at: datetime = Field(index=True)
109
113
  ```
110
114
 
111
115
  ### 4. Use `.exists()` Instead of `.count()`
@@ -26,17 +26,17 @@ Let's create a blog with users, posts, and comments:
26
26
  import asyncio
27
27
  from datetime import datetime
28
28
  from typing import Annotated
29
- from ferro import Model, FerroField, ForeignKey, BackRef, connect
29
+ from ferro import Model, Field, ForeignKey, BackRef, connect
30
30
 
31
31
  class User(Model):
32
- id: Annotated[int, FerroField(primary_key=True)]
33
- username: Annotated[str, FerroField(unique=True)]
34
- email: Annotated[str, FerroField(unique=True)]
32
+ id: int | None = Field(default=None, primary_key=True)
33
+ username: str = Field(unique=True)
34
+ email: str = Field(unique=True)
35
35
  posts: BackRef[list["Post"]] | None = None
36
36
  comments: BackRef[list["Comment"]] | None = None
37
37
 
38
38
  class Post(Model):
39
- id: Annotated[int, FerroField(primary_key=True)]
39
+ id: int | None = Field(default=None, primary_key=True)
40
40
  title: str
41
41
  content: str
42
42
  published: bool = False
@@ -45,7 +45,7 @@ class Post(Model):
45
45
  comments: BackRef[list["Comment"]] | None = None
46
46
 
47
47
  class Comment(Model):
48
- id: Annotated[int, FerroField(primary_key=True)]
48
+ id: int | None = Field(default=None, primary_key=True)
49
49
  text: str
50
50
  created_at: datetime = datetime.now()
51
51
  author: Annotated[User, ForeignKey(related_name="comments")]
@@ -305,17 +305,17 @@ Here's the full tutorial code:
305
305
  import asyncio
306
306
  from datetime import datetime
307
307
  from typing import Annotated
308
- from ferro import Model, FerroField, ForeignKey, BackRef, connect
308
+ from ferro import Model, Field, ForeignKey, BackRef, connect
309
309
 
310
310
  class User(Model):
311
- id: Annotated[int, FerroField(primary_key=True)]
312
- username: Annotated[str, FerroField(unique=True)]
313
- email: Annotated[str, FerroField(unique=True)]
311
+ id: int | None = Field(default=None, primary_key=True)
312
+ username: str = Field(unique=True)
313
+ email: str = Field(unique=True)
314
314
  posts: BackRef[list["Post"]] | None = None
315
315
  comments: BackRef[list["Comment"]] | None = None
316
316
 
317
317
  class Post(Model):
318
- id: Annotated[int, FerroField(primary_key=True)]
318
+ id: int | None = Field(default=None, primary_key=True)
319
319
  title: str
320
320
  content: str
321
321
  published: bool = False
@@ -324,7 +324,7 @@ class Post(Model):
324
324
  comments: BackRef[list["Comment"]] | None = None
325
325
 
326
326
  class Comment(Model):
327
- id: Annotated[int, FerroField(primary_key=True)]
327
+ id: int | None = Field(default=None, primary_key=True)
328
328
  text: str
329
329
  created_at: datetime = datetime.now()
330
330
  author: Annotated[User, ForeignKey(related_name="comments")]
@@ -367,7 +367,7 @@ if __name__ == "__main__":
367
367
  In this tutorial, you learned:
368
368
 
369
369
  ✅ How to define models with `Model` and type hints
370
- ✅ How to add constraints with `FerroField` or `Field`
370
+ ✅ How to add constraints with `Field()` (assignment or `Annotated[..., Field(...)]`)
371
371
  ✅ How to create relationships with `ForeignKey` and `BackRef`
372
372
  ✅ How to connect to a database with `connect()`
373
373
  ✅ How to create records with `.create()`