wexample-orm 1.4.4__tar.gz → 2.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.
Files changed (40) hide show
  1. {wexample_orm-1.4.4 → wexample_orm-2.0.0}/PKG-INFO +8 -7
  2. {wexample_orm-1.4.4 → wexample_orm-2.0.0}/README.md +5 -5
  3. {wexample_orm-1.4.4 → wexample_orm-2.0.0}/pyproject.toml +3 -2
  4. wexample_orm-2.0.0/src/wexample_orm/entity/abstract_entity.py +40 -0
  5. {wexample_orm-1.4.4 → wexample_orm-2.0.0}/src/wexample_orm/generator/entity_generator.py +3 -2
  6. {wexample_orm-1.4.4 → wexample_orm-2.0.0}/src/wexample_orm/helper/doctrine.py +2 -0
  7. {wexample_orm-1.4.4 → wexample_orm-2.0.0}/src/wexample_orm/import_export/entity_importer.py +4 -2
  8. {wexample_orm-1.4.4 → wexample_orm-2.0.0}/src/wexample_orm/repository/abstract_repository.py +6 -1
  9. {wexample_orm-1.4.4 → wexample_orm-2.0.0}/tests/test_abstract.py +19 -12
  10. {wexample_orm-1.4.4 → wexample_orm-2.0.0}/tests/test_entity_importer.py +9 -7
  11. wexample_orm-1.4.4/src/wexample_orm/entity/abstract_entity.py +0 -41
  12. {wexample_orm-1.4.4 → wexample_orm-2.0.0}/src/wexample_orm/__init__.py +0 -0
  13. {wexample_orm-1.4.4 → wexample_orm-2.0.0}/src/wexample_orm/common/__init__.py +0 -0
  14. {wexample_orm-1.4.4 → wexample_orm-2.0.0}/src/wexample_orm/common/abstract_repositories_manager.py +0 -0
  15. {wexample_orm-1.4.4 → wexample_orm-2.0.0}/src/wexample_orm/const/__init__.py +0 -0
  16. {wexample_orm-1.4.4 → wexample_orm-2.0.0}/src/wexample_orm/const/generator.py +0 -0
  17. {wexample_orm-1.4.4 → wexample_orm-2.0.0}/src/wexample_orm/const/import_export.py +0 -0
  18. {wexample_orm-1.4.4 → wexample_orm-2.0.0}/src/wexample_orm/entity/__init__.py +0 -0
  19. {wexample_orm-1.4.4 → wexample_orm-2.0.0}/src/wexample_orm/exception/__init__.py +0 -0
  20. {wexample_orm-1.4.4 → wexample_orm-2.0.0}/src/wexample_orm/exception/import_reference_not_found_exception.py +0 -0
  21. {wexample_orm-1.4.4 → wexample_orm-2.0.0}/src/wexample_orm/exception/import_unknown_entity_exception.py +0 -0
  22. {wexample_orm-1.4.4 → wexample_orm-2.0.0}/src/wexample_orm/exception/repository_session_missing_exception.py +0 -0
  23. {wexample_orm-1.4.4 → wexample_orm-2.0.0}/src/wexample_orm/exception/unknown_repository_exception.py +0 -0
  24. {wexample_orm-1.4.4 → wexample_orm-2.0.0}/src/wexample_orm/generator/__init__.py +0 -0
  25. {wexample_orm-1.4.4 → wexample_orm-2.0.0}/src/wexample_orm/generator/entity_schema.py +0 -0
  26. {wexample_orm-1.4.4 → wexample_orm-2.0.0}/src/wexample_orm/generator/repository_generator.py +0 -0
  27. {wexample_orm-1.4.4 → wexample_orm-2.0.0}/src/wexample_orm/helper/__init__.py +0 -0
  28. {wexample_orm-1.4.4 → wexample_orm-2.0.0}/src/wexample_orm/import_export/__init__.py +0 -0
  29. {wexample_orm-1.4.4 → wexample_orm-2.0.0}/src/wexample_orm/import_export/entity_exporter.py +0 -0
  30. {wexample_orm-1.4.4 → wexample_orm-2.0.0}/src/wexample_orm/import_export/import_references_bag.py +0 -0
  31. {wexample_orm-1.4.4 → wexample_orm-2.0.0}/src/wexample_orm/import_export/normalized_entity_hydrator.py +0 -0
  32. {wexample_orm-1.4.4 → wexample_orm-2.0.0}/src/wexample_orm/py.typed +0 -0
  33. {wexample_orm-1.4.4 → wexample_orm-2.0.0}/src/wexample_orm/repository/__init__.py +0 -0
  34. {wexample_orm-1.4.4 → wexample_orm-2.0.0}/src/wexample_orm/session/__init__.py +0 -0
  35. {wexample_orm-1.4.4 → wexample_orm-2.0.0}/src/wexample_orm/session/abstract_session_factory.py +0 -0
  36. {wexample_orm-1.4.4 → wexample_orm-2.0.0}/src/wexample_orm/testing/__init__.py +0 -0
  37. {wexample_orm-1.4.4 → wexample_orm-2.0.0}/src/wexample_orm/testing/sqlite.py +0 -0
  38. {wexample_orm-1.4.4 → wexample_orm-2.0.0}/tests/.gitkeep +0 -0
  39. {wexample_orm-1.4.4 → wexample_orm-2.0.0}/tests/__init__.py +0 -0
  40. {wexample_orm-1.4.4 → wexample_orm-2.0.0}/tests/test_entity_generator.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: wexample-orm
3
- Version: 1.4.4
3
+ Version: 2.0.0
4
4
  Summary: Provides SQLAlchemy ORM abstractions — base entity, repository, session factory, and repository manager — with PostgreSQL-first defaults via psycopg
5
5
  Author-Email: weeger <contact@wexample.com>
6
6
  License: MIT
@@ -12,7 +12,8 @@ Requires-Python: >=3.10
12
12
  Requires-Dist: attrs>=23.1.0
13
13
  Requires-Dist: psycopg[binary]>=3.2
14
14
  Requires-Dist: sqlalchemy<3,>=2
15
- Requires-Dist: wexample-helpers>=20.4.0
15
+ Requires-Dist: uuid-utils>=0.11
16
+ Requires-Dist: wexample-helpers>=20.5.0
16
17
  Provides-Extra: dev
17
18
  Requires-Dist: pytest; extra == "dev"
18
19
  Requires-Dist: pytest-cov; extra == "dev"
@@ -20,7 +21,7 @@ Description-Content-Type: text/markdown
20
21
 
21
22
  # orm
22
23
 
23
- Version: 1.4.4
24
+ Version: 2.0.0
24
25
 
25
26
  `wexample-orm` provides four SQLAlchemy base classes — `AbstractEntity`, `AbstractRepository`, `AbstractSessionFactory`, and `AbstractRepositoriesManager` — that impose a Doctrine-style repository pattern on top of SQLAlchemy 2. It targets Python developers in the Wexample stack who connect to PostgreSQL via psycopg and want a consistent, opinionated foundation without rewriting engine, session, and repository boilerplate for every project.
26
27
 
@@ -284,14 +285,13 @@ This package is part of the Wexample Suite — a collection of high-quality, mod
284
285
 
285
286
  The suite includes packages for configuration management, file handling, prompts, and more. Each package can be used independently or as part of the integrated suite.
286
287
 
287
- Visit the [Wexample Suite documentation](https://docs.wexample.com) for the complete package ecosystem.
288
-
289
288
  ## Dependencies
290
289
 
291
290
  - attrs: >=23.1.0
292
291
  - psycopg: >=3.2
293
292
  - sqlalchemy: <3,>=2
294
- - wexample-helpers: >=20.4.0
293
+ - uuid-utils: >=0.11
294
+ - wexample-helpers: >=20.5.0
295
295
 
296
296
  ## Versioning & Compatibility Policy
297
297
 
@@ -317,6 +317,8 @@ This packages suite embodies this spirit. Trusted by professionals and enthusias
317
317
 
318
318
  Wexample cultivates a culture of mastery. Each package, each contribution carries the mark of a community that values precision, ethics, and innovation — a community proud to shape the future of digital craftsmanship.
319
319
 
320
+ Built and maintained by [Romain Weeger](https://weeger.io).
321
+
320
322
  ## Known Limitations & Roadmap
321
323
 
322
324
  Current limitations and planned features are tracked in the GitHub issues.
@@ -336,7 +338,6 @@ See the [project roadmap](https://github.com/wexample/python-orm/issues) for upc
336
338
  ## Useful Links
337
339
 
338
340
  - **Homepage**: https://github.com/wexample/python-orm
339
- - **Documentation**: [docs.wexample.com](https://docs.wexample.com)
340
341
  - **Issue Tracker**: https://github.com/wexample/python-orm/issues
341
342
  - **Discussions**: https://github.com/wexample/python-orm/discussions
342
343
  - **PyPI**: [pypi.org/project/wexample-orm](https://pypi.org/project/wexample-orm/)
@@ -1,6 +1,6 @@
1
1
  # orm
2
2
 
3
- Version: 1.4.4
3
+ Version: 2.0.0
4
4
 
5
5
  `wexample-orm` provides four SQLAlchemy base classes — `AbstractEntity`, `AbstractRepository`, `AbstractSessionFactory`, and `AbstractRepositoriesManager` — that impose a Doctrine-style repository pattern on top of SQLAlchemy 2. It targets Python developers in the Wexample stack who connect to PostgreSQL via psycopg and want a consistent, opinionated foundation without rewriting engine, session, and repository boilerplate for every project.
6
6
 
@@ -264,14 +264,13 @@ This package is part of the Wexample Suite — a collection of high-quality, mod
264
264
 
265
265
  The suite includes packages for configuration management, file handling, prompts, and more. Each package can be used independently or as part of the integrated suite.
266
266
 
267
- Visit the [Wexample Suite documentation](https://docs.wexample.com) for the complete package ecosystem.
268
-
269
267
  ## Dependencies
270
268
 
271
269
  - attrs: >=23.1.0
272
270
  - psycopg: >=3.2
273
271
  - sqlalchemy: <3,>=2
274
- - wexample-helpers: >=20.4.0
272
+ - uuid-utils: >=0.11
273
+ - wexample-helpers: >=20.5.0
275
274
 
276
275
  ## Versioning & Compatibility Policy
277
276
 
@@ -297,6 +296,8 @@ This packages suite embodies this spirit. Trusted by professionals and enthusias
297
296
 
298
297
  Wexample cultivates a culture of mastery. Each package, each contribution carries the mark of a community that values precision, ethics, and innovation — a community proud to shape the future of digital craftsmanship.
299
298
 
299
+ Built and maintained by [Romain Weeger](https://weeger.io).
300
+
300
301
  ## Known Limitations & Roadmap
301
302
 
302
303
  Current limitations and planned features are tracked in the GitHub issues.
@@ -316,7 +317,6 @@ See the [project roadmap](https://github.com/wexample/python-orm/issues) for upc
316
317
  ## Useful Links
317
318
 
318
319
  - **Homepage**: https://github.com/wexample/python-orm
319
- - **Documentation**: [docs.wexample.com](https://docs.wexample.com)
320
320
  - **Issue Tracker**: https://github.com/wexample/python-orm/issues
321
321
  - **Discussions**: https://github.com/wexample/python-orm/discussions
322
322
  - **PyPI**: [pypi.org/project/wexample-orm](https://pypi.org/project/wexample-orm/)
@@ -6,7 +6,7 @@ build-backend = "pdm.backend"
6
6
 
7
7
  [project]
8
8
  name = "wexample-orm"
9
- version = "1.4.4"
9
+ version = "2.0.0"
10
10
  description = "Provides SQLAlchemy ORM abstractions — base entity, repository, session factory, and repository manager — with PostgreSQL-first defaults via psycopg"
11
11
  authors = [
12
12
  { name = "weeger", email = "contact@wexample.com" },
@@ -21,7 +21,8 @@ dependencies = [
21
21
  "attrs>=23.1.0",
22
22
  "psycopg[binary]>=3.2",
23
23
  "sqlalchemy>=2,<3",
24
- "wexample-helpers>=20.4.0",
24
+ "uuid-utils>=0.11",
25
+ "wexample-helpers>=20.5.0",
25
26
  ]
26
27
 
27
28
  [project.readme]
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ from uuid import UUID
4
+
5
+ from sqlalchemy import Uuid
6
+ from sqlalchemy.orm import Mapped, as_declarative, declared_attr, mapped_column
7
+ from wexample_helpers.helper.string import string_to_snake_case
8
+
9
+
10
+ @as_declarative()
11
+ class AbstractEntity:
12
+ """Common ancestor for every wex-managed SQLAlchemy entity.
13
+
14
+ Imposes a single convention for the primary key across all projects: a
15
+ UUIDv7, as the PHP side gives every entity (symfony-helpers
16
+ ``AbstractEntity``), so the same table is read and written from PHP and
17
+ Python alike.
18
+
19
+ The id is generated here, not by the database: an entity written from
20
+ Python has its id before the flush, and its timestamp prefix keeps the
21
+ creation order. ``Uuid`` is a native ``UUID`` on PostgreSQL and a
22
+ ``CHAR(32)`` on SQLite, so in-memory sessions used by
23
+ ``wexample_orm.testing.in_memory_session`` keep working as they are.
24
+ """
25
+
26
+ __abstract__ = True
27
+
28
+ @declared_attr
29
+ def __tablename__(cls) -> str:
30
+ return cls.get_entity_name()
31
+
32
+ @classmethod
33
+ def get_entity_name(cls) -> str:
34
+ return string_to_snake_case(cls.__name__)
35
+
36
+ @declared_attr
37
+ def id(cls) -> Mapped[UUID]:
38
+ from uuid_utils.compat import uuid7
39
+
40
+ return mapped_column(Uuid, primary_key=True, default=uuid7)
@@ -124,10 +124,11 @@ class EntityGenerator(BaseClass):
124
124
  self._attribute(
125
125
  foreign_key,
126
126
  "object",
127
- f"mapped_column(Integer, {constraint}nullable={nullable})",
127
+ f"mapped_column(Uuid, {constraint}nullable={nullable})",
128
128
  )
129
129
  )
130
- imports.add("Integer")
130
+ # Every id is a UUID (AbstractEntity), whatever entity it points to.
131
+ imports.add("Uuid")
131
132
  field_names.add(foreign_key)
132
133
 
133
134
  if target is not None:
@@ -21,6 +21,8 @@ def doctrine_type_to_sqlalchemy(orm: dict[str, Any]) -> tuple[str, set[str]]:
21
21
  the names it needs imported from `sqlalchemy`."""
22
22
  doctrine_type = str(orm.get("type") or "string").lower()
23
23
 
24
+ if doctrine_type == "uuid":
25
+ return "Uuid", {"Uuid"}
24
26
  if doctrine_type in {"integer", "int", "smallint", "bigint"}:
25
27
  return "Integer", {"Integer"}
26
28
  if doctrine_type in {"string", "guid"}:
@@ -35,7 +35,7 @@ class EntityImporter(BaseClass):
35
35
  """
36
36
 
37
37
  assign_ids: bool = public_field(
38
- description="Give every entity a sequential id, for graphs that never reach a database.",
38
+ description="Give every entity its UUID at once, for graphs that never reach a database.",
39
39
  default=False,
40
40
  )
41
41
  entity_types: list[type[AbstractEntity]] = public_field(
@@ -159,7 +159,9 @@ class EntityImporter(BaseClass):
159
159
  entity = entity_type()
160
160
  self._entities.append(entity)
161
161
  if self.assign_ids:
162
- entity.id = len(self._entities)
162
+ from uuid_utils.compat import uuid7
163
+
164
+ entity.id = uuid7()
163
165
  return entity
164
166
 
165
167
  def _find_column_attribute(self, mapper: Mapper, name: str) -> str | None:
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from typing import TYPE_CHECKING, Any
4
+ from uuid import UUID
4
5
 
5
6
  from wexample_helpers.classes.base_class import BaseClass
6
7
  from wexample_helpers.classes.field import public_field
@@ -23,9 +24,13 @@ class AbstractRepository(BaseClass):
23
24
  def get_entity_type(cls) -> type[AbstractEntity]:
24
25
  cls._raise_not_implemented_error()
25
26
 
26
- def find(self, entity_id: int) -> AbstractEntity | None:
27
+ def find(self, entity_id: UUID | str) -> AbstractEntity | None:
27
28
  if self.session is None:
28
29
  return None
30
+ # Ids arrive as text as often as not (message bodies, command lines,
31
+ # API payloads); the Uuid column only binds UUID objects.
32
+ if not isinstance(entity_id, UUID):
33
+ entity_id = UUID(str(entity_id))
29
34
  return self.session.get(self.get_entity_type(), entity_id)
30
35
 
31
36
  def find_one_by(self, filter_clause: Any) -> AbstractEntity | None:
@@ -1,7 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from uuid import UUID
4
+
3
5
  import pytest
4
- from sqlalchemy import BigInteger
6
+ from sqlalchemy import Uuid
5
7
  from sqlalchemy.orm import Mapped, mapped_column
6
8
 
7
9
  from wexample_orm.common.abstract_repositories_manager import (
@@ -28,18 +30,20 @@ def test_entity_name_is_snake_case_of_class_name() -> None:
28
30
  # ---------------------------------------------------------------------------
29
31
 
30
32
 
31
- def test_id_is_bigint_identity_style() -> None:
32
- """Every entity gets a BIGINT autoincrement PK with no Sequence.
33
-
34
- Production schema (PG) uses ``GENERATED BY DEFAULT AS IDENTITY`` — the
35
- DB owns id allocation, SQLAlchemy doesn't carry a Sequence default.
36
- """
33
+ def test_id_is_a_uuid_v7_given_by_python() -> None:
34
+ """Every entity gets a UUID primary key, generated on the Python side: the
35
+ same identity the PHP entities have, available before the flush."""
37
36
  id_col = User.__table__.c.id
38
- assert isinstance(id_col.type, BigInteger)
39
- assert id_col.default is None # no SQLAlchemy-managed Sequence
40
- assert id_col.autoincrement is True
37
+ assert isinstance(id_col.type, Uuid)
41
38
  assert id_col.primary_key is True
42
39
 
40
+ with in_memory_session(AbstractEntity) as session:
41
+ user = User(name="alice")
42
+ session.add(user)
43
+ session.flush()
44
+ assert isinstance(user.id, UUID)
45
+ assert user.id.version == 7
46
+
43
47
 
44
48
  def test_manager_caches_repository_instances() -> None:
45
49
  with in_memory_session(AbstractEntity) as session:
@@ -86,12 +90,15 @@ def test_repository_without_session_raises_on_query() -> None:
86
90
  def test_save_and_find_roundtrip() -> None:
87
91
  with in_memory_session(AbstractEntity) as session:
88
92
  repo = UserRepository(session=session)
89
- repo.save(User(name="alice"))
93
+ alice = User(name="alice")
94
+ repo.save(alice)
90
95
  repo.save(User(name="bob"))
91
96
 
92
- first = repo.find(1)
97
+ first = repo.find(alice.id)
93
98
  assert first is not None
94
99
  assert first.name == "alice"
100
+ # The text form, as ids come from messages and payloads.
101
+ assert repo.find(str(alice.id)) is first
95
102
 
96
103
 
97
104
  class User(AbstractEntity):
@@ -1,7 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from uuid import UUID
4
+
3
5
  import pytest
4
- from sqlalchemy import JSON, DateTime, ForeignKey, Integer, String
6
+ from sqlalchemy import JSON, DateTime, ForeignKey, String, Uuid
5
7
  from sqlalchemy.orm import Mapped, mapped_column, relationship
6
8
 
7
9
  from wexample_orm.entity.abstract_entity import AbstractEntity
@@ -157,9 +159,9 @@ class ImpFolder(AbstractEntity):
157
159
  "ImpFile", foreign_keys="ImpFile.folder_id", back_populates="folder"
158
160
  )
159
161
  owner = relationship("ImpOwner", foreign_keys="ImpFolder.owner_id")
160
- owner_id: Mapped[int] = mapped_column(Integer, ForeignKey("imp_owner.id"), nullable=True)
162
+ owner_id: Mapped[UUID] = mapped_column(Uuid, ForeignKey("imp_owner.id"), nullable=True)
161
163
  pinned = relationship("ImpFile", foreign_keys="ImpFolder.pinned_id")
162
- pinned_id: Mapped[int] = mapped_column(Integer, ForeignKey("imp_file.id"), nullable=True)
164
+ pinned_id: Mapped[UUID] = mapped_column(Uuid, ForeignKey("imp_file.id"), nullable=True)
163
165
  title: Mapped[str] = mapped_column(String(64), nullable=True)
164
166
 
165
167
 
@@ -169,9 +171,9 @@ class ImpFile(AbstractEntity):
169
171
  folder = relationship(
170
172
  "ImpFolder", foreign_keys="ImpFile.folder_id", back_populates="files"
171
173
  )
172
- folder_id: Mapped[int] = mapped_column(Integer, ForeignKey("imp_folder.id"), nullable=True)
174
+ folder_id: Mapped[UUID] = mapped_column(Uuid, ForeignKey("imp_folder.id"), nullable=True)
173
175
  name: Mapped[str] = mapped_column(String(64), nullable=True)
174
- target: Mapped[int] = mapped_column(Integer, nullable=True)
176
+ target: Mapped[UUID] = mapped_column(Uuid, nullable=True)
175
177
 
176
178
 
177
179
  class ImpTag(AbstractEntity):
@@ -180,9 +182,9 @@ class ImpTag(AbstractEntity):
180
182
 
181
183
  class ImpFolderTag(AbstractEntity):
182
184
  folder = relationship("ImpFolder", foreign_keys="ImpFolderTag.folder_id")
183
- folder_id: Mapped[int] = mapped_column(Integer, ForeignKey("imp_folder.id"), nullable=True)
185
+ folder_id: Mapped[UUID] = mapped_column(Uuid, ForeignKey("imp_folder.id"), nullable=True)
184
186
  tag = relationship("ImpTag", foreign_keys="ImpFolderTag.tag_id")
185
- tag_id: Mapped[int] = mapped_column(Integer, ForeignKey("imp_tag.id"), nullable=True)
187
+ tag_id: Mapped[UUID] = mapped_column(Uuid, ForeignKey("imp_tag.id"), nullable=True)
186
188
 
187
189
 
188
190
  ENTITY_TYPES = [ImpOwner, ImpFolder, ImpFile, ImpTag, ImpFolderTag]
@@ -1,41 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from sqlalchemy import BigInteger, Integer
4
- from sqlalchemy.orm import Mapped, as_declarative, declared_attr, mapped_column
5
- from wexample_helpers.helper.string import string_to_snake_case
6
-
7
-
8
- @as_declarative()
9
- class AbstractEntity:
10
- """Common ancestor for every wex-managed SQLAlchemy entity.
11
-
12
- Imposes a single convention for the primary key across all projects:
13
-
14
- ``id BIGINT GENERATED BY DEFAULT AS IDENTITY`` on PostgreSQL — modern
15
- SQL standard (PG ≥ 10) and Doctrine's default when it owns the
16
- schema, so the same table works seamlessly from PHP and Python.
17
-
18
- A ``with_variant(Integer, "sqlite")`` is bolted on for tests: SQLite
19
- only auto-increments a column declared as ``INTEGER PRIMARY KEY``
20
- (ROWID alias), not ``BIGINT``. Production stays BIGINT, in-memory
21
- sessions used by ``wexample_orm.testing.in_memory_session`` keep
22
- working without per-project gymnastics.
23
- """
24
-
25
- __abstract__ = True
26
-
27
- @declared_attr
28
- def __tablename__(cls) -> str:
29
- return cls.get_entity_name()
30
-
31
- @classmethod
32
- def get_entity_name(cls) -> str:
33
- return string_to_snake_case(cls.__name__)
34
-
35
- @declared_attr
36
- def id(cls) -> Mapped[int]:
37
- return mapped_column(
38
- BigInteger().with_variant(Integer(), "sqlite"),
39
- primary_key=True,
40
- autoincrement=True,
41
- )