sqlcrucible 0.4.0__tar.gz → 0.5.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 (129) hide show
  1. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/PKG-INFO +1 -1
  2. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/docs/guide/advanced.md +15 -7
  3. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/pyproject.toml +7 -2
  4. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/src/sqlcrucible/_version.py +2 -2
  5. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/src/sqlcrucible/entity/core.py +16 -9
  6. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/src/sqlcrucible/entity/descriptors.py +9 -23
  7. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/src/sqlcrucible/stubs/__init__.py +51 -23
  8. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/src/sqlcrucible/stubs/codegen.py +5 -2
  9. sqlcrucible-0.5.0/tests/entity/__init__.py +0 -0
  10. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/tests/entity/test_generic_sti.py +32 -1
  11. sqlcrucible-0.5.0/tests/stubs/test_stub_generation.py +52 -0
  12. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/uv.lock +29 -24
  13. sqlcrucible-0.4.0/tests/stubs/test_stub_generation.py +0 -44
  14. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/.github/workflows/ci.yml +0 -0
  15. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/.github/workflows/docs.yml +0 -0
  16. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/.gitignore +0 -0
  17. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/.python-version +0 -0
  18. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/LICENSE +0 -0
  19. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/README.md +0 -0
  20. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/docs/comparison.md +0 -0
  21. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/docs/getting-started.md +0 -0
  22. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/docs/guide/defining-entities.md +0 -0
  23. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/docs/guide/field-mapping.md +0 -0
  24. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/docs/guide/inheritance.md +0 -0
  25. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/docs/guide/orm-descriptors.md +0 -0
  26. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/docs/guide/relationships.md +0 -0
  27. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/docs/guide/type-conversion.md +0 -0
  28. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/docs/index.md +0 -0
  29. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/docs/reference/api.md +0 -0
  30. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/mkdocs.yml +0 -0
  31. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/noxfile.py +0 -0
  32. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/src/sqlcrucible/__init__.py +0 -0
  33. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/src/sqlcrucible/_types/__init__.py +0 -0
  34. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/src/sqlcrucible/_types/annotations.py +0 -0
  35. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/src/sqlcrucible/_types/forward_refs.py +0 -0
  36. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/src/sqlcrucible/_types/match.py +0 -0
  37. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/src/sqlcrucible/_types/params.py +0 -0
  38. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/src/sqlcrucible/_types/transformer.py +0 -0
  39. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/src/sqlcrucible/conversion/__init__.py +0 -0
  40. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/src/sqlcrucible/conversion/caching.py +0 -0
  41. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/src/sqlcrucible/conversion/context.py +0 -0
  42. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/src/sqlcrucible/conversion/dicts.py +0 -0
  43. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/src/sqlcrucible/conversion/exceptions.py +0 -0
  44. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/src/sqlcrucible/conversion/function.py +0 -0
  45. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/src/sqlcrucible/conversion/literals.py +0 -0
  46. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/src/sqlcrucible/conversion/noop.py +0 -0
  47. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/src/sqlcrucible/conversion/registry.py +0 -0
  48. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/src/sqlcrucible/conversion/sequences.py +0 -0
  49. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/src/sqlcrucible/conversion/unions.py +0 -0
  50. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/src/sqlcrucible/conversion/unwrap.py +0 -0
  51. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/src/sqlcrucible/entity/__init__.py +0 -0
  52. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/src/sqlcrucible/entity/annotations.py +0 -0
  53. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/src/sqlcrucible/entity/automodel.py +0 -0
  54. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/src/sqlcrucible/entity/column_projection.py +0 -0
  55. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/src/sqlcrucible/entity/field_definitions.py +0 -0
  56. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/src/sqlcrucible/entity/field_resolution.py +0 -0
  57. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/src/sqlcrucible/entity/sa_conversion.py +0 -0
  58. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/src/sqlcrucible/entity/sa_type.py +0 -0
  59. /sqlcrucible-0.4.0/tests/__init__.py → /sqlcrucible-0.5.0/src/sqlcrucible/py.typed +0 -0
  60. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/src/sqlcrucible/stubs/__main__.py +0 -0
  61. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/src/sqlcrucible/stubs/discovery.py +0 -0
  62. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/src/sqlcrucible/stubs/serialization.py +0 -0
  63. {sqlcrucible-0.4.0/tests/_types → sqlcrucible-0.5.0/tests}/__init__.py +0 -0
  64. {sqlcrucible-0.4.0/tests/conversion → sqlcrucible-0.5.0/tests/_types}/__init__.py +0 -0
  65. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/tests/_types/test_annotations.py +0 -0
  66. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/tests/_types/test_annotations_properties.py +0 -0
  67. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/tests/_types/test_forward_refs.py +0 -0
  68. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/tests/_types/test_params.py +0 -0
  69. {sqlcrucible-0.4.0/tests/entity → sqlcrucible-0.5.0/tests/conversion}/__init__.py +0 -0
  70. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/tests/conversion/conftest.py +0 -0
  71. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/tests/conversion/test_caching.py +0 -0
  72. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/tests/conversion/test_caching_properties.py +0 -0
  73. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/tests/conversion/test_dicts.py +0 -0
  74. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/tests/conversion/test_literals.py +0 -0
  75. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/tests/conversion/test_literals_properties.py +0 -0
  76. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/tests/conversion/test_noop.py +0 -0
  77. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/tests/conversion/test_registry.py +0 -0
  78. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/tests/conversion/test_sequences.py +0 -0
  79. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/tests/conversion/test_unions.py +0 -0
  80. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/tests/conversion/test_unwrap.py +0 -0
  81. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/tests/entity/composite/__init__.py +0 -0
  82. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/tests/entity/composite/conftest.py +0 -0
  83. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/tests/entity/composite/test_from_sa_model.py +0 -0
  84. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/tests/entity/composite/test_projections.py +0 -0
  85. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/tests/entity/composite/test_to_column_dict.py +0 -0
  86. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/tests/entity/composite/test_to_sa_model.py +0 -0
  87. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/tests/entity/generic_sti_models.py +0 -0
  88. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/tests/entity/orm_descriptors/__init__.py +0 -0
  89. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/tests/entity/orm_descriptors/conftest.py +0 -0
  90. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/tests/entity/orm_descriptors/test_association_proxy.py +0 -0
  91. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/tests/entity/orm_descriptors/test_hybrid_property.py +0 -0
  92. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/tests/entity/orm_descriptors/test_writable_descriptors.py +0 -0
  93. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/tests/entity/test_attrs_entity.py +0 -0
  94. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/tests/entity/test_concrete_table_inheritance.py +0 -0
  95. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/tests/entity/test_conversion_context.py +0 -0
  96. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/tests/entity/test_custom_sa_model.py +0 -0
  97. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/tests/entity/test_dataclass_entity.py +0 -0
  98. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/tests/entity/test_explicit_table.py +0 -0
  99. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/tests/entity/test_generic_entity.py +0 -0
  100. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/tests/entity/test_joined_table_inheritance.py +0 -0
  101. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/tests/entity/test_pydantic_entity.py +0 -0
  102. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/tests/entity/test_readonly_field_serialisation.py +0 -0
  103. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/tests/entity/test_relationships_annotated_metadata.py +0 -0
  104. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/tests/entity/test_relationships_back_populates.py +0 -0
  105. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/tests/entity/test_relationships_cycles.py +0 -0
  106. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/tests/entity/test_relationships_eager.py +0 -0
  107. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/tests/entity/test_relationships_many_to_many.py +0 -0
  108. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/tests/entity/test_relationships_one_to_many_child.py +0 -0
  109. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/tests/entity/test_relationships_one_to_many_parent.py +0 -0
  110. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/tests/entity/test_relationships_one_to_one.py +0 -0
  111. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/tests/entity/test_relationships_self_referential.py +0 -0
  112. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/tests/entity/test_sa_type.py +0 -0
  113. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/tests/entity/test_single_table_inheritance.py +0 -0
  114. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/tests/strategies.py +0 -0
  115. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/tests/stubs/__init__.py +0 -0
  116. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/tests/stubs/conftest.py +0 -0
  117. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/tests/stubs/sample_models.py +0 -0
  118. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/tests/stubs/test_build_import_block.py +0 -0
  119. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/tests/stubs/test_codegen.py +0 -0
  120. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/tests/stubs/test_construct_model_def.py +0 -0
  121. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/tests/stubs/test_discovery.py +0 -0
  122. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/tests/stubs/test_generate_model_defs.py +0 -0
  123. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/tests/stubs/test_sa_field_type.py +0 -0
  124. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/tests/stubs/test_serialization.py +0 -0
  125. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/tests/stubs/test_typecheck_columns.py +0 -0
  126. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/tests/stubs/test_typecheck_entity_preservation.py +0 -0
  127. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/tests/stubs/test_typecheck_excluded_fields.py +0 -0
  128. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/tests/stubs/test_typecheck_relationships.py +0 -0
  129. {sqlcrucible-0.4.0 → sqlcrucible-0.5.0}/tests/stubs/test_typecheck_sa_type.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlcrucible
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Define a single model that works as both Pydantic and SQLAlchemy, with explicit conversion between the two
5
5
  Project-URL: Homepage, https://sqlcrucible.rdrj.uk
6
6
  Project-URL: Issues, https://github.com/RichardDRJ/sqlcrucible/issues
@@ -105,8 +105,18 @@ python -m sqlcrucible.stubs myapp.models --output typings/
105
105
  !!! tip
106
106
  For projects with entities spread across many modules, create a single module that imports them all, then generate stubs from that.
107
107
 
108
+ The output directory contains a PEP 561 `sqlcrucible-stubs` partial stub package. Point your type checker at that directory (not at the package inside it); the checker discovers `sqlcrucible-stubs` and merges it with the installed `sqlcrucible`, overriding `SAType` and adding the generated model types while everything else falls through to the real package.
109
+
108
110
  ### Configuring Type Checkers
109
111
 
112
+ === "ty"
113
+
114
+ ```toml
115
+ # pyproject.toml
116
+ [tool.ty.environment]
117
+ extra-paths = ["stubs"]
118
+ ```
119
+
110
120
  === "Pyright"
111
121
 
112
122
  ```toml
@@ -123,13 +133,11 @@ python -m sqlcrucible.stubs myapp.models --output typings/
123
133
  mypy_path = "stubs"
124
134
  ```
125
135
 
126
- === "ty"
127
-
128
- ```toml
129
- # pyproject.toml
130
- [tool.ty.environment]
131
- extra-paths = ["stubs"]
132
- ```
136
+ !!! note
137
+ Mypy picks up the typed `sqlcrucible` package but does not currently
138
+ resolve the generated `SAType[Entity]` model types from the partial
139
+ stub package, so `SAType[...]` access falls back to `Any` under mypy.
140
+ Use ty or pyright for full `SAType` column typing.
133
141
 
134
142
  ### Keeping Stubs Updated
135
143
 
@@ -44,11 +44,11 @@ test = [
44
44
  "pytest-cov>=6.0.0",
45
45
  "hypothesis>=6.100.0",
46
46
  "pyright",
47
- "ty",
47
+ "ty>=0.0.41",
48
48
  ]
49
49
  typecheck = [
50
50
  "pyright",
51
- "ty",
51
+ "ty>=0.0.41",
52
52
  ]
53
53
  lint = [
54
54
  "ruff",
@@ -69,6 +69,11 @@ dev = [
69
69
  { include-group = "depcheck" },
70
70
  ]
71
71
 
72
+ [tool.pyright]
73
+ # Required for TypeForm support (PEP 747): without this, pyright rejects string
74
+ # forward references and union special forms passed to readonly_field().
75
+ enableExperimentalFeatures = true
76
+
72
77
  [tool.pytest.ini_options]
73
78
  addopts = "--doctest-modules"
74
79
  testpaths = ["src", "tests"]
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.4.0'
22
- __version_tuple__ = version_tuple = (0, 4, 0)
21
+ __version__ = version = '0.5.0'
22
+ __version_tuple__ = version_tuple = (0, 5, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -278,7 +278,11 @@ class SQLCrucibleEntity:
278
278
 
279
279
  This method converts a SQLAlchemy model instance into the corresponding
280
280
  entity class. For polymorphic models, it automatically selects the most
281
- specific entity subclass that matches the model type.
281
+ specific entity subclass that matches the model type — searching the
282
+ whole subclass subtree, not just ``cls``'s direct children, so that a
283
+ polymorphic query through a *generic* STI root reaches the named
284
+ concrete subclasses rather than only the transparent ``Foo[X]``
285
+ parameterisations that share the root's automodel.
282
286
 
283
287
  Args:
284
288
  sa_model: A SQLAlchemy model instance to convert.
@@ -302,15 +306,18 @@ class SQLCrucibleEntity:
302
306
  f"Hint: Make sure you're passing a SQLAlchemy model that was created from "
303
307
  f"this entity class or one of its subclasses."
304
308
  )
305
- best_subclasses = sorted(
306
- cls.__subclasses__(),
307
- key=lambda it: mro_distance(sa_model.__class__, it.__sqlalchemy_type__),
308
- )
309
- if best_subclasses:
310
- best_match = best_subclasses[0]
311
- else:
312
- best_match = cls
309
+ sa_class = sa_model.__class__
310
+
311
+ def subtree(entity: type[Any]) -> Iterator[type[Any]]:
312
+ yield entity
313
+ for subclass in entity.__subclasses__():
314
+ yield from subtree(subclass)
313
315
 
316
+ best_match = min(
317
+ (entity for entity in subtree(cls) if issubclass(sa_class, entity.__sqlalchemy_type__)),
318
+ key=lambda entity: mro_distance(sa_class, entity.__sqlalchemy_type__),
319
+ default=cls,
320
+ )
314
321
  return best_match._from_sa_model(sa_model)
315
322
 
316
323
  @classmethod
@@ -27,7 +27,7 @@ from sqlcrucible.entity.field_definitions import (
27
27
  canonicalise_typeform,
28
28
  SQLCrucibleField,
29
29
  )
30
- from typing_extensions import get_annotations, Format
30
+ from typing_extensions import get_annotations, Format, TypeForm
31
31
 
32
32
  if TYPE_CHECKING:
33
33
  from sqlcrucible.entity.core import SQLCrucibleEntity
@@ -221,40 +221,23 @@ class ReadonlyFieldDescriptor(property, Generic[_T, _O]):
221
221
 
222
222
 
223
223
  @overload
224
- def readonly_field(tp: type[_T]) -> _T: ...
224
+ def readonly_field(tp: TypeForm[_T]) -> _T: ...
225
225
 
226
226
 
227
227
  @overload
228
- def readonly_field(tp: type[_T], arg1: SQLAlchemyField | ORMDescriptor[Any], /) -> _T: ...
228
+ def readonly_field(tp: TypeForm[_T], arg1: SQLAlchemyField | ORMDescriptor[Any], /) -> _T: ...
229
229
 
230
230
 
231
231
  @overload
232
232
  def readonly_field(
233
- tp: type[_T],
233
+ tp: TypeForm[_T],
234
234
  arg1: SQLAlchemyField | ORMDescriptor[Any],
235
235
  arg2: SQLAlchemyField | ORMDescriptor[Any],
236
236
  /,
237
237
  ) -> _T: ...
238
238
 
239
239
 
240
- @overload
241
- def readonly_field(tp: str) -> Any: ...
242
-
243
-
244
- @overload
245
- def readonly_field(tp: str, arg1: SQLAlchemyField | ORMDescriptor[Any], /) -> Any: ...
246
-
247
-
248
- @overload
249
- def readonly_field(
250
- tp: str,
251
- arg1: SQLAlchemyField | ORMDescriptor[Any],
252
- arg2: SQLAlchemyField | ORMDescriptor[Any],
253
- /,
254
- ) -> Any: ...
255
-
256
-
257
- def readonly_field(tp: type[_T] | str, *args: SQLAlchemyField | ORMDescriptor[Any]) -> Any:
240
+ def readonly_field(tp: TypeForm[_T], *args: SQLAlchemyField | ORMDescriptor[Any]) -> Any:
258
241
  """Create a readonly field descriptor.
259
242
 
260
243
  Readonly fields are loaded from the SQLAlchemy model but cannot be set
@@ -262,7 +245,10 @@ def readonly_field(tp: type[_T] | str, *args: SQLAlchemyField | ORMDescriptor[An
262
245
  and association_proxy.
263
246
 
264
247
  Args:
265
- tp: The type of the field value
248
+ tp: The type of the field value. Any type form is accepted and its
249
+ value type is preserved in the return type: a bare type (``str``),
250
+ a parameterized generic (``list[str]``), a union (``int | None``),
251
+ or a string forward reference (``"list[str]"``).
266
252
  *args: Optional SQLAlchemyField and/or ORMDescriptor (e.g., hybrid_property,
267
253
  association_proxy) in any order. If both are provided, the descriptor
268
254
  is merged into the SQLAlchemyField. If neither is provided, the descriptor
@@ -39,21 +39,59 @@ def _group_by(iterable: Iterable[_T], key: Callable[[_T], _K]) -> dict[_K, list[
39
39
  return groups
40
40
 
41
41
 
42
+ _STUB_SUFFIX = "-stubs"
43
+
44
+
42
45
  def _stub_path(root: Path, module_name: str) -> Path:
43
- module_parts = module_name.split(".")
44
- return root.joinpath(
45
- *module_parts[:-1],
46
- f"{module_parts[-1]}.pyi",
47
- )
46
+ """Map a dotted module to its file inside the ``<pkg>-stubs`` package.
47
+
48
+ The generated stubs override and extend the ``sqlcrucible`` package, so they
49
+ are emitted as a PEP 561 ``sqlcrucible-stubs`` partial stub package rather
50
+ than a parallel ``sqlcrucible`` tree. Type checkers (ty, pyright) resolve a
51
+ ``<pkg>-stubs`` package independently of the installed package, which a
52
+ namespace tree in a separate search path no longer reliably does.
53
+ """
54
+ top, *parts = module_name.split(".")
55
+ parts = [f"{top}{_STUB_SUFFIX}", *parts]
56
+ return root.joinpath(*parts[:-1], f"{parts[-1]}.pyi")
57
+
48
58
 
59
+ def _real_package_init(module_name: str) -> str | None:
60
+ """Return the source of a real package's ``__init__`` module, if it exists.
49
61
 
50
- def _package_exists_in_source(package_name: str) -> bool:
51
- """Check if a package exists in the source (not just as a stub)."""
62
+ Used to mirror re-exports into the stub package's ``__init__.pyi`` so the
63
+ empty stub does not shadow the runtime package's public API.
64
+ """
52
65
  try:
53
- spec = importlib.util.find_spec(package_name)
54
- return spec is not None and spec.submodule_search_locations is not None
55
- except (ModuleNotFoundError, ValueError):
56
- return False
66
+ spec = importlib.util.find_spec(module_name)
67
+ except (ModuleNotFoundError, ValueError, ImportError):
68
+ return None
69
+ if spec is None or spec.submodule_search_locations is None or spec.origin is None:
70
+ return None
71
+ origin = Path(spec.origin)
72
+ return origin.read_text() if origin.name == "__init__.py" else None
73
+
74
+
75
+ def _finalize_stub_package(output_dir: Path) -> None:
76
+ """Mark each generated ``*-stubs`` tree as a PEP 561 partial stub package.
77
+
78
+ Type checkers only treat a ``<pkg>-stubs`` directory as a stub package when
79
+ every directory is an explicit package (has ``__init__.pyi``) and the
80
+ distribution is flagged partial (``py.typed`` containing ``partial``) so
81
+ modules absent from the stubs fall through to the runtime package. Each
82
+ ``__init__.pyi`` mirrors the corresponding real package's ``__init__`` (when
83
+ one exists) so the stub does not shadow the runtime package's exports.
84
+ """
85
+ for stub_package_dir in output_dir.glob(f"*{_STUB_SUFFIX}"):
86
+ (stub_package_dir / "py.typed").write_text("partial\n")
87
+ real_top = stub_package_dir.name[: -len(_STUB_SUFFIX)]
88
+ directories = [stub_package_dir, *(p for p in stub_package_dir.rglob("*") if p.is_dir())]
89
+ for directory in directories:
90
+ init_file = directory / "__init__.pyi"
91
+ if init_file.exists():
92
+ continue
93
+ module_name = ".".join((real_top, *directory.relative_to(stub_package_dir).parts))
94
+ init_file.write_text(_real_package_init(module_name) or "")
57
95
 
58
96
 
59
97
  def _write_to_stub_file(classdefs: list[ClassDef], stubs_root: Path, module_name: str):
@@ -65,18 +103,6 @@ def _write_to_stub_file(classdefs: list[ClassDef], stubs_root: Path, module_name
65
103
  stub_path = _stub_path(stubs_root, module_name)
66
104
  stub_path.parent.mkdir(parents=True, exist_ok=True)
67
105
 
68
- # Create __init__.pyi files in parent directories that don't exist in source.
69
- # For packages that exist in source, we use namespace packages (no __init__.pyi)
70
- # so type checkers can merge stubs with the real source.
71
- current = stubs_root
72
- package_path = ""
73
- for part in module_name.split(".")[:-1]:
74
- current = current / part
75
- package_path = f"{package_path}.{part}" if package_path else part
76
- init_file = current / "__init__.pyi"
77
- if not init_file.exists() and not _package_exists_in_source(package_path):
78
- init_file.touch()
79
-
80
106
  with open(stub_path, "w") as fd:
81
107
  fd.write(import_block)
82
108
  fd.write("\n\n")
@@ -146,3 +172,5 @@ def generate_stubs(
146
172
  _generate_automodel_stubs(all_with_bases, output_path)
147
173
 
148
174
  _generate_sa_type_stub(all_entities, output_path)
175
+
176
+ _finalize_stub_package(output_path)
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from collections.abc import Sequence
5
6
  from dataclasses import dataclass
6
7
  from typing import Any
7
8
 
@@ -90,7 +91,9 @@ def construct_model_def(sqlalchemy_type: type) -> ClassDef:
90
91
  )
91
92
 
92
93
 
93
- def specificity_order(entities: list[type[SQLCrucibleEntity]]) -> list[type[SQLCrucibleEntity]]:
94
+ def specificity_order(
95
+ entities: Sequence[type[SQLCrucibleEntity]],
96
+ ) -> list[type[SQLCrucibleEntity]]:
94
97
  """Sort entities so subclasses appear before parent classes.
95
98
 
96
99
  Type checkers match the first applicable overload, so more-specific
@@ -128,7 +131,7 @@ def _is_parameterised_generic(entity: type) -> bool:
128
131
  return origin is not None and origin is not entity
129
132
 
130
133
 
131
- def construct_sa_type_stub(entities: list[type[SQLCrucibleEntity]]) -> str:
134
+ def construct_sa_type_stub(entities: Sequence[type[SQLCrucibleEntity]]) -> str:
132
135
  """Construct a stub for SAType with @overload declarations.
133
136
 
134
137
  Generates overloads on SATypeMeta.__getitem__ that map each entity
File without changes
@@ -100,6 +100,37 @@ def test_polymorphic_query_through_root_dispatches_to_subclass(engine):
100
100
  assert isinstance(row, SAType[Single])
101
101
 
102
102
 
103
+ def test_from_sa_model_through_generic_root_yields_the_named_subclass(engine):
104
+ """A heterogeneous polymorphic query through the generic STI root, fed
105
+ back through ``Release.from_sa_model`` (not ``Single``/``Album``
106
+ directly), yields the named concrete subclass for each row — with its
107
+ ``details`` re-validated to that subclass's parameterised type. The
108
+ search has to walk past the transparent ``Release[SingleDetails]`` /
109
+ ``Release[AlbumDetails]`` parameterisations (which share the root's
110
+ automodel) to reach ``Single`` / ``Album``."""
111
+ with Session(engine) as session:
112
+ session.add(
113
+ Single(details=SingleDetails(a_side="Heroes", b_side="V-2 Schneider")).to_sa_model()
114
+ )
115
+ session.add(
116
+ Album(
117
+ details=AlbumDetails(track_titles=["Speed of Life", "Breaking Glass"])
118
+ ).to_sa_model()
119
+ )
120
+ session.commit()
121
+
122
+ rows = session.execute(select(SAType[Release])).scalars().all()
123
+
124
+ by_kind = {row.kind: Release.from_sa_model(row) for row in rows}
125
+
126
+ assert isinstance(by_kind["single"], Single)
127
+ assert by_kind["single"].details == SingleDetails(a_side="Heroes", b_side="V-2 Schneider")
128
+ assert isinstance(by_kind["album"], Album)
129
+ assert by_kind["album"].details == AlbumDetails(
130
+ track_titles=["Speed of Life", "Breaking Glass"]
131
+ )
132
+
133
+
103
134
  def test_stub_generation_is_well_formed(tmp_path: Path):
104
135
  """No bracket-named automodel class is created, so the generated
105
136
  stubs contain no class whose name has ``[`` ``]``; the parameterised
@@ -118,7 +149,7 @@ def test_stub_generation_is_well_formed(tmp_path: Path):
118
149
  assert "Release[" not in source, f"{pyi} references a parameterised generic"
119
150
  ast.parse(source, filename=str(pyi)) # SyntaxError on malformed output
120
151
 
121
- sa_type_pyi = (tmp_path / "sqlcrucible" / "entity" / "sa_type.pyi").read_text()
152
+ sa_type_pyi = (tmp_path / "sqlcrucible-stubs" / "entity" / "sa_type.pyi").read_text()
122
153
  # The concrete subclasses and the (abstract) origin do get overloads.
123
154
  assert "tests.entity.generic_sti_models.Single]" in sa_type_pyi
124
155
  assert "tests.entity.generic_sti_models.Album]" in sa_type_pyi
@@ -0,0 +1,52 @@
1
+ """Tests for stub file generation and error paths."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import tempfile
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+
10
+ from sqlcrucible.stubs import _finalize_stub_package, _stub_path, generate_stubs
11
+
12
+
13
+ @pytest.fixture
14
+ def stubs_root(tmp_path: Path) -> Path:
15
+ return tmp_path / "stubs"
16
+
17
+
18
+ @pytest.mark.parametrize(
19
+ ("module_name", "expected"),
20
+ [
21
+ ("sqlcrucible.entity.sa_type", "sqlcrucible-stubs/entity/sa_type.pyi"),
22
+ ("sqlcrucible.generated.myapp.models", "sqlcrucible-stubs/generated/myapp/models.pyi"),
23
+ ],
24
+ )
25
+ def test_stub_path_targets_stubs_package(stubs_root: Path, module_name: str, expected: str):
26
+ assert _stub_path(stubs_root, module_name) == stubs_root / expected
27
+
28
+
29
+ def test_finalize_flags_package_partial_and_marks_stub_only_dirs(stubs_root: Path):
30
+ generated = stubs_root / "sqlcrucible-stubs" / "generated"
31
+ generated.mkdir(parents=True)
32
+ (generated / "models.pyi").write_text("class Fake: ...")
33
+
34
+ _finalize_stub_package(stubs_root)
35
+
36
+ assert (stubs_root / "sqlcrucible-stubs" / "py.typed").read_text() == "partial\n"
37
+ assert (generated / "__init__.pyi").read_text() == ""
38
+
39
+
40
+ def test_finalize_mirrors_real_package_init_to_avoid_shadowing(stubs_root: Path):
41
+ (stubs_root / "sqlcrucible-stubs").mkdir(parents=True)
42
+
43
+ _finalize_stub_package(stubs_root)
44
+
45
+ init_pyi = (stubs_root / "sqlcrucible-stubs" / "__init__.pyi").read_text()
46
+ assert "SAType" in init_pyi
47
+
48
+
49
+ def test_generate_stubs_no_entities_raises():
50
+ with tempfile.TemporaryDirectory() as tmpdir:
51
+ with pytest.raises(ValueError, match="No SQLCrucibleEntity subclasses found"):
52
+ generate_stubs(["json"], output_dir=tmpdir)
@@ -343,6 +343,7 @@ wheels = [
343
343
  { url = "https://files.pythonhosted.org/packages/1f/cb/48e964c452ca2b92175a9b2dca037a553036cb053ba69e284650ce755f13/greenlet-3.3.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:e29f3018580e8412d6aaf5641bb7745d38c85228dacf51a73bd4e26ddf2a6a8e", size = 274908, upload-time = "2025-12-04T14:23:26.435Z" },
344
344
  { url = "https://files.pythonhosted.org/packages/28/da/38d7bff4d0277b594ec557f479d65272a893f1f2a716cad91efeb8680953/greenlet-3.3.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a687205fb22794e838f947e2194c0566d3812966b41c78709554aa883183fb62", size = 577113, upload-time = "2025-12-04T14:50:05.493Z" },
345
345
  { url = "https://files.pythonhosted.org/packages/3c/f2/89c5eb0faddc3ff014f1c04467d67dee0d1d334ab81fadbf3744847f8a8a/greenlet-3.3.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4243050a88ba61842186cb9e63c7dfa677ec146160b0efd73b855a3d9c7fcf32", size = 590338, upload-time = "2025-12-04T14:57:41.136Z" },
346
+ { url = "https://files.pythonhosted.org/packages/80/d7/db0a5085035d05134f8c089643da2b44cc9b80647c39e93129c5ef170d8f/greenlet-3.3.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:670d0f94cd302d81796e37299bcd04b95d62403883b24225c6b5271466612f45", size = 601098, upload-time = "2025-12-04T15:07:11.898Z" },
346
347
  { url = "https://files.pythonhosted.org/packages/dc/a6/e959a127b630a58e23529972dbc868c107f9d583b5a9f878fb858c46bc1a/greenlet-3.3.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cb3a8ec3db4a3b0eb8a3c25436c2d49e3505821802074969db017b87bc6a948", size = 590206, upload-time = "2025-12-04T14:26:01.254Z" },
347
348
  { url = "https://files.pythonhosted.org/packages/48/60/29035719feb91798693023608447283b266b12efc576ed013dd9442364bb/greenlet-3.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2de5a0b09eab81fc6a382791b995b1ccf2b172a9fec934747a7a23d2ff291794", size = 1550668, upload-time = "2025-12-04T15:04:22.439Z" },
348
349
  { url = "https://files.pythonhosted.org/packages/0a/5f/783a23754b691bfa86bd72c3033aa107490deac9b2ef190837b860996c9f/greenlet-3.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4449a736606bd30f27f8e1ff4678ee193bc47f6ca810d705981cfffd6ce0d8c5", size = 1615483, upload-time = "2025-12-04T14:27:28.083Z" },
@@ -350,6 +351,7 @@ wheels = [
350
351
  { url = "https://files.pythonhosted.org/packages/f8/0a/a3871375c7b9727edaeeea994bfff7c63ff7804c9829c19309ba2e058807/greenlet-3.3.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:b01548f6e0b9e9784a2c99c5651e5dc89ffcbe870bc5fb2e5ef864e9cc6b5dcb", size = 276379, upload-time = "2025-12-04T14:23:30.498Z" },
351
352
  { url = "https://files.pythonhosted.org/packages/43/ab/7ebfe34dce8b87be0d11dae91acbf76f7b8246bf9d6b319c741f99fa59c6/greenlet-3.3.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349345b770dc88f81506c6861d22a6ccd422207829d2c854ae2af8025af303e3", size = 597294, upload-time = "2025-12-04T14:50:06.847Z" },
352
353
  { url = "https://files.pythonhosted.org/packages/a4/39/f1c8da50024feecd0793dbd5e08f526809b8ab5609224a2da40aad3a7641/greenlet-3.3.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e8e18ed6995e9e2c0b4ed264d2cf89260ab3ac7e13555b8032b25a74c6d18655", size = 607742, upload-time = "2025-12-04T14:57:42.349Z" },
354
+ { url = "https://files.pythonhosted.org/packages/77/cb/43692bcd5f7a0da6ec0ec6d58ee7cddb606d055ce94a62ac9b1aa481e969/greenlet-3.3.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c024b1e5696626890038e34f76140ed1daf858e37496d33f2af57f06189e70d7", size = 622297, upload-time = "2025-12-04T15:07:13.552Z" },
353
355
  { url = "https://files.pythonhosted.org/packages/75/b0/6bde0b1011a60782108c01de5913c588cf51a839174538d266de15e4bf4d/greenlet-3.3.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:047ab3df20ede6a57c35c14bf5200fcf04039d50f908270d3f9a7a82064f543b", size = 609885, upload-time = "2025-12-04T14:26:02.368Z" },
354
356
  { url = "https://files.pythonhosted.org/packages/49/0e/49b46ac39f931f59f987b7cd9f34bfec8ef81d2a1e6e00682f55be5de9f4/greenlet-3.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2d9ad37fc657b1102ec880e637cccf20191581f75c64087a549e66c57e1ceb53", size = 1567424, upload-time = "2025-12-04T15:04:23.757Z" },
355
357
  { url = "https://files.pythonhosted.org/packages/05/f5/49a9ac2dff7f10091935def9165c90236d8f175afb27cbed38fb1d61ab6b/greenlet-3.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83cd0e36932e0e7f36a64b732a6f60c2fc2df28c351bae79fbaf4f8092fe7614", size = 1636017, upload-time = "2025-12-04T14:27:29.688Z" },
@@ -357,6 +359,7 @@ wheels = [
357
359
  { url = "https://files.pythonhosted.org/packages/02/2f/28592176381b9ab2cafa12829ba7b472d177f3acc35d8fbcf3673d966fff/greenlet-3.3.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:a1e41a81c7e2825822f4e068c48cb2196002362619e2d70b148f20a831c00739", size = 275140, upload-time = "2025-12-04T14:23:01.282Z" },
358
360
  { url = "https://files.pythonhosted.org/packages/2c/80/fbe937bf81e9fca98c981fe499e59a3f45df2a04da0baa5c2be0dca0d329/greenlet-3.3.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f515a47d02da4d30caaa85b69474cec77b7929b2e936ff7fb853d42f4bf8808", size = 599219, upload-time = "2025-12-04T14:50:08.309Z" },
359
361
  { url = "https://files.pythonhosted.org/packages/c2/ff/7c985128f0514271b8268476af89aee6866df5eec04ac17dcfbc676213df/greenlet-3.3.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d2d9fd66bfadf230b385fdc90426fcd6eb64db54b40c495b72ac0feb5766c54", size = 610211, upload-time = "2025-12-04T14:57:43.968Z" },
362
+ { url = "https://files.pythonhosted.org/packages/79/07/c47a82d881319ec18a4510bb30463ed6891f2ad2c1901ed5ec23d3de351f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30a6e28487a790417d036088b3bcb3f3ac7d8babaa7d0139edbaddebf3af9492", size = 624311, upload-time = "2025-12-04T15:07:14.697Z" },
360
363
  { url = "https://files.pythonhosted.org/packages/fd/8e/424b8c6e78bd9837d14ff7df01a9829fc883ba2ab4ea787d4f848435f23f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:087ea5e004437321508a8d6f20efc4cfec5e3c30118e1417ea96ed1d93950527", size = 612833, upload-time = "2025-12-04T14:26:03.669Z" },
361
364
  { url = "https://files.pythonhosted.org/packages/b5/ba/56699ff9b7c76ca12f1cdc27a886d0f81f2189c3455ff9f65246780f713d/greenlet-3.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab97cf74045343f6c60a39913fa59710e4bd26a536ce7ab2397adf8b27e67c39", size = 1567256, upload-time = "2025-12-04T15:04:25.276Z" },
362
365
  { url = "https://files.pythonhosted.org/packages/1e/37/f31136132967982d698c71a281a8901daf1a8fbab935dce7c0cf15f942cc/greenlet-3.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5375d2e23184629112ca1ea89a53389dddbffcf417dad40125713d88eb5f96e8", size = 1636483, upload-time = "2025-12-04T14:27:30.804Z" },
@@ -364,6 +367,7 @@ wheels = [
364
367
  { url = "https://files.pythonhosted.org/packages/d7/7c/f0a6d0ede2c7bf092d00bc83ad5bafb7e6ec9b4aab2fbdfa6f134dc73327/greenlet-3.3.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:60c2ef0f578afb3c8d92ea07ad327f9a062547137afe91f38408f08aacab667f", size = 275671, upload-time = "2025-12-04T14:23:05.267Z" },
365
368
  { url = "https://files.pythonhosted.org/packages/44/06/dac639ae1a50f5969d82d2e3dd9767d30d6dbdbab0e1a54010c8fe90263c/greenlet-3.3.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a5d554d0712ba1de0a6c94c640f7aeba3f85b3a6e1f2899c11c2c0428da9365", size = 646360, upload-time = "2025-12-04T14:50:10.026Z" },
366
369
  { url = "https://files.pythonhosted.org/packages/e0/94/0fb76fe6c5369fba9bf98529ada6f4c3a1adf19e406a47332245ef0eb357/greenlet-3.3.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3a898b1e9c5f7307ebbde4102908e6cbfcb9ea16284a3abe15cab996bee8b9b3", size = 658160, upload-time = "2025-12-04T14:57:45.41Z" },
370
+ { url = "https://files.pythonhosted.org/packages/93/79/d2c70cae6e823fac36c3bbc9077962105052b7ef81db2f01ec3b9bf17e2b/greenlet-3.3.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dcd2bdbd444ff340e8d6bdf54d2f206ccddbb3ccfdcd3c25bf4afaa7b8f0cf45", size = 671388, upload-time = "2025-12-04T15:07:15.789Z" },
367
371
  { url = "https://files.pythonhosted.org/packages/b8/14/bab308fc2c1b5228c3224ec2bf928ce2e4d21d8046c161e44a2012b5203e/greenlet-3.3.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5773edda4dc00e173820722711d043799d3adb4f01731f40619e07ea2750b955", size = 660166, upload-time = "2025-12-04T14:26:05.099Z" },
368
372
  { url = "https://files.pythonhosted.org/packages/4b/d2/91465d39164eaa0085177f61983d80ffe746c5a1860f009811d498e7259c/greenlet-3.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ac0549373982b36d5fd5d30beb8a7a33ee541ff98d2b502714a09f1169f31b55", size = 1615193, upload-time = "2025-12-04T15:04:27.041Z" },
369
373
  { url = "https://files.pythonhosted.org/packages/42/1b/83d110a37044b92423084d52d5d5a3b3a73cafb51b547e6d7366ff62eff1/greenlet-3.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d198d2d977460358c3b3a4dc844f875d1adb33817f0613f663a656f463764ccc", size = 1683653, upload-time = "2025-12-04T14:27:32.366Z" },
@@ -371,6 +375,7 @@ wheels = [
371
375
  { url = "https://files.pythonhosted.org/packages/a0/66/bd6317bc5932accf351fc19f177ffba53712a202f9df10587da8df257c7e/greenlet-3.3.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d6ed6f85fae6cdfdb9ce04c9bf7a08d666cfcfb914e7d006f44f840b46741931", size = 282638, upload-time = "2025-12-04T14:25:20.941Z" },
372
376
  { url = "https://files.pythonhosted.org/packages/30/cf/cc81cb030b40e738d6e69502ccbd0dd1bced0588e958f9e757945de24404/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9125050fcf24554e69c4cacb086b87b3b55dc395a8b3ebe6487b045b2614388", size = 651145, upload-time = "2025-12-04T14:50:11.039Z" },
373
377
  { url = "https://files.pythonhosted.org/packages/9c/ea/1020037b5ecfe95ca7df8d8549959baceb8186031da83d5ecceff8b08cd2/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:87e63ccfa13c0a0f6234ed0add552af24cc67dd886731f2261e46e241608bee3", size = 654236, upload-time = "2025-12-04T14:57:47.007Z" },
378
+ { url = "https://files.pythonhosted.org/packages/69/cc/1e4bae2e45ca2fa55299f4e85854606a78ecc37fead20d69322f96000504/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2662433acbca297c9153a4023fe2161c8dcfdcc91f10433171cf7e7d94ba2221", size = 662506, upload-time = "2025-12-04T15:07:16.906Z" },
374
379
  { url = "https://files.pythonhosted.org/packages/57/b9/f8025d71a6085c441a7eaff0fd928bbb275a6633773667023d19179fe815/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c6e9b9c1527a78520357de498b0e709fb9e2f49c3a513afd5a249007261911b", size = 653783, upload-time = "2025-12-04T14:26:06.225Z" },
375
380
  { url = "https://files.pythonhosted.org/packages/f6/c7/876a8c7a7485d5d6b5c6821201d542ef28be645aa024cfe1145b35c120c1/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:286d093f95ec98fdd92fcb955003b8a3d054b4e2cab3e2707a5039e7b50520fd", size = 1614857, upload-time = "2025-12-04T15:04:28.484Z" },
376
381
  { url = "https://files.pythonhosted.org/packages/4f/dc/041be1dff9f23dac5f48a43323cd0789cb798342011c19a248d9c9335536/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c10513330af5b8ae16f023e8ddbfb486ab355d04467c4679c5cfe4659975dd9", size = 1676034, upload-time = "2025-12-04T14:27:33.531Z" },
@@ -1200,7 +1205,7 @@ dev = [
1200
1205
  { name = "pytest", specifier = ">=8.0.0" },
1201
1206
  { name = "pytest-cov", specifier = ">=6.0.0" },
1202
1207
  { name = "ruff" },
1203
- { name = "ty" },
1208
+ { name = "ty", specifier = ">=0.0.41" },
1204
1209
  ]
1205
1210
  docs = [
1206
1211
  { name = "mike", specifier = ">=2.1" },
@@ -1213,11 +1218,11 @@ test = [
1213
1218
  { name = "pyright" },
1214
1219
  { name = "pytest", specifier = ">=8.0.0" },
1215
1220
  { name = "pytest-cov", specifier = ">=6.0.0" },
1216
- { name = "ty" },
1221
+ { name = "ty", specifier = ">=0.0.41" },
1217
1222
  ]
1218
1223
  typecheck = [
1219
1224
  { name = "pyright" },
1220
- { name = "ty" },
1225
+ { name = "ty", specifier = ">=0.0.41" },
1221
1226
  ]
1222
1227
 
1223
1228
  [[package]]
@@ -1276,27 +1281,27 @@ wheels = [
1276
1281
 
1277
1282
  [[package]]
1278
1283
  name = "ty"
1279
- version = "0.0.11"
1280
- source = { registry = "https://pypi.org/simple" }
1281
- sdist = { url = "https://files.pythonhosted.org/packages/bc/45/5ae578480168d4b3c08cf8e5eac3caf8eb7acdb1a06a9bed7519564bd9b4/ty-0.0.11.tar.gz", hash = "sha256:ebcbc7d646847cb6610de1da4ffc849d8b800e29fd1e9ebb81ba8f3fbac88c25", size = 4920340, upload-time = "2026-01-09T21:06:01.592Z" }
1282
- wheels = [
1283
- { url = "https://files.pythonhosted.org/packages/0f/34/b1d05cdcd01589a8d2e63011e0a1e24dcefdc2a09d024fee3e27755963f6/ty-0.0.11-py3-none-linux_armv6l.whl", hash = "sha256:68f0b8d07b0a2ea7ec63a08ba2624f853e4f9fa1a06fce47fb453fa279dead5a", size = 9521748, upload-time = "2026-01-09T21:06:13.221Z" },
1284
- { url = "https://files.pythonhosted.org/packages/43/21/f52d93f4b3784b91bfbcabd01b84dc82128f3a9de178536bcf82968f3367/ty-0.0.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:cbf82d7ef0618e9ae3cc3c37c33abcfa302c9b3e3b8ff11d71076f98481cb1a8", size = 9454903, upload-time = "2026-01-09T21:06:42.363Z" },
1285
- { url = "https://files.pythonhosted.org/packages/ad/01/3a563dba8b1255e474c35e1c3810b7589e81ae8c41df401b6a37c8e2cde9/ty-0.0.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:121987c906e02264c3b511b95cb9f8a3cdd66f3283b8bbab678ca3525652e304", size = 8823417, upload-time = "2026-01-09T21:06:26.315Z" },
1286
- { url = "https://files.pythonhosted.org/packages/6f/b1/99b87222c05d3a28fb7bbfb85df4efdde8cb6764a24c1b138f3a615283dd/ty-0.0.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:999390b6cc045fe5e1b3da1c2c9ae8e8c0def23b69455e7c9191ba9ffd747023", size = 9290785, upload-time = "2026-01-09T21:05:59.028Z" },
1287
- { url = "https://files.pythonhosted.org/packages/3d/9f/598809a8fff2194f907ba6de07ac3d7b7788342592d8f8b98b1b50c2fb49/ty-0.0.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed504d78eb613c49be3c848f236b345b6c13dc6bcfc4b202790a60a97e1d8f35", size = 9359392, upload-time = "2026-01-09T21:06:37.459Z" },
1288
- { url = "https://files.pythonhosted.org/packages/71/3e/aeea2a97b38f3dcd9f8224bf83609848efa4bc2f484085508165567daa7b/ty-0.0.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7fedc8b43cc8a9991e0034dd205f957a8380dd29bfce36f2a35b5d321636dfd9", size = 9852973, upload-time = "2026-01-09T21:06:21.245Z" },
1289
- { url = "https://files.pythonhosted.org/packages/72/40/86173116995e38f954811a86339ac4c00a2d8058cc245d3e4903bc4a132c/ty-0.0.11-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:0808bdfb7efe09881bf70249b85b0498fb8b75fbb036ce251c496c20adb10075", size = 10796113, upload-time = "2026-01-09T21:06:16.034Z" },
1290
- { url = "https://files.pythonhosted.org/packages/69/71/97c92c401dacae9baa3696163ebe8371635ebf34ba9fda781110d0124857/ty-0.0.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:07185b3e38b18c562056dfbc35fb51d866f872977ea1ebcd64ca24a001b5b4f1", size = 10432137, upload-time = "2026-01-09T21:06:07.498Z" },
1291
- { url = "https://files.pythonhosted.org/packages/18/10/9ab43f3cfc5f7792f6bc97620f54d0a0a81ef700be84ea7f6be330936a99/ty-0.0.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b5c72f1ada8eb5be984502a600f71d1a3099e12fb6f3c0607aaba2f86f0e9d80", size = 10240520, upload-time = "2026-01-09T21:06:34.823Z" },
1292
- { url = "https://files.pythonhosted.org/packages/74/18/8dd4fe6df1fd66f3e83b4798eddb1d8482d9d9b105f25099b76703402ebb/ty-0.0.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:25f88e8789072830348cb59b761d5ced70642ed5600673b4bf6a849af71eca8b", size = 9973340, upload-time = "2026-01-09T21:06:39.657Z" },
1293
- { url = "https://files.pythonhosted.org/packages/e4/0b/fb2301450cf8f2d7164944d6e1e659cac9ec7021556cc173d54947cf8ef4/ty-0.0.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f370e1047a62dcedcd06e2b27e1f0b16c7f8ea2361d9070fcbf0d0d69baaa192", size = 9262101, upload-time = "2026-01-09T21:06:28.989Z" },
1294
- { url = "https://files.pythonhosted.org/packages/f7/8c/d6374af023541072dee1c8bcfe8242669363a670b7619e6fffcc7415a995/ty-0.0.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:52be34047ed6177bfcef9247459a767ec03d775714855e262bca1fb015895e8a", size = 9382756, upload-time = "2026-01-09T21:06:24.097Z" },
1295
- { url = "https://files.pythonhosted.org/packages/0d/44/edd1e63ffa8d49d720c475c2c1c779084e5efe50493afdc261938705d10a/ty-0.0.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:b9e5762ccb3778779378020b8d78f936b3f52ea83f18785319cceba3ae85d8e6", size = 9553944, upload-time = "2026-01-09T21:06:18.426Z" },
1296
- { url = "https://files.pythonhosted.org/packages/35/cd/4afdb0d182d23d07ff287740c4954cc6dde5c3aed150ec3f2a1d72b00f71/ty-0.0.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e9334646ee3095e778e3dbc45fdb2bddfc16acc7804283830ad84991ece16dd7", size = 10060365, upload-time = "2026-01-09T21:06:45.083Z" },
1297
- { url = "https://files.pythonhosted.org/packages/d1/94/a009ad9d8b359933cfea8721c689c0331189be28650d74dcc6add4d5bb09/ty-0.0.11-py3-none-win32.whl", hash = "sha256:44cfb7bb2d6784bd7ffe7b5d9ea90851d9c4723729c50b5f0732d4b9a2013cfc", size = 9040448, upload-time = "2026-01-09T21:06:32.241Z" },
1298
- { url = "https://files.pythonhosted.org/packages/df/04/5a5dfd0aec0ea99ead1e824ee6e347fb623c464da7886aa1e3660fb0f36c/ty-0.0.11-py3-none-win_amd64.whl", hash = "sha256:1bb205db92715d4a13343bfd5b0c59ce8c0ca0daa34fb220ec9120fc66ccbda7", size = 9780112, upload-time = "2026-01-09T21:06:04.69Z" },
1299
- { url = "https://files.pythonhosted.org/packages/ad/07/47d4fccd7bcf5eea1c634d518d6cb233f535a85d0b63fcd66815759e2fa0/ty-0.0.11-py3-none-win_arm64.whl", hash = "sha256:4688bd87b2dc5c85da277bda78daba14af2e66f3dda4d98f3604e3de75519eba", size = 9194038, upload-time = "2026-01-09T21:06:10.152Z" },
1284
+ version = "0.0.49"
1285
+ source = { registry = "https://pypi.org/simple" }
1286
+ sdist = { url = "https://files.pythonhosted.org/packages/1d/8d/37cb91808069509d43a2a11743e12f1e854fd808dbef2203309d256718cd/ty-0.0.49.tar.gz", hash = "sha256:0a027bd0c9c75d035641a365d087ad883446057f9be0b9826251c2aecafbf145", size = 5884753, upload-time = "2026-06-12T03:08:20.221Z" }
1287
+ wheels = [
1288
+ { url = "https://files.pythonhosted.org/packages/ca/de/9237c6a96356612dd0393db1e94cf21f903616adf3a3701bf3da6e4adc92/ty-0.0.49-py3-none-linux_armv6l.whl", hash = "sha256:12c0c4310b936d762a8586c210b53d4fa4bb361a04429afa89bf84b922e5e065", size = 11834671, upload-time = "2026-06-12T03:07:53.062Z" },
1289
+ { url = "https://files.pythonhosted.org/packages/8f/15/daf5a14a5e07012277d450c75325c94614e2acfec4c620c881486118c410/ty-0.0.49-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:737bfdc2caf9712a8580944dcdc80a450a37a4f2bc83c8fa9b7433b374f9e471", size = 11589570, upload-time = "2026-06-12T03:08:25.779Z" },
1290
+ { url = "https://files.pythonhosted.org/packages/7d/58/30bdf98436488aca25f0763bf7f92a061528d42461b686453029e845e4c5/ty-0.0.49-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ab90c1baf3b1701d282fce4b02fa552a962d109f8972c46ef6b22429503bfea4", size = 10985236, upload-time = "2026-06-12T03:08:36.664Z" },
1291
+ { url = "https://files.pythonhosted.org/packages/22/45/ece503e4a1396e13a1a9a0cde51afe476a6506a1d557eeadf8ad45c83bc0/ty-0.0.49-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b4ce8ecf6ba6fc79bd137cc0557a754f7e5f2dfe9436412551d480d680e248ad", size = 11504302, upload-time = "2026-06-12T03:08:01.664Z" },
1292
+ { url = "https://files.pythonhosted.org/packages/17/dc/5d09333d289dfbca1804eaade125c9e8a1a992a2a592a8b80c5e9b589ca9/ty-0.0.49-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:10d85c6865c984e78661e0bd20b180514b4a289739224e84816e342bdf381e04", size = 11626629, upload-time = "2026-06-12T03:08:06.844Z" },
1293
+ { url = "https://files.pythonhosted.org/packages/f2/36/155f41c9dd7237c4b609211f29f77755a139ee6218605dadc7fe21d5e3c8/ty-0.0.49-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d96a67a206619e01fa92f35a22267ec634bba62be24b1d0e947020cc179995b", size = 12074481, upload-time = "2026-06-12T03:08:09.643Z" },
1294
+ { url = "https://files.pythonhosted.org/packages/96/4c/998ee13cd5045f1f8b36982de7343163832ac53f27debe01b0de0e8bd968/ty-0.0.49-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3de9f648564e0a66344ef397770387cb0d093735f8679d2c5a08a4741e79814d", size = 12678042, upload-time = "2026-06-12T03:08:39.319Z" },
1295
+ { url = "https://files.pythonhosted.org/packages/85/c9/9a505aba85c41ce54cbcaa14f8d79aa084b86151d2d70df11c4655b92898/ty-0.0.49-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5779179ab397d15f8c9dbb8f506ec1b1745f54eac639982f76ef3ce538943b50", size = 12316194, upload-time = "2026-06-12T03:08:18.023Z" },
1296
+ { url = "https://files.pythonhosted.org/packages/c9/b8/ded37fb93503294abbc83c36470bb1413bea05048b745881d4470b518a06/ty-0.0.49-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:792d4974e93cc09bd32f934586080bbbe21b8e777099cb521cb2de18b68a49f0", size = 12145507, upload-time = "2026-06-12T03:07:56.505Z" },
1297
+ { url = "https://files.pythonhosted.org/packages/2f/07/392e80d78f02445f695b815bb9eb0fffacda68b03faee38c900f7b990815/ty-0.0.49-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:727bda86deb136073e525c2e78d60e38aedcce5d80579170844a52bbf7c1440d", size = 12365967, upload-time = "2026-06-12T03:08:12.553Z" },
1298
+ { url = "https://files.pythonhosted.org/packages/50/d3/31b0c2a7fbedd3373e389cb1d81b8d2128f6f868fafb46557736a6f9aca8/ty-0.0.49-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4f2fc2bc4a8d2ff1cca59fd94772cabdfec4062d47a0b3a0784be46d94d0540b", size = 11475283, upload-time = "2026-06-12T03:08:28.334Z" },
1299
+ { url = "https://files.pythonhosted.org/packages/5a/5b/329e101638920b468a3bb63059c9f66ef99b44aac501222c44832a507321/ty-0.0.49-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:3724bd9badef333321578b6a941fbc571ebf49141ec2356a8590fbe4c9aa588d", size = 11645343, upload-time = "2026-06-12T03:08:15.246Z" },
1300
+ { url = "https://files.pythonhosted.org/packages/a9/76/c897e615e32f80ca81c8c1bc49b9a1f72ff9e3cfea0f8345ba505fe28472/ty-0.0.49-py3-none-musllinux_1_2_i686.whl", hash = "sha256:166c6eb52ee4af3c5a9bb267d165d93000daa55c6758cd8ff3199741fb75917d", size = 11725585, upload-time = "2026-06-12T03:08:33.915Z" },
1301
+ { url = "https://files.pythonhosted.org/packages/59/e1/fdb42ee239f618800842681af5bb8598117e74512c10974a8b7b9086a898/ty-0.0.49-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:91e81d832c287b05782ee32eb1b801f62c1fa08df37d589d2b88c3f1d51c9731", size = 12237261, upload-time = "2026-06-12T03:08:31.105Z" },
1302
+ { url = "https://files.pythonhosted.org/packages/98/0f/a2d6a5fc9d0786cbeb3c200786da4e18c203589be3984bb5def83ca92320/ty-0.0.49-py3-none-win32.whl", hash = "sha256:7186af5ca9829d1f5d8916bcf767b8e819bfbf61b1b8ec843bb3fc699cb502e1", size = 11100789, upload-time = "2026-06-12T03:07:59.092Z" },
1303
+ { url = "https://files.pythonhosted.org/packages/d0/9d/473ac8bc57b5a2d121da893bf9dd74a118efb19a01d711df1a6e397f05cc/ty-0.0.49-py3-none-win_amd64.whl", hash = "sha256:ae2142fc126a01effcca0c222908b0e6654b5ba1266d4e4d406e4866aef8e1d1", size = 12204644, upload-time = "2026-06-12T03:08:04.327Z" },
1304
+ { url = "https://files.pythonhosted.org/packages/ef/a2/8959249da951ba3977fee20e688d28678b8a1d30a9ed4464228a85d45853/ty-0.0.49-py3-none-win_arm64.whl", hash = "sha256:75d5e2e7649765f31f4bed6c8adb149a75b18edd3fa6336dac4d0efc1a66466f", size = 11558965, upload-time = "2026-06-12T03:08:23.012Z" },
1300
1305
  ]
1301
1306
 
1302
1307
  [[package]]
@@ -1,44 +0,0 @@
1
- """Tests for stub file generation and error paths."""
2
-
3
- from __future__ import annotations
4
-
5
- import tempfile
6
- from pathlib import Path
7
-
8
- import pytest
9
-
10
- from sqlcrucible.stubs import _write_to_stub_file, generate_stubs
11
- from sqlcrucible.stubs.codegen import ClassDef
12
-
13
-
14
- def _stub_classdef(module: str) -> ClassDef:
15
- return ClassDef(source=object, module=module, imports=[], class_def="class Fake: pass")
16
-
17
-
18
- @pytest.fixture
19
- def stubs_root(tmp_path: Path) -> Path:
20
- return tmp_path / "stubs"
21
-
22
-
23
- def test_write_stub_creates_init_pyi_for_nonexistent_package(stubs_root: Path):
24
- module_name = "nonexistent.fake.module"
25
- _write_to_stub_file([_stub_classdef(module_name)], stubs_root, module_name)
26
-
27
- for package in ("nonexistent", "nonexistent/fake"):
28
- init_pyi = stubs_root / package / "__init__.pyi"
29
- assert init_pyi.exists(), f"Expected {init_pyi} to exist for non-source package"
30
-
31
-
32
- def test_write_stub_skips_init_pyi_for_source_package(stubs_root: Path):
33
- module_name = "sqlcrucible.stubs.fakefile"
34
- _write_to_stub_file([_stub_classdef(module_name)], stubs_root, module_name)
35
-
36
- for package in ("sqlcrucible", "sqlcrucible/stubs"):
37
- init_pyi = stubs_root / package / "__init__.pyi"
38
- assert not init_pyi.exists(), f"Expected {init_pyi} to NOT exist for source package"
39
-
40
-
41
- def test_generate_stubs_no_entities_raises():
42
- with tempfile.TemporaryDirectory() as tmpdir:
43
- with pytest.raises(ValueError, match="No SQLCrucibleEntity subclasses found"):
44
- generate_stubs(["json"], output_dir=tmpdir)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes