activemodel 0.14.0__tar.gz → 0.15.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.
Files changed (80) hide show
  1. {activemodel-0.14.0 → activemodel-0.15.0}/.github/dependabot.yml +2 -2
  2. {activemodel-0.14.0 → activemodel-0.15.0}/.github/workflows/build_and_publish.yml +21 -7
  3. {activemodel-0.14.0 → activemodel-0.15.0}/.github/workflows/repo-sync.yml +2 -4
  4. {activemodel-0.14.0 → activemodel-0.15.0}/CHANGELOG.md +20 -0
  5. activemodel-0.15.0/Justfile +105 -0
  6. {activemodel-0.14.0 → activemodel-0.15.0}/PKG-INFO +4 -4
  7. {activemodel-0.14.0 → activemodel-0.15.0}/activemodel/base_model.py +4 -6
  8. activemodel-0.15.0/activemodel/patches/__init__.py +2 -0
  9. {activemodel-0.14.0/activemodel → activemodel-0.15.0/activemodel/patches}/get_column_from_field_patch.py +29 -40
  10. {activemodel-0.14.0 → activemodel-0.15.0}/activemodel/pytest/factories.py +12 -1
  11. {activemodel-0.14.0 → activemodel-0.15.0}/activemodel/pytest/plugin.py +11 -7
  12. {activemodel-0.14.0 → activemodel-0.15.0}/activemodel/pytest/transaction.py +7 -0
  13. {activemodel-0.14.0 → activemodel-0.15.0}/activemodel/pytest/truncate.py +0 -1
  14. {activemodel-0.14.0 → activemodel-0.15.0}/activemodel/types/sqlalchemy_protocol.py +1 -3
  15. {activemodel-0.14.0 → activemodel-0.15.0}/activemodel/types/sqlalchemy_protocol.pyi +1 -1
  16. {activemodel-0.14.0 → activemodel-0.15.0}/activemodel/types/typeid_patch.py +6 -0
  17. {activemodel-0.14.0 → activemodel-0.15.0}/docker-compose.yml +2 -2
  18. {activemodel-0.14.0 → activemodel-0.15.0}/pyproject.toml +9 -7
  19. {activemodel-0.14.0 → activemodel-0.15.0}/test/models.py +1 -1
  20. {activemodel-0.14.0 → activemodel-0.15.0}/test/orm/test_upsert.py +4 -5
  21. {activemodel-0.14.0 → activemodel-0.15.0}/test/orm_test.py +1 -0
  22. {activemodel-0.14.0 → activemodel-0.15.0}/test/pytest/pytest_test.py +1 -0
  23. {activemodel-0.14.0 → activemodel-0.15.0}/test/types/typeid_mixin_test.py +0 -5
  24. {activemodel-0.14.0 → activemodel-0.15.0}/test/types/typeid_sqlmodel_test.py +0 -2
  25. {activemodel-0.14.0 → activemodel-0.15.0}/test/utils.py +0 -1
  26. activemodel-0.15.0/uv.lock +1544 -0
  27. activemodel-0.14.0/Justfile +0 -14
  28. activemodel-0.14.0/uv.lock +0 -1362
  29. {activemodel-0.14.0 → activemodel-0.15.0}/.envrc +0 -0
  30. {activemodel-0.14.0 → activemodel-0.15.0}/.gitignore +0 -0
  31. {activemodel-0.14.0 → activemodel-0.15.0}/.tool-versions +0 -0
  32. {activemodel-0.14.0 → activemodel-0.15.0}/.vscode/settings.json +0 -0
  33. {activemodel-0.14.0 → activemodel-0.15.0}/LICENSE +0 -0
  34. {activemodel-0.14.0 → activemodel-0.15.0}/Makefile +0 -0
  35. {activemodel-0.14.0 → activemodel-0.15.0}/README.md +0 -0
  36. {activemodel-0.14.0 → activemodel-0.15.0}/TODO +0 -0
  37. {activemodel-0.14.0 → activemodel-0.15.0}/activemodel/__init__.py +0 -0
  38. {activemodel-0.14.0 → activemodel-0.15.0}/activemodel/celery.py +0 -0
  39. {activemodel-0.14.0 → activemodel-0.15.0}/activemodel/cli/__init__.py +0 -0
  40. {activemodel-0.14.0 → activemodel-0.15.0}/activemodel/errors.py +0 -0
  41. {activemodel-0.14.0 → activemodel-0.15.0}/activemodel/logger.py +0 -0
  42. {activemodel-0.14.0 → activemodel-0.15.0}/activemodel/mixins/__init__.py +0 -0
  43. {activemodel-0.14.0 → activemodel-0.15.0}/activemodel/mixins/pydantic_json.py +0 -0
  44. {activemodel-0.14.0 → activemodel-0.15.0}/activemodel/mixins/soft_delete.py +0 -0
  45. {activemodel-0.14.0 → activemodel-0.15.0}/activemodel/mixins/timestamps.py +0 -0
  46. {activemodel-0.14.0 → activemodel-0.15.0}/activemodel/mixins/typeid.py +0 -0
  47. {activemodel-0.14.0 → activemodel-0.15.0}/activemodel/pytest/__init__.py +0 -0
  48. {activemodel-0.14.0 → activemodel-0.15.0}/activemodel/query_wrapper.py +0 -0
  49. {activemodel-0.14.0 → activemodel-0.15.0}/activemodel/session_manager.py +0 -0
  50. {activemodel-0.14.0 → activemodel-0.15.0}/activemodel/types/__init__.py +0 -0
  51. {activemodel-0.14.0 → activemodel-0.15.0}/activemodel/types/typeid.py +0 -0
  52. {activemodel-0.14.0 → activemodel-0.15.0}/activemodel/utils.py +0 -0
  53. {activemodel-0.14.0 → activemodel-0.15.0}/playground/alternative_typeid_mixin.py +0 -0
  54. {activemodel-0.14.0 → activemodel-0.15.0}/playground/comments.py +0 -0
  55. {activemodel-0.14.0 → activemodel-0.15.0}/playground/env-with-model.patch +0 -0
  56. {activemodel-0.14.0 → activemodel-0.15.0}/playground/extract_comments.py +0 -0
  57. {activemodel-0.14.0 → activemodel-0.15.0}/playground/field.py +0 -0
  58. {activemodel-0.14.0 → activemodel-0.15.0}/playground/middleware.py +0 -0
  59. {activemodel-0.14.0 → activemodel-0.15.0}/playground/old_session_manager.py +0 -0
  60. {activemodel-0.14.0 → activemodel-0.15.0}/playground/pydantic_validation.py +0 -0
  61. {activemodel-0.14.0 → activemodel-0.15.0}/playground.py +0 -0
  62. {activemodel-0.14.0 → activemodel-0.15.0}/test/__init__.py +0 -0
  63. {activemodel-0.14.0 → activemodel-0.15.0}/test/comments_test.py +0 -0
  64. {activemodel-0.14.0 → activemodel-0.15.0}/test/conftest.py +0 -0
  65. {activemodel-0.14.0 → activemodel-0.15.0}/test/delete_test.py +0 -0
  66. {activemodel-0.14.0 → activemodel-0.15.0}/test/factory_test.py +0 -0
  67. {activemodel-0.14.0 → activemodel-0.15.0}/test/fastapi_test.py +0 -0
  68. {activemodel-0.14.0 → activemodel-0.15.0}/test/import_test.py +0 -0
  69. {activemodel-0.14.0 → activemodel-0.15.0}/test/lifecycle_test.py +0 -0
  70. {activemodel-0.14.0 → activemodel-0.15.0}/test/migrations/README +0 -0
  71. {activemodel-0.14.0 → activemodel-0.15.0}/test/migrations/alembic.ini +0 -0
  72. {activemodel-0.14.0 → activemodel-0.15.0}/test/migrations/env.py +0 -0
  73. {activemodel-0.14.0 → activemodel-0.15.0}/test/migrations/script.py.mako +0 -0
  74. {activemodel-0.14.0 → activemodel-0.15.0}/test/migrations_test.py +0 -0
  75. {activemodel-0.14.0 → activemodel-0.15.0}/test/mutation_test.py +0 -0
  76. {activemodel-0.14.0 → activemodel-0.15.0}/test/nested_pydantic_json_test.py +0 -0
  77. {activemodel-0.14.0 → activemodel-0.15.0}/test/session_manager_test.py +0 -0
  78. {activemodel-0.14.0 → activemodel-0.15.0}/test/table_name_test.py +0 -0
  79. {activemodel-0.14.0 → activemodel-0.15.0}/test/test_query_wrapper.py +0 -0
  80. {activemodel-0.14.0 → activemodel-0.15.0}/test/types/typeid_pydantic_test.py +0 -0
@@ -6,7 +6,7 @@ updates:
6
6
  schedule:
7
7
  interval: "weekly"
8
8
 
9
- - package-ecosystem: "pip"
9
+ - package-ecosystem: "uv"
10
10
  directory: "/"
11
11
  schedule:
12
- interval: "weekly"
12
+ interval: "weekly"
@@ -32,25 +32,39 @@ jobs:
32
32
 
33
33
  publish:
34
34
  runs-on: ubuntu-latest
35
- needs: [release-please]
35
+ needs: release-please
36
36
  if: needs.release-please.outputs.release_created
37
+ permissions:
38
+ id-token: write
39
+ contents: read
37
40
  steps:
38
- - uses: actions/checkout@v5
41
+ - uses: actions/checkout@v6
39
42
  - uses: jdx/mise-action@v3
40
- - run: direnv allow . && direnv export gha >> "$GITHUB_ENV"
43
+ - uses: iloveitaly/github-action-direnv-load-and-mask@master
44
+ - run: uv sync
41
45
  - run: uv build
42
- - run: uv publish --token ${{ secrets.PYPI_API_TOKEN }}
46
+ - run: uv publish
47
+
48
+ lint:
49
+ runs-on: ubuntu-latest
50
+ steps:
51
+ - uses: actions/checkout@v6
52
+ - uses: jdx/mise-action@v3
53
+ env:
54
+ MISE_ENV: ci
55
+ - uses: iloveitaly/github-action-direnv-load-and-mask@master
56
+ - run: uv sync
57
+ # - run: just lint
43
58
 
44
59
  matrix-test:
45
60
  strategy:
46
61
  matrix:
47
62
  os: [ubuntu-latest]
48
- # TODO test on macos-latest, does not have docker by default :/
49
63
  # unfortunately, some of the typing stuff we use requires new python versions
50
- python-version: ["3.13", "3.12"]
64
+ python-version: ["3.14", "3.13", "3.12"]
51
65
  runs-on: ${{ matrix.os }}
52
66
  steps:
53
- - uses: actions/checkout@v5
67
+ - uses: actions/checkout@v6
54
68
  - uses: jdx/mise-action@v3
55
69
  - run: mise use python@${{ matrix.python-version }}
56
70
  - run: docker compose up -d --wait
@@ -8,9 +8,7 @@ jobs:
8
8
  repo_sync:
9
9
  runs-on: ubuntu-latest
10
10
  steps:
11
- - name: Fetching Local Repository
12
- uses: actions/checkout@v5
13
- - name: Repository Metadata Sync
14
- uses: iloveitaly/github-actions-metadata-sync@main
11
+ - uses: actions/checkout@v6
12
+ - uses: iloveitaly/github-actions-metadata-sync@main
15
13
  with:
16
14
  TOKEN: ${{ secrets.GH_PERSONAL_TOKEN }}
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.15.0](https://github.com/iloveitaly/activemodel/compare/v0.14.1...v0.15.0) (2026-02-04)
4
+
5
+
6
+ ### Features
7
+
8
+ * sqlmodel 0.0.32 support ([#33](https://github.com/iloveitaly/activemodel/issues/33)) ([e2a104a](https://github.com/iloveitaly/activemodel/commit/e2a104a11cd1f530e52aa0d6b2e951752ef2bb92))
9
+
10
+ ## [0.14.1](https://github.com/iloveitaly/activemodel/compare/v0.14.0...v0.14.1) (2026-01-14)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * work with latest sqlmodel which drops v1 ([79920c6](https://github.com/iloveitaly/activemodel/commit/79920c63913b0a1d6824efc84719e6ea41b483eb))
16
+
17
+
18
+ ### Documentation
19
+
20
+ * add pydantic v2 compatibility notes to typeid_patch.py ([7d3f43a](https://github.com/iloveitaly/activemodel/commit/7d3f43a0b1a3c2c51d46ef0530aabee7392f75ec))
21
+ * add warning log for missing session in ActiveModelFactory ([317dd22](https://github.com/iloveitaly/activemodel/commit/317dd22b4abe5d9261d8c7f69a7b32b9c7b2d4de))
22
+
3
23
  ## [0.14.0](https://github.com/iloveitaly/activemodel/compare/v0.13.0...v0.14.0) (2025-10-16)
4
24
 
5
25
 
@@ -0,0 +1,105 @@
1
+ set unstable := true
2
+
3
+ [macos]
4
+ db_play:
5
+ uv tool run pgcli $DATABASE_URL
6
+
7
+ up:
8
+ docker compose up -d
9
+
10
+ # Run linting checks
11
+ [script]
12
+ lint FILES=".":
13
+ set +e
14
+ exit_code=0
15
+
16
+ if [ -n "${CI:-}" ]; then
17
+ # CI mode: GitHub-friendly output
18
+ uv run ruff check --output-format=github {{FILES}} || exit_code=$?
19
+ uv run ruff format --check {{FILES}} || exit_code=$?
20
+
21
+ uv run pyright {{FILES}} --outputjson > pyright_report.json || exit_code=$?
22
+ jq -r \
23
+ --arg root "$GITHUB_WORKSPACE/" \
24
+ '
25
+ .generalDiagnostics[] |
26
+ .file as $file |
27
+ ($file | sub("^\\Q\($root)\\E"; "")) as $rel_file |
28
+ "::\(.severity) file=\($rel_file),line=\(.range.start.line),endLine=\(.range.end.line),col=\(.range.start.character),endColumn=\(.range.end.character)::\($rel_file):\(.range.start.line): \(.message)"
29
+ ' < pyright_report.json
30
+ rm pyright_report.json
31
+ else
32
+ # Local mode: regular output
33
+ uv run ruff check {{FILES}} || exit_code=$?
34
+ uv run ruff format --check {{FILES}} || exit_code=$?
35
+ uv run pyright {{FILES}} || exit_code=$?
36
+ fi
37
+
38
+ if [ $exit_code -ne 0 ]; then
39
+ echo "One or more linting checks failed"
40
+ exit 1
41
+ fi
42
+
43
+ # Automatically fix linting errors
44
+ lint-fix:
45
+ uv run ruff check . --fix
46
+ uv run ruff format .
47
+
48
+ clean:
49
+ rm -rf *.egg-info .venv
50
+ find . -type d -name "__pycache__" -prune -exec rm -rf {} \; 2>/dev/null || true
51
+
52
+ # TODO what exactly was this used for?
53
+ gh_configure:
54
+ repo_path=$(gh repo view --json nameWithOwner --jq '.nameWithOwner') && \
55
+ gh api --method PUT "/repos/${repo_path}/actions/permissions/workflow" \
56
+ -f default_workflow_permissions=write \
57
+ -F can_approve_pull_request_reviews=true && \
58
+ gh api "/repos/${repo_path}/actions/permissions/workflow"
59
+
60
+ GITHUB_PROTECT_MASTER_RULESET := """
61
+ {
62
+ "name": "Protect master from force pushes",
63
+ "target": "branch",
64
+ "enforcement": "active",
65
+ "conditions": {
66
+ "ref_name": {
67
+ "include": ["refs/heads/master"],
68
+ "exclude": []
69
+ }
70
+ },
71
+ "rules": [
72
+ {
73
+ "type": "non_fast_forward"
74
+ }
75
+ ]
76
+ }
77
+ """
78
+
79
+ _github_repo:
80
+ gh repo view --json nameWithOwner -q .nameWithOwner
81
+
82
+ # TODO this only supports deleting the single ruleset specified above
83
+ github_ruleset_protect_master_delete:
84
+ repo=$(just _github_repo) && \
85
+ ruleset_name=$(echo '{{GITHUB_PROTECT_MASTER_RULESET}}' | jq -r .name) && \
86
+ ruleset_id=$(gh api repos/$repo/rulesets --jq ".[] | select(.name == \"$ruleset_name\") | .id") && \
87
+ (([ -n "${ruleset_id}" ] || (echo "No ruleset found" && exit 0)) || gh api --method DELETE repos/$repo/rulesets/$ruleset_id)
88
+
89
+ # adds github ruleset to prevent --force and other destructive actions on the github main branch
90
+ github_ruleset_protect_master_create: github_ruleset_protect_master_delete
91
+ gh api --method POST repos/$(just _github_repo)/rulesets --input - <<< '{{GITHUB_PROTECT_MASTER_RULESET}}'
92
+
93
+ INVESTIGATE_PATCHES_PROMPT := '''
94
+ Take a look at this patched function from an external package. The assertion that is in place is breaking because the upstream package was mutated.
95
+
96
+ I want you to investigate the changes in that function between The current version of that package that is used in this project and the most recent version. Take a look at our patch and:
97
+
98
+ 1. Check the latest version of the package in pypi and update the version reference in pyproject.toml and `uv sync`
99
+ 2. Update the patch if necessary to be compatible with the latest logic in the upstream package.
100
+ 3. Provide a summary of the changes made in the latest version of that function.
101
+ '''
102
+
103
+ # Output the investigate patches prompt
104
+ investigate-patches-prompt:
105
+ @echo '{{INVESTIGATE_PATCHES_PROMPT}}'
@@ -1,16 +1,16 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: activemodel
3
- Version: 0.14.0
3
+ Version: 0.15.0
4
4
  Summary: Make SQLModel more like an a real ORM
5
5
  Project-URL: Repository, https://github.com/iloveitaly/activemodel
6
6
  Author-email: Michael Bianco <iloveitaly@gmail.com>
7
7
  License-File: LICENSE
8
8
  Keywords: activemodel,activerecord,orm,sqlalchemy,sqlmodel
9
- Requires-Python: >=3.10
9
+ Requires-Python: >=3.12
10
10
  Requires-Dist: python-decouple-typed>=3.11.0
11
- Requires-Dist: sqlmodel>=0.0.22
11
+ Requires-Dist: sqlmodel==0.0.32
12
12
  Requires-Dist: textcase>=0.4.0
13
- Requires-Dist: typeid-python>=0.3.1
13
+ Requires-Dist: typeid-python==0.3.3
14
14
  Description-Content-Type: text/markdown
15
15
 
16
16
  # ActiveModel: ORM Wrapper for SQLModel
@@ -15,7 +15,7 @@ from sqlalchemy.orm import declared_attr
15
15
  from activemodel.mixins.pydantic_json import PydanticJSONMixin
16
16
 
17
17
  # NOTE: this patches a core method in sqlmodel to support db comments
18
- from . import get_column_from_field_patch # noqa: F401
18
+ from .patches import get_column_from_field_patch # noqa: F401
19
19
  from .query_wrapper import QueryWrapper
20
20
  from .session_manager import get_session
21
21
 
@@ -72,11 +72,9 @@ class BaseModel(SQLModel):
72
72
  def __init_subclass__(cls, **kwargs):
73
73
  super().__init_subclass__(**kwargs)
74
74
 
75
- from sqlmodel._compat import set_config_value
76
-
77
75
  # Enables field-level docstrings on the pydantic `description` field, which we
78
76
  # copy into table/column comments by patching SQLModel internals elsewhere.
79
- set_config_value(model=cls, parameter="use_attribute_docstrings", value=True)
77
+ cls.model_config["use_attribute_docstrings"] = True
80
78
 
81
79
  cls._apply_class_doc()
82
80
 
@@ -290,7 +288,7 @@ class BaseModel(SQLModel):
290
288
  # TODO where is this actually used? shoudl prob remove this
291
289
  # TODO should we even do this? Can we specify a better json rendering class?
292
290
  def json(self, **kwargs):
293
- return json.dumps(self.dict(), default=str, **kwargs)
291
+ return json.dumps(self.model_dump(), default=str, **kwargs)
294
292
 
295
293
  # TODO should move this to the wrapper
296
294
  @classmethod
@@ -325,7 +323,7 @@ class BaseModel(SQLModel):
325
323
  assert len(args) > 0, "Must pass at least one field name"
326
324
 
327
325
  for field_name in args:
328
- if field_name not in self.__fields__:
326
+ if field_name not in self.model_fields:
329
327
  raise ValueError(f"Field '{field_name}' does not exist in the model.")
330
328
 
331
329
  # check if the field exists
@@ -0,0 +1,2 @@
1
+ # NOTE: this patches a core method in sqlmodel to support db comments
2
+ from . import get_column_from_field_patch # noqa: F401
@@ -13,26 +13,23 @@ Some ideas for this originally sourced from: https://github.com/fastapi/sqlmodel
13
13
  from typing import (
14
14
  TYPE_CHECKING,
15
15
  Any,
16
- Dict,
17
16
  Sequence,
18
17
  cast,
19
18
  )
20
19
 
21
20
  import sqlmodel
22
- from pydantic.fields import FieldInfo as PydanticFieldInfo
23
21
  from sqlalchemy import (
24
22
  Column,
25
23
  ForeignKey,
26
24
  )
27
25
  from sqlmodel._compat import ( # type: ignore[attr-defined]
28
- IS_PYDANTIC_V2,
29
26
  ModelMetaclass,
30
27
  Representation,
31
28
  Undefined,
32
29
  UndefinedType,
33
30
  is_field_noneable,
34
31
  )
35
- from sqlmodel.main import FieldInfo, get_sqlalchemy_type
32
+ from sqlmodel.main import get_sqlalchemy_type, _get_sqlmodel_field_value
36
33
 
37
34
  from activemodel.utils import hash_function_code
38
35
 
@@ -43,67 +40,60 @@ if TYPE_CHECKING:
43
40
  from pydantic_core import PydanticUndefinedType as UndefinedType
44
41
 
45
42
 
43
+ # https://github.com/fastapi/sqlmodel/blob/5c2dbe419edc2d15200eee5269c9508987944ed8/sqlmodel/main.py#L691
46
44
  assert (
47
45
  hash_function_code(sqlmodel.main.get_column_from_field)
48
- == "398006ef8fd8da191ca1a271ef25b6e135da0f400a80df2f29526d8674f9ec51"
46
+ == "c64e50f8ca8a345ad2543690849a284d5436515835e41c56638cfaba251bc406"
47
+ ), (
48
+ f"get_column_from_field has changed, please verify the patch is still valid: {hash_function_code(sqlmodel.main.get_column_from_field)}"
49
49
  )
50
50
 
51
51
 
52
- def get_column_from_field(field: PydanticFieldInfo | FieldInfo) -> Column: # type: ignore
53
- """
54
- Takes a field definition, which can either come from the sqlmodel FieldInfo class or the pydantic variant of that class,
55
- and converts it into a sqlalchemy Column object.
56
- """
57
- if IS_PYDANTIC_V2:
58
- field_info = field
59
- else:
60
- field_info = field.field_info
61
-
62
- sa_column = getattr(field_info, "sa_column", Undefined)
52
+ def get_column_from_field(field: Any) -> Column: # type: ignore
53
+ field_info = field
54
+ sa_column = _get_sqlmodel_field_value(field_info, "sa_column", Undefined)
63
55
  if isinstance(sa_column, Column):
64
- # IMPORTANT: change from the original function
56
+ # <Change>
65
57
  if not sa_column.comment and (field_comment := field_info.description):
66
58
  sa_column.comment = field_comment
59
+ # </Change>
67
60
  return sa_column
68
-
69
- primary_key = getattr(field_info, "primary_key", Undefined)
61
+ sa_type = get_sqlalchemy_type(field)
62
+ primary_key = _get_sqlmodel_field_value(field_info, "primary_key", Undefined)
70
63
  if primary_key is Undefined:
71
64
  primary_key = False
72
-
73
- index = getattr(field_info, "index", Undefined)
65
+ index = _get_sqlmodel_field_value(field_info, "index", Undefined)
74
66
  if index is Undefined:
75
67
  index = False
76
-
77
68
  nullable = not primary_key and is_field_noneable(field)
78
69
  # Override derived nullability if the nullable property is set explicitly
79
70
  # on the field
80
- field_nullable = getattr(field_info, "nullable", Undefined) # noqa: B009
71
+ field_nullable = _get_sqlmodel_field_value(field_info, "nullable", Undefined) # noqa: B009
81
72
  if field_nullable is not Undefined:
82
73
  assert not isinstance(field_nullable, UndefinedType)
83
74
  nullable = field_nullable
84
75
  args = []
85
- foreign_key = getattr(field_info, "foreign_key", Undefined)
76
+ foreign_key = _get_sqlmodel_field_value(field_info, "foreign_key", Undefined)
86
77
  if foreign_key is Undefined:
87
78
  foreign_key = None
88
- unique = getattr(field_info, "unique", Undefined)
79
+ unique = _get_sqlmodel_field_value(field_info, "unique", Undefined)
89
80
  if unique is Undefined:
90
81
  unique = False
91
82
  if foreign_key:
92
- if field_info.ondelete == "SET NULL" and not nullable:
83
+ ondelete_value = _get_sqlmodel_field_value(field_info, "ondelete", Undefined)
84
+ if ondelete_value is Undefined:
85
+ ondelete_value = None
86
+ if ondelete_value == "SET NULL" and not nullable:
93
87
  raise RuntimeError('ondelete="SET NULL" requires nullable=True')
94
88
  assert isinstance(foreign_key, str)
95
- ondelete = getattr(field_info, "ondelete", Undefined)
96
- if ondelete is Undefined:
97
- ondelete = None
98
- assert isinstance(ondelete, (str, type(None))) # for typing
99
- args.append(ForeignKey(foreign_key, ondelete=ondelete))
89
+ assert isinstance(ondelete_value, (str, type(None))) # for typing
90
+ args.append(ForeignKey(foreign_key, ondelete=ondelete_value))
100
91
  kwargs = {
101
92
  "primary_key": primary_key,
102
93
  "nullable": nullable,
103
94
  "index": index,
104
95
  "unique": unique,
105
96
  }
106
-
107
97
  sa_default = Undefined
108
98
  if field_info.default_factory:
109
99
  sa_default = field_info.default_factory
@@ -111,14 +101,14 @@ def get_column_from_field(field: PydanticFieldInfo | FieldInfo) -> Column: # ty
111
101
  sa_default = field_info.default
112
102
  if sa_default is not Undefined:
113
103
  kwargs["default"] = sa_default
114
-
115
- sa_column_args = getattr(field_info, "sa_column_args", Undefined)
104
+ sa_column_args = _get_sqlmodel_field_value(field_info, "sa_column_args", Undefined)
116
105
  if sa_column_args is not Undefined:
117
106
  args.extend(list(cast(Sequence[Any], sa_column_args)))
107
+ sa_column_kwargs = _get_sqlmodel_field_value(
108
+ field_info, "sa_column_kwargs", Undefined
109
+ )
118
110
 
119
- sa_column_kwargs = getattr(field_info, "sa_column_kwargs", Undefined)
120
-
121
- # IMPORTANT: change from the original function
111
+ # <Change>
122
112
  if field_info.description:
123
113
  if sa_column_kwargs is Undefined:
124
114
  sa_column_kwargs = {}
@@ -128,11 +118,10 @@ def get_column_from_field(field: PydanticFieldInfo | FieldInfo) -> Column: # ty
128
118
  # only update comments if not already set
129
119
  if "comment" not in sa_column_kwargs:
130
120
  sa_column_kwargs["comment"] = field_info.description
121
+ # </Change>
131
122
 
132
123
  if sa_column_kwargs is not Undefined:
133
- kwargs.update(cast(Dict[Any, Any], sa_column_kwargs))
134
-
135
- sa_type = get_sqlalchemy_type(field)
124
+ kwargs.update(cast(dict[Any, Any], sa_column_kwargs))
136
125
  return Column(sa_type, *args, **kwargs) # type: ignore
137
126
 
138
127
 
@@ -12,6 +12,7 @@ from polyfactory.field_meta import FieldMeta
12
12
  from typeid import TypeID
13
13
 
14
14
  from activemodel.session_manager import global_session
15
+ from activemodel.logger import logger
15
16
 
16
17
  # TODO not currently used
17
18
  # def type_id_provider(cls, field_meta):
@@ -56,11 +57,20 @@ class ActiveModelFactory[T](SQLModelFactory[T]):
56
57
  @classmethod
57
58
  def save(cls, *args, **kwargs) -> T:
58
59
  """
60
+ Builds and persists a new model to the database.
61
+
59
62
  Where this gets tricky, is this can be called multiple times within the same callstack. This can happen when
60
- a factory uses other factories to create relationships.
63
+ a factory uses other factories to create relationships. This is fine if `__sqlalchemy_session__` is set, but
64
+ if it's not (in the case of a truncation DB strategy) you'll run into issues.
61
65
 
62
66
  In a truncation strategy, the __sqlalchemy_session__ is set to None.
63
67
  """
68
+
69
+ if cls.__sqlalchemy_session__ is None:
70
+ logger.warning(
71
+ "No __sqlalchemy_session__ set on factory class, nested factory save() will fail. Use `db_session` or `db_truncate_session` to avoid this."
72
+ )
73
+
64
74
  with global_session(cls.__sqlalchemy_session__):
65
75
  return cls.build(*args, **kwargs).save()
66
76
 
@@ -74,6 +84,7 @@ class ActiveModelFactory[T](SQLModelFactory[T]):
74
84
  # TODO right now assumes the model is typeid, maybe we should assert against this?
75
85
  primary_key_name = cls.__model__.primary_key_column().name
76
86
  return TypeID(
87
+ # gets the prefix associated with the pk field
77
88
  cls.__model__.model_fields[primary_key_name].sa_column.type.prefix
78
89
  )
79
90
 
@@ -2,8 +2,8 @@
2
2
 
3
3
  Currently provides:
4
4
 
5
- * ``db_session`` fixture quick access to a database session (see ``test_session``)
6
- * ``activemodel_preserve_tables`` ini option configure tables to preserve when using
5
+ * ``db_session`` fixture - quick access to a database session (see ``test_session``)
6
+ * ``activemodel_preserve_tables`` ini option - configure tables to preserve when using
7
7
  ``database_reset_truncate`` (comma separated list or multiple lines depending on config style)
8
8
 
9
9
  Configuration examples:
@@ -28,7 +28,10 @@ The list always implicitly includes ``alembic_version`` even if not specified.
28
28
  from activemodel.session_manager import global_session
29
29
  import pytest
30
30
 
31
- from .transaction import set_factory_session, set_polyfactory_session, test_session
31
+ from .transaction import (
32
+ set_factory_sessions,
33
+ test_session,
34
+ )
32
35
 
33
36
 
34
37
  def pytest_addoption(
@@ -53,7 +56,7 @@ def pytest_addoption(
53
56
  @pytest.fixture(scope="function")
54
57
  def db_session():
55
58
  """
56
- Helpful for tests that are more similar to unit tests. If you doing a routing or integration test, you
59
+ Helpful for tests that are similar to unit tests. If you doing a routing or integration test, you
57
60
  probably don't need this. If your unit test is simple (you are just creating a couple of models) you
58
61
  can most likely skip this.
59
62
 
@@ -71,11 +74,12 @@ def db_truncate_session():
71
74
  """
72
75
  Provides a database session for testing when using a truncation cleaning strategy.
73
76
 
74
- When not using a transaction cleaning strategy, no global test session is set
77
+ When using a truncation cleaning strategy, no global test session is set. This means all models that are created
78
+ are tied to a detached session, which makes it hard to mutate models after creation. This fixture fixes that problem
79
+ by setting the session used by all model factories to a global session.
75
80
  """
76
81
  with global_session() as session:
77
82
  # set global database sessions for model factories to avoid lazy loading issues
78
- set_factory_session(session)
79
- set_polyfactory_session(session)
83
+ set_factory_sessions(session)
80
84
 
81
85
  yield session
@@ -41,6 +41,13 @@ def set_polyfactory_session(session):
41
41
  ActiveModelFactory.__sqlalchemy_session__ = session
42
42
 
43
43
 
44
+ def set_factory_sessions(session):
45
+ "set all supported model factories to use the provided session"
46
+
47
+ set_factory_session(session)
48
+ set_polyfactory_session(session)
49
+
50
+
44
51
  @contextlib.contextmanager
45
52
  def test_session():
46
53
  """
@@ -1,7 +1,6 @@
1
1
  import os
2
2
  from typing import Iterable
3
3
 
4
- import pytest
5
4
  from sqlmodel import SQLModel
6
5
 
7
6
  from ..logger import logger
@@ -1,9 +1,7 @@
1
1
  # IMPORTANT: This file is auto-generated. Do not edit directly.
2
2
 
3
- from typing import Protocol, TypeVar, Any, Generic
3
+ from typing import Protocol
4
4
  import sqlmodel as sm
5
- from sqlalchemy.sql.base import _NoArg
6
- from typing import TYPE_CHECKING
7
5
 
8
6
 
9
7
  class SQLAlchemyQueryMethods[T: sm.SQLModel](Protocol):
@@ -1,6 +1,6 @@
1
1
  # IMPORTANT: This file is auto-generated. Do not edit directly.
2
2
 
3
- from typing import Protocol, TypeVar, Any, Generic
3
+ from typing import Protocol, Any
4
4
  import sqlmodel as sm
5
5
  from sqlalchemy.sql.base import _NoArg
6
6
 
@@ -1,3 +1,9 @@
1
+ """
2
+ Pydantic v2 support for TypeID.
3
+
4
+ TODO should push this upstream to the typeid package
5
+ """
6
+
1
7
  from typing import Any, Type
2
8
 
3
9
  from pydantic import GetCoreSchemaHandler
@@ -10,14 +10,14 @@ services:
10
10
  ports:
11
11
  - ${CI:+5432}:5432
12
12
  volumes:
13
- - package_postgres_data:/var/lib/postgresql/data/
13
+ - package_postgres_data:/var/lib/postgresql/
14
14
  healthcheck:
15
15
  # if you customize the default user at all, this healthcheck with clutter your logs
16
16
  # since the default username + database does not match up
17
17
  test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
18
18
  interval: 2s
19
19
  timeout: 5s
20
- retries: 3
20
+ retries: 5
21
21
 
22
22
  volumes:
23
23
  # NOTE redis data is not persisted, assumed to be ephemeral
@@ -1,14 +1,14 @@
1
1
  [project]
2
2
  name = "activemodel"
3
- version = "0.14.0"
3
+ version = "0.15.0"
4
4
  description = "Make SQLModel more like an a real ORM"
5
5
  readme = "README.md"
6
- requires-python = ">=3.10"
6
+ requires-python = ">=3.12"
7
7
  dependencies = [
8
8
  "python-decouple-typed>=3.11.0",
9
- "sqlmodel>=0.0.22",
9
+ "sqlmodel==0.0.32",
10
10
  "textcase>=0.4.0",
11
- "typeid-python>=0.3.1",
11
+ "typeid-python==0.3.3",
12
12
  ]
13
13
  authors = [{ name = "Michael Bianco", email = "iloveitaly@gmail.com" }]
14
14
  keywords = ["sqlmodel", "orm", "activerecord", "activemodel", "sqlalchemy"]
@@ -38,13 +38,15 @@ dev = [
38
38
  "pydantic>=2.10.0",
39
39
  "fastapi[standard]>=0.115.6",
40
40
  "alembic>=1.14.1",
41
- "pyright>=1.1.398",
41
+ "pyright[nodejs]>=1.1.398",
42
42
  "polyfactory>=2.22.1",
43
43
  ]
44
44
 
45
45
  [tool.ruff]
46
- extend-exclude = ["playground.py"]
47
- force-exclude = true
46
+ extend-exclude = ["playground.py", "playground/"]
47
+
48
+ [tool.pyright]
49
+ exclude = ["playground/", "tmp/", ".venv/", "tests/"]
48
50
 
49
51
  # https://github.com/astral-sh/uv/issues/9513
50
52
  [build-system]
@@ -4,7 +4,7 @@ Example models to test various ORM cases
4
4
 
5
5
  from pydantic import computed_field
6
6
  from sqlalchemy import UniqueConstraint
7
- from sqlmodel import Column, Field, Integer, Relationship
7
+ from sqlmodel import Field, Relationship
8
8
 
9
9
  from activemodel import BaseModel
10
10
  from activemodel.mixins import TypeIDMixin
@@ -1,9 +1,4 @@
1
- import pytest
2
- from sqlalchemy import UniqueConstraint
3
- from sqlmodel import Field
4
1
 
5
- from activemodel import BaseModel
6
- from activemodel.mixins.typeid import TypeIDMixin
7
2
  from test.models import UpsertTestModel
8
3
 
9
4
 
@@ -45,6 +40,7 @@ def test_upsert_single_unique_field(create_and_wipe_database):
45
40
 
46
41
  assert UpsertTestModel.count() == 1
47
42
  record = UpsertTestModel.get(name="test1")
43
+ assert record is not None
48
44
  # 1. Double-check that DB record matches what was returned
49
45
  assert record.id == updated_result.id
50
46
  assert record.category == "B"
@@ -68,6 +64,7 @@ def test_upsert_multiple_unique_fields(create_and_wipe_database):
68
64
 
69
65
  # 1. Check that returned model's ID matches the DB record
70
66
  db_record1 = UpsertTestModel.get(name="multi1", category="X")
67
+ assert db_record1 is not None
71
68
  assert db_record1.id == result1.id
72
69
 
73
70
  result2 = UpsertTestModel.upsert(
@@ -139,6 +136,7 @@ def test_upsert_single_update_field(create_and_wipe_database):
139
136
 
140
137
  # Get record to verify field was updated
141
138
  record = UpsertTestModel.get(name="update1")
139
+ assert record is not None
142
140
  # 1. Check that DB record matches what was returned
143
141
  assert record.id == updated_result.id
144
142
  assert record.value == 25 # Updated
@@ -178,6 +176,7 @@ def test_upsert_multiple_update_fields(create_and_wipe_database):
178
176
 
179
177
  # Get record to verify all fields were updated
180
178
  record = UpsertTestModel.get(name="update2")
179
+ assert record is not None
181
180
  # 1. Check that DB record matches what was returned
182
181
  assert record.id == updated_result.id
183
182
  assert record.value == 99