python-general-be-lib 0.11.1__tar.gz → 0.11.3__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 (82) hide show
  1. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/PKG-INFO +2 -1
  2. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/general/exception/crud_exceptions.py +2 -1
  3. python_general_be_lib-0.11.3/general/interface/base/__init__.py +59 -0
  4. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/general/interface/base/declarative_base.py +9 -1
  5. python_general_be_lib-0.11.3/general/interface/metadata/__init__.py +58 -0
  6. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/general/interface/metadata/crud_metadata.py +2 -2
  7. python_general_be_lib-0.11.3/general/interface/repository/__init__.py +59 -0
  8. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/general/interface/repository/crud_repository.py +10 -3
  9. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/general/interface/repository/handler/handler.py +0 -19
  10. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/general/interface/repository/handler/statement_manager.py +36 -4
  11. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/general/logger.py +11 -3
  12. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/pyproject.toml +2 -1
  13. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/python_general_be_lib.egg-info/PKG-INFO +2 -1
  14. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/python_general_be_lib.egg-info/SOURCES.txt +1 -0
  15. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/python_general_be_lib.egg-info/requires.txt +1 -0
  16. python_general_be_lib-0.11.3/tests/test_costo_import.py +155 -0
  17. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/tests/test_find_one.py +8 -0
  18. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/tests/test_get.py +8 -0
  19. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/tests/test_import_pubblici.py +64 -0
  20. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/tests/test_logger.py +81 -23
  21. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/tests/test_metadata_scorciatoie.py +6 -2
  22. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/tests/test_pipeline_handler.py +50 -0
  23. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/tests/test_to_pydantic.py +25 -0
  24. python_general_be_lib-0.11.1/general/interface/base/__init__.py +0 -12
  25. python_general_be_lib-0.11.1/general/interface/metadata/__init__.py +0 -12
  26. python_general_be_lib-0.11.1/general/interface/repository/__init__.py +0 -13
  27. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/LICENSE +0 -0
  28. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/README.md +0 -0
  29. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/general/__init__.py +0 -0
  30. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/general/context.py +0 -0
  31. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/general/exception/__init__.py +0 -0
  32. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/general/exception/access_exceptions.py +0 -0
  33. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/general/exception/exception_interface.py +0 -0
  34. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/general/interface/__init__.py +0 -0
  35. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/general/interface/base/base_model.py +0 -0
  36. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/general/interface/metadata/geom_metadata.py +0 -0
  37. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/general/interface/mixin/__init__.py +0 -0
  38. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/general/interface/mixin/audit_mixin.py +0 -0
  39. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/general/interface/mixin/softdelete_mixin.py +0 -0
  40. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/general/interface/mixin/timestamp_mixin.py +0 -0
  41. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/general/interface/repository/geometry_repository.py +0 -0
  42. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/general/interface/repository/handler/__init__.py +0 -0
  43. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/general/interface/repository/handler/base_handler.py +0 -0
  44. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/general/interface/repository/handler/ilike_handler.py +0 -0
  45. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/general/interface/repository/handler/interval_handler.py +0 -0
  46. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/general/interface/repository/handler/json_handler.py +0 -0
  47. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/general/interface/repository/handler/json_ilike_handler.py +0 -0
  48. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/general/interface/repository/handler/or_handler.py +0 -0
  49. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/general/interface/repository/handler/soft_delete_handler.py +0 -0
  50. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/general/interface/repository/many_to_many_repository.py +0 -0
  51. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/general/interface/repository/view_repository.py +0 -0
  52. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/general/paginator_dto.py +0 -0
  53. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/general/py.typed +0 -0
  54. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/general/utils/__init__.py +0 -0
  55. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/general/utils/consts.py +0 -0
  56. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/python_general_be_lib.egg-info/dependency_links.txt +0 -0
  57. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/python_general_be_lib.egg-info/top_level.txt +0 -0
  58. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/setup.cfg +0 -0
  59. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/tests/test_caricamento_parziale.py +0 -0
  60. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/tests/test_compiled_cache.py +0 -0
  61. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/tests/test_engine_defaults.py +0 -0
  62. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/tests/test_entity_has_columns.py +0 -0
  63. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/tests/test_execute_contratto.py +0 -0
  64. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/tests/test_exists.py +0 -0
  65. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/tests/test_has_columns.py +0 -0
  66. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/tests/test_many_to_many.py +0 -0
  67. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/tests/test_metadata_eccezioni.py +0 -0
  68. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/tests/test_metadata_geom.py +0 -0
  69. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/tests/test_metadata_paginazione.py +0 -0
  70. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/tests/test_metadata_pipeline.py +0 -0
  71. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/tests/test_mixin.py +0 -0
  72. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/tests/test_negazione.py +0 -0
  73. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/tests/test_ordinamento.py +0 -0
  74. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/tests/test_query_count.py +0 -0
  75. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/tests/test_retrieve_mapper.py +0 -0
  76. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/tests/test_smoke_crud.py +0 -0
  77. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/tests/test_softdelete.py +0 -0
  78. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/tests/test_sql_snapshot.py +0 -0
  79. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/tests/test_to_dict.py +0 -0
  80. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/tests/test_upsert_chiavi.py +0 -0
  81. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/tests/test_valori_falsy.py +0 -0
  82. {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/tests/test_view_repository.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-general-be-lib
3
- Version: 0.11.1
3
+ Version: 0.11.3
4
4
  Summary: General purpose backend library — SQLAlchemy CRUD/Geometry repositories, FastAPI exceptions, Pydantic base models, logger utilities.
5
5
  Author-email: Andrea Di Placido <a.diplacido@arpes.it>, "Arpes S.r.l." <it.admin@arpes.it>
6
6
  License: MIT
@@ -31,6 +31,7 @@ Requires-Dist: testcontainers[postgres]>=4; extra == "dev"
31
31
  Requires-Dist: psycopg[binary]>=3; extra == "dev"
32
32
  Requires-Dist: build; extra == "dev"
33
33
  Requires-Dist: twine; extra == "dev"
34
+ Requires-Dist: mypy>=1.11; extra == "dev"
34
35
  Dynamic: license-file
35
36
 
36
37
  # python-general-be-lib
@@ -17,7 +17,6 @@ __all__ = [
17
17
  "InternalServerException",
18
18
  ]
19
19
 
20
- from sqlalchemy.exc import DataError, IntegrityError, NoResultFound, OperationalError
21
20
 
22
21
  from .exception_interface import ExceptionInterface
23
22
 
@@ -111,6 +110,8 @@ def raise_http_exception(e: Exception):
111
110
  BadRequestException: On ``DataError`` or ``IntegrityError``.
112
111
  Exception: The original exception, for every other type.
113
112
  """
113
+ from sqlalchemy.exc import DataError, IntegrityError, NoResultFound, OperationalError
114
+
114
115
  match e:
115
116
  case OperationalError():
116
117
  raise ServiceUnavailableException()
@@ -0,0 +1,59 @@
1
+ """SQLAlchemy declarative base class and preconfigured Pydantic models.
2
+
3
+ The public symbols are resolved lazily (PEP 562): importing this package does not import
4
+ the modules behind them, so reaching one of them does not pay for the heavier siblings.
5
+ The ``TYPE_CHECKING`` block is what keeps the static types — without it a type checker
6
+ would see every symbol as ``Any``, silently, and the ``py.typed`` marker would promise
7
+ types the consumer does not get.
8
+ """
9
+ from typing import TYPE_CHECKING
10
+
11
+ __all__ = [
12
+ "Base",
13
+ "retrieve_mapper_entity",
14
+ "retrieve_mapper_from_table",
15
+ "ExtBaseModel",
16
+ "CamelExtBaseModel",
17
+ ]
18
+
19
+ if TYPE_CHECKING: # i type checker leggono da qui, il runtime no
20
+ from .base_model import CamelExtBaseModel, ExtBaseModel
21
+ from .declarative_base import Base, retrieve_mapper_entity, retrieve_mapper_from_table
22
+
23
+ _MODULES = {
24
+ "Base": ".declarative_base",
25
+ "retrieve_mapper_entity": ".declarative_base",
26
+ "retrieve_mapper_from_table": ".declarative_base",
27
+ "ExtBaseModel": ".base_model",
28
+ "CamelExtBaseModel": ".base_model",
29
+ }
30
+
31
+
32
+ def __getattr__(name: str):
33
+ """Resolve a public symbol, importing its module on first access.
34
+
35
+ Args:
36
+ name (str): Name of the attribute being read.
37
+
38
+ Returns:
39
+ Any: The requested symbol.
40
+
41
+ Raises:
42
+ AttributeError: If the name is not part of the public surface.
43
+ """
44
+ module = _MODULES.get(name)
45
+ if module is None:
46
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
47
+
48
+ from importlib import import_module
49
+
50
+ return getattr(import_module(module, __name__), name)
51
+
52
+
53
+ def __dir__() -> list[str]:
54
+ """List the public surface without importing anything.
55
+
56
+ Returns:
57
+ list[str]: The names in ``__all__``.
58
+ """
59
+ return list(__all__)
@@ -3,7 +3,6 @@ __all__ = ["Base", "retrieve_mapper_entity", "retrieve_mapper_from_table"]
3
3
  from functools import lru_cache
4
4
  from typing import Optional, Any, Sequence
5
5
 
6
- from geoalchemy2 import Geometry, WKBElement
7
6
  from pydantic import create_model, ConfigDict
8
7
  from sqlalchemy.inspection import inspect
9
8
  from sqlalchemy.orm import Mapper, as_declarative
@@ -31,6 +30,11 @@ class Base:
31
30
  or nothing: passing ``__config__`` puts the whole configuration in the hands of the
32
31
  caller, and passing ``__base__`` lets the base carry its own.
33
32
 
33
+ The generated class is stamped with the module of the entity, unless the caller
34
+ passes ``__module__``: ``create_model`` reads the calling frame otherwise, so every
35
+ generated model would claim to live in this module and a ``repr`` would point at
36
+ the library instead of the entity.
37
+
34
38
  Args:
35
39
  model_name (str, optional): Name of the generated model. When not given,
36
40
  ``{ClassName}Model`` is used.
@@ -49,6 +53,10 @@ class Base:
49
53
  Returns:
50
54
  Type[BaseModel]: Dynamically generated Pydantic class.
51
55
  """
56
+ from geoalchemy2 import Geometry, WKBElement
57
+
58
+ create_model_kwargs.setdefault("__module__", cls.__module__)
59
+
52
60
  if "__config__" not in create_model_kwargs.keys() and "__base__" not in create_model_kwargs.keys():
53
61
  create_model_kwargs["__config__"] = ConfigDict(from_attributes=True, populate_by_name=True, arbitrary_types_allowed=True, validate_default=True)
54
62
 
@@ -0,0 +1,58 @@
1
+ """CRUD and spatial layer over reflected ``Table`` objects, without the ORM.
2
+
3
+ Alternative to the repositories to operate on tables having no ORM model: the schema is
4
+ reflected at runtime and the tables are resolved by name. It goes through the **same**
5
+ handler pipeline as the ORM layer, built per table from the reflected ``Table.c``, and maps
6
+ the SQLAlchemy exceptions onto the same HTTP exceptions.
7
+
8
+ The public symbols are resolved lazily (PEP 562): importing this package does not import
9
+ the modules behind them, so reaching one of them does not pay for the heavier siblings.
10
+ The ``TYPE_CHECKING`` block is what keeps the static types — without it a type checker
11
+ would see every symbol as ``Any``, silently, and the ``py.typed`` marker would promise
12
+ types the consumer does not get.
13
+ """
14
+ from typing import TYPE_CHECKING
15
+
16
+ __all__ = [
17
+ "CrudMetadata",
18
+ "GeomMetadata",
19
+ ]
20
+
21
+ if TYPE_CHECKING: # i type checker leggono da qui, il runtime no
22
+ from .crud_metadata import CrudMetadata
23
+ from .geom_metadata import GeomMetadata
24
+
25
+ _MODULES = {
26
+ "CrudMetadata": ".crud_metadata",
27
+ "GeomMetadata": ".geom_metadata",
28
+ }
29
+
30
+
31
+ def __getattr__(name: str):
32
+ """Resolve a public symbol, importing its module on first access.
33
+
34
+ Args:
35
+ name (str): Name of the attribute being read.
36
+
37
+ Returns:
38
+ Any: The requested symbol.
39
+
40
+ Raises:
41
+ AttributeError: If the name is not part of the public surface.
42
+ """
43
+ module = _MODULES.get(name)
44
+ if module is None:
45
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
46
+
47
+ from importlib import import_module
48
+
49
+ return getattr(import_module(module, __name__), name)
50
+
51
+
52
+ def __dir__() -> list[str]:
53
+ """List the public surface without importing anything.
54
+
55
+ Returns:
56
+ list[str]: The names in ``__all__``.
57
+ """
58
+ return list(__all__)
@@ -342,7 +342,7 @@ class CrudMetadata:
342
342
  if rows:
343
343
  return rows[0]
344
344
  if strict:
345
- raise EntityNotFoundException()
345
+ raise EntityNotFoundException(custom_message=table.name)
346
346
  return None
347
347
 
348
348
  def _get(self, table: Table, connection: Connection, pk: Any, columns: list[str] = None, **kwhere):
@@ -429,7 +429,7 @@ class CrudMetadata:
429
429
  if rows:
430
430
  return rows[0]
431
431
  if strict:
432
- raise EntityNotFoundException()
432
+ raise EntityNotFoundException(custom_message=table.name)
433
433
  return None
434
434
 
435
435
  def insert(self, from_table: str, connection: Connection = None, models: list[Type[BaseModel] | BaseModel] = None, values: list[dict[str, Any]] = None, returning: bool = True,
@@ -0,0 +1,59 @@
1
+ """ORM repositories: generic CRUD, spatial, many-to-many and views.
2
+
3
+ The public symbols are resolved lazily (PEP 562): importing this package does not import
4
+ the modules behind them, so reaching one of them does not pay for the heavier siblings.
5
+ The ``TYPE_CHECKING`` block is what keeps the static types — without it a type checker
6
+ would see every symbol as ``Any``, silently, and the ``py.typed`` marker would promise
7
+ types the consumer does not get.
8
+ """
9
+ from typing import TYPE_CHECKING
10
+
11
+ __all__ = [
12
+ "CrudRepository",
13
+ "GeometryRepository",
14
+ "ManyToManyRepository",
15
+ "ViewRepository",
16
+ ]
17
+
18
+ if TYPE_CHECKING: # i type checker leggono da qui, il runtime no
19
+ from .crud_repository import CrudRepository
20
+ from .geometry_repository import GeometryRepository
21
+ from .many_to_many_repository import ManyToManyRepository
22
+ from .view_repository import ViewRepository
23
+
24
+ _MODULES = {
25
+ "CrudRepository": ".crud_repository",
26
+ "GeometryRepository": ".geometry_repository",
27
+ "ManyToManyRepository": ".many_to_many_repository",
28
+ "ViewRepository": ".view_repository",
29
+ }
30
+
31
+
32
+ def __getattr__(name: str):
33
+ """Resolve a public symbol, importing its module on first access.
34
+
35
+ Args:
36
+ name (str): Name of the attribute being read.
37
+
38
+ Returns:
39
+ Any: The requested symbol.
40
+
41
+ Raises:
42
+ AttributeError: If the name is not part of the public surface.
43
+ """
44
+ module = _MODULES.get(name)
45
+ if module is None:
46
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
47
+
48
+ from importlib import import_module
49
+
50
+ return getattr(import_module(module, __name__), name)
51
+
52
+
53
+ def __dir__() -> list[str]:
54
+ """List the public surface without importing anything.
55
+
56
+ Returns:
57
+ list[str]: The names in ``__all__``.
58
+ """
59
+ return list(__all__)
@@ -147,7 +147,14 @@ class CrudRepository[Entity: Base | Type[Base]]:
147
147
  self.columns = self.entity.columns()
148
148
  self._relationship_attributes = None # popolato lazy dalla property relationship_attributes
149
149
 
150
- base_handler = handler_class(self.columns) if handler_class else BaseHandler(self.columns)
150
+ # ``handler_class`` accetta una classe — che si costruisce dalle colonne — oppure
151
+ # un'istanza già pronta: il suggerimento di tipo prometteva entrambe e ne reggeva una
152
+ if handler_class is None:
153
+ base_handler = BaseHandler(self.columns)
154
+ elif isinstance(handler_class, type):
155
+ base_handler = handler_class(self.columns)
156
+ else:
157
+ base_handler = handler_class
151
158
 
152
159
  self.stmt_manager = StatementManager(handlers=handlers, base_handler=base_handler)
153
160
 
@@ -423,7 +430,7 @@ class CrudRepository[Entity: Base | Type[Base]]:
423
430
  if records:
424
431
  return records[0]
425
432
  if strict:
426
- raise EntityNotFoundException()
433
+ raise EntityNotFoundException(custom_message=self.entity.__name__)
427
434
  return None
428
435
 
429
436
  def _get(self, session: Session, pk: Any, columns: list[str] = None, exclude_columns: list[str] = None, **kwhere):
@@ -523,7 +530,7 @@ class CrudRepository[Entity: Base | Type[Base]]:
523
530
  if records:
524
531
  return records[0]
525
532
  if strict:
526
- raise EntityNotFoundException()
533
+ raise EntityNotFoundException(custom_message=self.entity.__name__)
527
534
  return None
528
535
 
529
536
  def _find(self, session: Session, where: dict[str, Any] = None, columns: list[str] = None, exclude_columns: list[str] = None, limit: int = 0, orders_by: dict[str, bool] = None, **kwhere):
@@ -32,22 +32,3 @@ class Handler:
32
32
  the handler claims nothing.
33
33
  """
34
34
  return []
35
-
36
- def handle(self, stmt, neq=False, where=None):
37
- """Apply the conditions of this handler alone to a statement.
38
-
39
- Adapter for the isolated use of a handler, outside the ``StatementManager``
40
- pipeline: it collects the conditions of ``conditions`` and adds them with a single
41
- ``.where()`` call. Nothing is applied when the handler produces no condition.
42
-
43
- Args:
44
- stmt (Select | Update | Delete): Statement to apply the filters to.
45
- neq (bool): If ``True``, the conditions are negated. Default: ``False``.
46
- where (dict[str, Any]): Filters. The keys the handler claims are removed
47
- in-place. Default: ``None``.
48
-
49
- Returns:
50
- Select | Update | Delete: Statement with the WHERE conditions applied.
51
- """
52
- conditions = self.conditions(neq=neq, where=where)
53
- return stmt.where(*conditions) if conditions else stmt
@@ -5,6 +5,8 @@ from typing import Any, Type
5
5
  from sqlalchemy import ColumnElement, Delete, Select, Update
6
6
 
7
7
  from general.exception.crud_exceptions import HasNoAttributeException
8
+ from sqlalchemy.sql.base import ReadOnlyColumnCollection
9
+
8
10
  from .base_handler import BaseHandler
9
11
 
10
12
 
@@ -27,19 +29,49 @@ class StatementManager:
27
29
  def __init__(self, handlers: list[BaseHandler | Type[BaseHandler]] = None, base_handler: BaseHandler = None):
28
30
  """Initialize the manager with the handler list and the mandatory base handler.
29
31
 
32
+ A handler can be given as an instance or as a class: a class is built with the
33
+ columns of the ``base_handler``, which is what every handler needs and the only
34
+ thing some of them need. Building it here is what makes a wrong handler fail at
35
+ construction, where the caller can read the cause, instead of at the first query.
36
+
30
37
  Args:
31
- handlers (list[BaseHandler], optional): Additional handlers to run before the
32
- base handler, such as ``JsonHandler`` or ``ILikeHandler``.
38
+ handlers (list[BaseHandler | Type[BaseHandler]], optional): Additional handlers
39
+ to run before the base handler, such as ``JsonHandler`` or ``ILikeHandler``.
40
+ Each one as an instance or as a class.
33
41
  base_handler (BaseHandler): Main handler for the standard filters. Mandatory.
34
42
 
35
43
  Raises:
36
44
  ValueError: If ``base_handler`` is not provided.
45
+ TypeError: If a handler class needs more than the columns to be built.
37
46
  """
38
47
  if not base_handler:
39
- raise ValueError("StatemantManager: base_handler is not set")
40
- self._handlers = handlers if handlers else []
48
+ raise ValueError("StatementManager: base_handler is not set")
49
+ self._handlers = [self._instance_of(handler, base_handler.columns) for handler in handlers] if handlers else []
41
50
  self._base_handler = base_handler
42
51
 
52
+ @staticmethod
53
+ def _instance_of(handler: BaseHandler | Type[BaseHandler], columns: ReadOnlyColumnCollection) -> BaseHandler:
54
+ """Return the handler itself, building it from the columns when given as a class.
55
+
56
+ Args:
57
+ handler (BaseHandler | Type[BaseHandler]): Handler instance or class.
58
+ columns (ReadOnlyColumnCollection): Columns to build a class with.
59
+
60
+ Returns:
61
+ BaseHandler: The handler ready to be run by the pipeline.
62
+
63
+ Raises:
64
+ TypeError: If the class needs more arguments than the columns, with the name of
65
+ the handler and the suggestion to pass an instance.
66
+ """
67
+ if not isinstance(handler, type):
68
+ return handler
69
+
70
+ try:
71
+ return handler(columns)
72
+ except TypeError as e:
73
+ raise TypeError(f"{handler.__name__} needs more than the columns to be built: pass an instance, not the class") from e
74
+
43
75
  def conditions(self, neq: bool = False, where: dict[str, Any] = None) -> list[ColumnElement[Any]]:
44
76
  """Collect the conditions of every handler without touching any statement.
45
77
 
@@ -1,5 +1,6 @@
1
1
  import logging
2
2
  import logging.config
3
+ import reprlib
3
4
  from functools import wraps
4
5
  from time import perf_counter
5
6
 
@@ -21,6 +22,11 @@ color = COLOR['BLUE']
21
22
  end_color = COLOR['ENDC']
22
23
  exc_color = COLOR['RED']
23
24
 
25
+ # Bounded repr of the call arguments for the error record: truncates long strings,
26
+ # collections and object reprs, and falls back to a placeholder when an argument's
27
+ # own ``__repr__`` raises, so that it cannot replace the original exception.
28
+ _args_repr = reprlib.Repr(maxlevel=3, maxtuple=10, maxlist=10, maxdict=10, maxset=10, maxstring=200, maxother=200)
29
+
24
30
  LOGGING_CONFIG = {
25
31
  "version": 1,
26
32
  "disable_existing_loggers": False,
@@ -100,8 +106,9 @@ def log(func):
100
106
  ``HTTPException`` is propagated as is, any other exception is converted into an
101
107
  ``InternalServerException``, whose ``detail`` does not carry the original message so
102
108
  that internal details never reach the HTTP response body. The call arguments are not
103
- serialized on the happy path; on error they are attached to the record as the
104
- ``func_args`` and ``func_kwargs`` structured fields, available to a JSON handler.
109
+ serialized on the happy path; on error their truncated ``repr`` is appended to the
110
+ message, and they are also attached to the record as the ``func_args`` and
111
+ ``func_kwargs`` structured fields, available to a JSON handler.
105
112
 
106
113
  The wrapper is **synchronous**: applied to a coroutine function it measures the
107
114
  coroutine creation time, not its execution, and does not intercept the exceptions
@@ -123,7 +130,8 @@ def log(func):
123
130
  result = func(*args, **kwargs)
124
131
  except Exception as e:
125
132
  extra = dict(func_args=args, func_kwargs=kwargs)
126
- logging.exception(f"Exception {exc_color} {func_name} {end_color}: {repr(e)}", extra=extra)
133
+ call = f"args={_args_repr.repr(args)} kwargs={_args_repr.repr(kwargs)}"
134
+ logging.exception(f"Exception {exc_color} {func_name} {end_color}: {repr(e)} - {call}", extra=extra)
127
135
  raise e.with_traceback(None) if isinstance(e, HTTPException) else InternalServerException()
128
136
  else:
129
137
  logging.info(f"{color} {func_name} {end_color} completed in {(perf_counter() - start):.3f} seconds")
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-general-be-lib"
7
- version = "0.11.1"
7
+ version = "0.11.3"
8
8
  description = "General purpose backend library — SQLAlchemy CRUD/Geometry repositories, FastAPI exceptions, Pydantic base models, logger utilities."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -40,6 +40,7 @@ dev = [
40
40
  "psycopg[binary]>=3",
41
41
  "build",
42
42
  "twine",
43
+ "mypy>=1.11",
43
44
  ]
44
45
 
45
46
  [project.urls]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-general-be-lib
3
- Version: 0.11.1
3
+ Version: 0.11.3
4
4
  Summary: General purpose backend library — SQLAlchemy CRUD/Geometry repositories, FastAPI exceptions, Pydantic base models, logger utilities.
5
5
  Author-email: Andrea Di Placido <a.diplacido@arpes.it>, "Arpes S.r.l." <it.admin@arpes.it>
6
6
  License: MIT
@@ -31,6 +31,7 @@ Requires-Dist: testcontainers[postgres]>=4; extra == "dev"
31
31
  Requires-Dist: psycopg[binary]>=3; extra == "dev"
32
32
  Requires-Dist: build; extra == "dev"
33
33
  Requires-Dist: twine; extra == "dev"
34
+ Requires-Dist: mypy>=1.11; extra == "dev"
34
35
  Dynamic: license-file
35
36
 
36
37
  # python-general-be-lib
@@ -45,6 +45,7 @@ python_general_be_lib.egg-info/requires.txt
45
45
  python_general_be_lib.egg-info/top_level.txt
46
46
  tests/test_caricamento_parziale.py
47
47
  tests/test_compiled_cache.py
48
+ tests/test_costo_import.py
48
49
  tests/test_engine_defaults.py
49
50
  tests/test_entity_has_columns.py
50
51
  tests/test_execute_contratto.py
@@ -11,3 +11,4 @@ testcontainers[postgres]>=4
11
11
  psycopg[binary]>=3
12
12
  build
13
13
  twine
14
+ mypy>=1.11
@@ -0,0 +1,155 @@
1
+ """Il costo di import per sottoalbero (1.10 di `.claude/piano-ottimizzazioni.md`).
2
+
3
+ `general/__init__.py` è vuoto di proposito, così ogni percorso paga solo per il proprio
4
+ sottoalbero. La regola valeva per la radice e **non era applicata un livello sotto**: tre
5
+ catene la disfacevano, e nessuna di esse era visibile perché niente le verificava.
6
+
7
+ - `crud_exceptions.py` importava `sqlalchemy.exc` a livello di modulo — 80 moduli — e in
8
+ quel file stanno anche le classi di eccezione, di cui `general/logger.py` usa una. Il
9
+ logger caricava quindi SQLAlchemy, contro quanto `CLAUDE.md` dichiara di lui.
10
+ - `declarative_base.py` importava GeoAlchemy2 per le due righe di `to_pydantic` che mappano
11
+ una colonna geometrica, quindi `Base` — che ogni entità estende — trascinava PostGIS.
12
+ - gli `__init__.py` dei package ri-esportavano tutto con `import *`, quindi raggiungere un
13
+ modulo leggero costava i fratelli pesanti.
14
+
15
+ Questi test **non misurano millisecondi**, che sarebbero instabili: asseriscono cosa
16
+ finisce in `sys.modules`, che è deterministico. È la forma che accorge del ritorno di un
17
+ import ansioso il giorno in cui qualcuno lo rimette.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import subprocess
23
+ import sys
24
+
25
+ import pytest
26
+
27
+ PESANTI = ("sqlalchemy", "geoalchemy2", "shapely")
28
+
29
+
30
+ def moduli_caricati(istruzione: str) -> set[str]:
31
+ """Esegue l'import in un processo pulito e restituisce i pacchetti pesanti caricati."""
32
+ codice = f"{istruzione}\nimport sys\nprint(' '.join(p for p in {PESANTI!r} if p in sys.modules))"
33
+ esito = subprocess.run([sys.executable, "-c", codice], capture_output=True, text=True)
34
+ assert esito.returncode == 0, esito.stderr
35
+ return set(esito.stdout.split())
36
+
37
+
38
+ # --------------------------------------------------- ciò che non deve essere caricato
39
+
40
+
41
+ @pytest.mark.parametrize(
42
+ "istruzione, vietati",
43
+ [
44
+ ("import general.logger", {"sqlalchemy", "geoalchemy2", "shapely"}),
45
+ ("import general.exception", {"sqlalchemy", "geoalchemy2", "shapely"}),
46
+ ("import general.paginator_dto", {"sqlalchemy", "geoalchemy2", "shapely"}),
47
+ ("import general.context", {"sqlalchemy", "geoalchemy2", "shapely"}),
48
+ ("import general.utils", {"sqlalchemy", "geoalchemy2", "shapely"}),
49
+ ("import general.interface.repository.crud_repository", {"geoalchemy2", "shapely"}),
50
+ ("import general.interface.metadata.crud_metadata", {"geoalchemy2", "shapely"}),
51
+ ],
52
+ ids=lambda v: v.replace("import ", "") if isinstance(v, str) else "",
53
+ )
54
+ def test_un_modulo_non_paga_per_quello_che_non_usa(istruzione: str, vietati: set[str]) -> None:
55
+ assert moduli_caricati(istruzione) & vietati == set()
56
+
57
+
58
+ def test_il_percorso_orm_carica_sqlalchemy_ma_non_postgis() -> None:
59
+ """La distinzione è il punto: l'ORM serve, PostGIS no — e prima venivano insieme."""
60
+ caricati = moduli_caricati("import general.interface.repository.crud_repository")
61
+
62
+ assert "sqlalchemy" in caricati
63
+ assert "geoalchemy2" not in caricati
64
+
65
+
66
+ def test_chi_usa_le_geometrie_le_carica_eccome() -> None:
67
+ """La pigrizia non toglie niente: chi tocca il modulo spaziale ha le sue dipendenze."""
68
+ assert "geoalchemy2" in moduli_caricati("import general.interface.repository.geometry_repository")
69
+
70
+
71
+ # ------------------------------------------------------- il package pigro si comporta
72
+
73
+
74
+ @pytest.mark.parametrize(
75
+ "package, simbolo, modulo_dietro",
76
+ [
77
+ ("general.interface.base", "Base", "general.interface.base.declarative_base"),
78
+ ("general.interface.repository", "CrudRepository", "general.interface.repository.crud_repository"),
79
+ ("general.interface.metadata", "CrudMetadata", "general.interface.metadata.crud_metadata"),
80
+ ],
81
+ )
82
+ def test_importare_il_package_non_importa_i_moduli(package: str, simbolo: str, modulo_dietro: str) -> None:
83
+ """Il package si carica da solo; il modulo dietro arriva al primo accesso al simbolo."""
84
+ codice = (
85
+ f"import {package}, sys\n"
86
+ f"print('prima' if {modulo_dietro!r} in sys.modules else 'assente')\n"
87
+ f"_ = {package}.{simbolo}\n"
88
+ f"print('dopo' if {modulo_dietro!r} in sys.modules else 'mai')"
89
+ )
90
+ esito = subprocess.run([sys.executable, "-c", codice], capture_output=True, text=True)
91
+
92
+ assert esito.stdout.split() == ["assente", "dopo"], esito.stdout + esito.stderr
93
+
94
+
95
+ @pytest.mark.parametrize(
96
+ "package, simbolo",
97
+ [
98
+ ("general.interface.base", "Base"),
99
+ ("general.interface.repository", "CrudRepository"),
100
+ ("general.interface.metadata", "GeomMetadata"),
101
+ ],
102
+ )
103
+ def test_il_simbolo_si_importa_come_prima(package: str, simbolo: str) -> None:
104
+ """Il `from package import X` del consumatore non cambia."""
105
+ esito = subprocess.run(
106
+ [sys.executable, "-c", f"from {package} import {simbolo}; print({simbolo}.__name__)"],
107
+ capture_output=True, text=True,
108
+ )
109
+
110
+ assert esito.stdout.strip() == simbolo, esito.stderr
111
+
112
+
113
+ def test_un_nome_inesistente_alza_attributeerror() -> None:
114
+ """Il `__getattr__` non deve trasformare un typo in un ImportError oscuro."""
115
+ import general.interface.repository as package
116
+
117
+ with pytest.raises(AttributeError, match="Inesistente"):
118
+ _ = package.Inesistente
119
+
120
+
121
+ def test_dir_elenca_la_superficie_senza_importare_niente() -> None:
122
+ """Senza `__dir__` un package pigro sembrerebbe vuoto in un REPL."""
123
+ import general.interface.repository as package
124
+
125
+ assert set(dir(package)) == set(package.__all__)
126
+
127
+
128
+ # ------------------------------------------------------------- l'invariante dei tipi
129
+
130
+
131
+ def test_i_tipi_restano_visibili_a_mypy(tmp_path) -> None:
132
+ """Il blocco ``TYPE_CHECKING`` è ciò che rende la pigrizia gratuita, e va difeso.
133
+
134
+ Senza, un type checker vede ``Any`` al posto di ogni simbolo della libreria: gli import
135
+ continuano a funzionare, `py.typed` viene ancora spedito, e il consumatore smette di
136
+ ricevere errori **in silenzio**. Verificato con mypy 2.3.1 il 2026-09-25: è la sola
137
+ regressione possibile di questo intervento, ed è invisibile senza un test.
138
+
139
+ mypy sta fra le dipendenze `[dev]` proprio per questo test; l'`importorskip` copre chi
140
+ esegue la suite senza l'extra.
141
+ """
142
+ api = pytest.importorskip("mypy.api", reason="mypy non installato")
143
+
144
+ sorgente = tmp_path / "consumatore.py"
145
+ sorgente.write_text(
146
+ "from general.interface.repository import CrudRepository\n"
147
+ "sbagliato: int = CrudRepository\n"
148
+ )
149
+ uscita, _, _ = api.run(["--ignore-missing-imports", "--follow-imports=silent",
150
+ "--no-error-summary", str(sorgente)])
151
+
152
+ assert "Incompatible types in assignment" in uscita, (
153
+ f"mypy non vede più il tipo di CrudRepository: probabilmente manca il blocco "
154
+ f"TYPE_CHECKING in un __init__.py pigro.\n{uscita}"
155
+ )
@@ -56,6 +56,14 @@ def test_con_strict_alza_il_404(repo: CrudRepository) -> None:
56
56
  assert errore.value.status_code == 404
57
57
 
58
58
 
59
+ def test_il_404_nomina_l_entita(repo: CrudRepository) -> None:
60
+ """Con più letture in fila il 404 deve dire **quale** è andata a vuoto."""
61
+ with pytest.raises(EntityNotFoundException) as errore:
62
+ repo.find_one(nome="nessuno", strict=True)
63
+
64
+ assert errore.value.detail == "Dato non trovato - Utente"
65
+
66
+
59
67
  def test_con_strict_la_sessione_viene_chiusa_prima_del_raise(repo: CrudRepository, engine: Engine) -> None:
60
68
  """Il ``raise`` sta **dopo** ``_return``, che è quello che chiude la sessione.
61
69
 
@@ -62,6 +62,14 @@ def test_con_strict_alza_il_404(repo: CrudRepository) -> None:
62
62
  assert errore.value.status_code == 404
63
63
 
64
64
 
65
+ def test_il_404_nomina_l_entita(repo: CrudRepository) -> None:
66
+ """Con più letture in fila il 404 deve dire **quale** è andata a vuoto."""
67
+ with pytest.raises(EntityNotFoundException) as errore:
68
+ repo.get(999, strict=True)
69
+
70
+ assert errore.value.detail == "Dato non trovato - Utente"
71
+
72
+
65
73
  def test_costa_una_sola_query(repo: CrudRepository, engine: Engine) -> None:
66
74
  with assert_query_count(engine, 1, verbs=["SELECT"]):
67
75
  repo.get(1)