python-general-be-lib 0.11.2__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.2 → python_general_be_lib-0.11.3}/PKG-INFO +2 -1
  2. {python_general_be_lib-0.11.2 → 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.2 → python_general_be_lib-0.11.3}/general/interface/base/declarative_base.py +2 -1
  5. python_general_be_lib-0.11.3/general/interface/metadata/__init__.py +58 -0
  6. python_general_be_lib-0.11.3/general/interface/repository/__init__.py +59 -0
  7. {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/general/interface/repository/crud_repository.py +8 -1
  8. {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/general/interface/repository/handler/handler.py +0 -19
  9. {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/general/interface/repository/handler/statement_manager.py +36 -4
  10. {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/general/logger.py +11 -3
  11. {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/pyproject.toml +2 -1
  12. {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/python_general_be_lib.egg-info/PKG-INFO +2 -1
  13. {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/python_general_be_lib.egg-info/SOURCES.txt +1 -0
  14. {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/python_general_be_lib.egg-info/requires.txt +1 -0
  15. python_general_be_lib-0.11.3/tests/test_costo_import.py +155 -0
  16. {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/tests/test_import_pubblici.py +64 -0
  17. {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/tests/test_logger.py +81 -23
  18. {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/tests/test_pipeline_handler.py +50 -0
  19. python_general_be_lib-0.11.2/general/interface/base/__init__.py +0 -12
  20. python_general_be_lib-0.11.2/general/interface/metadata/__init__.py +0 -12
  21. python_general_be_lib-0.11.2/general/interface/repository/__init__.py +0 -13
  22. {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/LICENSE +0 -0
  23. {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/README.md +0 -0
  24. {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/general/__init__.py +0 -0
  25. {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/general/context.py +0 -0
  26. {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/general/exception/__init__.py +0 -0
  27. {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/general/exception/access_exceptions.py +0 -0
  28. {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/general/exception/exception_interface.py +0 -0
  29. {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/general/interface/__init__.py +0 -0
  30. {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/general/interface/base/base_model.py +0 -0
  31. {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/general/interface/metadata/crud_metadata.py +0 -0
  32. {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/general/interface/metadata/geom_metadata.py +0 -0
  33. {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/general/interface/mixin/__init__.py +0 -0
  34. {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/general/interface/mixin/audit_mixin.py +0 -0
  35. {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/general/interface/mixin/softdelete_mixin.py +0 -0
  36. {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/general/interface/mixin/timestamp_mixin.py +0 -0
  37. {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/general/interface/repository/geometry_repository.py +0 -0
  38. {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/general/interface/repository/handler/__init__.py +0 -0
  39. {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/general/interface/repository/handler/base_handler.py +0 -0
  40. {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/general/interface/repository/handler/ilike_handler.py +0 -0
  41. {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/general/interface/repository/handler/interval_handler.py +0 -0
  42. {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/general/interface/repository/handler/json_handler.py +0 -0
  43. {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/general/interface/repository/handler/json_ilike_handler.py +0 -0
  44. {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/general/interface/repository/handler/or_handler.py +0 -0
  45. {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/general/interface/repository/handler/soft_delete_handler.py +0 -0
  46. {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/general/interface/repository/many_to_many_repository.py +0 -0
  47. {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/general/interface/repository/view_repository.py +0 -0
  48. {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/general/paginator_dto.py +0 -0
  49. {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/general/py.typed +0 -0
  50. {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/general/utils/__init__.py +0 -0
  51. {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/general/utils/consts.py +0 -0
  52. {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/python_general_be_lib.egg-info/dependency_links.txt +0 -0
  53. {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/python_general_be_lib.egg-info/top_level.txt +0 -0
  54. {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/setup.cfg +0 -0
  55. {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/tests/test_caricamento_parziale.py +0 -0
  56. {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/tests/test_compiled_cache.py +0 -0
  57. {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/tests/test_engine_defaults.py +0 -0
  58. {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/tests/test_entity_has_columns.py +0 -0
  59. {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/tests/test_execute_contratto.py +0 -0
  60. {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/tests/test_exists.py +0 -0
  61. {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/tests/test_find_one.py +0 -0
  62. {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/tests/test_get.py +0 -0
  63. {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/tests/test_has_columns.py +0 -0
  64. {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/tests/test_many_to_many.py +0 -0
  65. {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/tests/test_metadata_eccezioni.py +0 -0
  66. {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/tests/test_metadata_geom.py +0 -0
  67. {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/tests/test_metadata_paginazione.py +0 -0
  68. {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/tests/test_metadata_pipeline.py +0 -0
  69. {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/tests/test_metadata_scorciatoie.py +0 -0
  70. {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/tests/test_mixin.py +0 -0
  71. {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/tests/test_negazione.py +0 -0
  72. {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/tests/test_ordinamento.py +0 -0
  73. {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/tests/test_query_count.py +0 -0
  74. {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/tests/test_retrieve_mapper.py +0 -0
  75. {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/tests/test_smoke_crud.py +0 -0
  76. {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/tests/test_softdelete.py +0 -0
  77. {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/tests/test_sql_snapshot.py +0 -0
  78. {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/tests/test_to_dict.py +0 -0
  79. {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/tests/test_to_pydantic.py +0 -0
  80. {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/tests/test_upsert_chiavi.py +0 -0
  81. {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/tests/test_valori_falsy.py +0 -0
  82. {python_general_be_lib-0.11.2 → 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.2
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
@@ -54,6 +53,8 @@ class Base:
54
53
  Returns:
55
54
  Type[BaseModel]: Dynamically generated Pydantic class.
56
55
  """
56
+ from geoalchemy2 import Geometry, WKBElement
57
+
57
58
  create_model_kwargs.setdefault("__module__", cls.__module__)
58
59
 
59
60
  if "__config__" not in create_model_kwargs.keys() and "__base__" not in create_model_kwargs.keys():
@@ -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__)
@@ -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
 
@@ -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.2"
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.2
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
+ )
@@ -8,15 +8,29 @@ test fissano gli import che devono funzionare e la pulizia di ciò che esce da
8
8
  Sono deliberatamente banali: il loro valore è che falliscono quando qualcuno aggiunge
9
9
  una classe pubblica e dimentica `__all__` o l'`__init__.py`, che è successo con
10
10
  `OrHandler` (mai esportato) e con `Handler` (esportato solo dopo averlo cercato).
11
+
12
+ `SUPERFICIE` però è una **mappa scritta a mano**, e per un package nuovo non fallisce:
13
+ non lo conosce. È successo con `general.utils`, aggiunto qui a mano *dopo* che il buco
14
+ era stato trovato leggendo il codice — cioè il test non ha fatto la sola cosa per cui
15
+ esiste (voce 1b.9). Da lì
16
+ `test_ogni_package_della_libreria_e_censito`, che enumera i package davvero presenti e
17
+ pretende che ognuno sia o in `SUPERFICIE` o nell'elenco esplicito di quelli vuoti per
18
+ scelta.
11
19
  """
12
20
 
13
21
  from __future__ import annotations
14
22
 
15
23
  import importlib
24
+ import pkgutil
16
25
  import types
17
26
 
18
27
  import pytest
19
28
 
29
+ # Package senza superficie propria, **per scelta**: la radice resta vuota perché ogni
30
+ # percorso di import paghi solo il proprio sottoalbero (voce 1.10 delle ottimizzazioni),
31
+ # e ``general.interface`` è solo un contenitore di namespace.
32
+ SENZA_SUPERFICIE = {"general", "general.interface"}
33
+
20
34
  # (package, simboli che devono essere importabili da lì)
21
35
  SUPERFICIE = {
22
36
  "general.utils": [
@@ -140,3 +154,53 @@ def test_general_interface_e_un_package_regolare() -> None:
140
154
 
141
155
  assert general.interface.__file__ is not None
142
156
  assert general.interface.__file__.endswith("__init__.py")
157
+
158
+
159
+ # ------------------------------------------- che la mappa qui sopra resti completa
160
+
161
+
162
+ def test_ogni_package_della_libreria_e_censito() -> None:
163
+ """Un package nuovo deve comparire in `SUPERFICIE` o essere dichiarato senza superficie.
164
+
165
+ È il test che mancava: gli altri verificano i package che qualcuno ha già elencato,
166
+ quindi non possono accorgersi di quello che nessuno ha elencato.
167
+ """
168
+ import general
169
+
170
+ presenti = {"general"} | {
171
+ modulo.name
172
+ for modulo in pkgutil.walk_packages(general.__path__, "general.")
173
+ if modulo.ispkg
174
+ }
175
+
176
+ assert presenti - set(SUPERFICIE) - SENZA_SUPERFICIE == set(), (
177
+ "package senza una voce in SUPERFICIE: aggiungila, oppure dichiaralo in SENZA_SUPERFICIE"
178
+ )
179
+
180
+
181
+ @pytest.mark.parametrize("package", sorted(SENZA_SUPERFICIE))
182
+ def test_i_package_senza_superficie_restano_vuoti(package: str) -> None:
183
+ """La radice vuota è una scelta di costo: se qualcuno ci esporta qualcosa, va saputo."""
184
+ modulo = importlib.import_module(package)
185
+
186
+ assert not hasattr(modulo, "__all__"), f"{package} ha smesso di essere vuoto"
187
+
188
+
189
+ # ------------------------------- i package pigri hanno due elenchi da tenere allineati
190
+
191
+
192
+ @pytest.mark.parametrize(
193
+ "package",
194
+ ["general.interface.base", "general.interface.repository", "general.interface.metadata"],
195
+ )
196
+ def test_i_package_pigri_hanno_all_e_modules_allineati(package: str) -> None:
197
+ """Il `__getattr__` pigro (ottim. 1.10) risolve da `_MODULES`, l'`import *` da `__all__`.
198
+
199
+ Delle due dimenticanze possibili una è rumorosa — un nome in `__all__` e non in
200
+ `_MODULES` fa fallire l'`import *` con `AttributeError` — e l'altra è **silenziosa**: un
201
+ nome in `_MODULES` e non in `__all__` funziona con l'import diretto e sparisce da
202
+ `import *` e da `dir()`.
203
+ """
204
+ modulo = importlib.import_module(package)
205
+
206
+ assert set(modulo.__all__) == set(modulo._MODULES)
@@ -61,7 +61,8 @@ class ArgomentoOstile:
61
61
 
62
62
  Serve a dimostrare che il decorator **non** serializza gli argomenti quando la
63
63
  chiamata va a buon fine: se lo facesse, questo ``__repr__`` farebbe fallire il
64
- test in modo esplicito.
64
+ test in modo esplicito. Sul percorso di errore dimostra il contrario: il decorator
65
+ lo rappresenta, e la sua eccezione non deve sostituire quella originale.
65
66
  """
66
67
 
67
68
  def __repr__(self) -> str:
@@ -249,8 +250,8 @@ def test_l_errore_registra_traceback_e_argomenti_in_un_solo_record(caplog) -> No
249
250
  aggregatore di log finirebbero interlacciati con altre richieste e l'elenco degli
250
251
  argomenti si staccherebbe dal suo traceback.
251
252
 
252
- Gli argomenti viaggiano come campi strutturati (``extra``) e non nel messaggio.
253
- Le chiavi **non possono** chiamarsi ``args``: è un attributo riservato di
253
+ Gli argomenti viaggiano due volte: troncati nel messaggio, per chi legge il log
254
+ testuale, e interi come campi strutturati (``extra``), per un handler JSON. Le chiavi **non possono** chiamarsi ``args``: è un attributo riservato di
254
255
  ``LogRecord`` e ``Logger.makeRecord`` solleva
255
256
  ``KeyError: "Attempt to overwrite 'args' in LogRecord"`` — che, dentro un ``except``,
256
257
  sostituirebbe l'eccezione originale e trasformerebbe anche un 404 atteso in un
@@ -273,39 +274,96 @@ def test_l_errore_registra_traceback_e_argomenti_in_un_solo_record(caplog) -> No
273
274
  assert errori[0].func_kwargs == {"nota": "controllo"}
274
275
 
275
276
 
276
- def test_gli_argomenti_non_compaiono_nell_output_del_formatter_di_default(caplog) -> None:
277
- """Caratterizzazione: con il formatter di ``LOGGING_CONFIG`` gli argomenti non si vedono.
277
+ def test_gli_argomenti_compaiono_nell_output_del_formatter_di_default(caplog) -> None:
278
+ """Con il formatter di ``LOGGING_CONFIG`` gli argomenti si vedono nella riga di errore.
278
279
 
279
- Sono attaccati al record come campi ``extra``, quindi disponibili a un handler
280
- strutturato (JSON), ma il formatter di default
281
- (``[%(asctime)s] %(levelname)s - %(message)s``) non li referenzia e l'informazione
282
- non raggiunge il log.
283
-
284
- Metterli direttamente nella stringa di formato non è possibile: ``%(func_args)s``
285
- farebbe fallire la formattazione di **ogni altro** record, che quell'attributo non
286
- ce l'ha. Serve una sottoclasse di ``Formatter`` che li accodi quando presenti.
287
-
288
- Se verrà aggiunta, **questo test deve fallire** e va sostituito con l'asserzione
289
- sull'output atteso.
280
+ Stanno nel **messaggio**, non in un campo che il formatter dovrebbe referenziare:
281
+ ``%(func_args)s`` nella stringa di formato farebbe fallire la formattazione di ogni
282
+ altro record, che quell'attributo non ce l'ha. Così valgono anche per chi configura
283
+ ``logging`` per conto proprio senza passare da ``init_logger``.
290
284
  """
291
285
  caplog.set_level(logging.INFO)
292
286
 
293
287
  @log
294
- def esplode(identificativo):
288
+ def esplode(identificativo, nota=None):
295
289
  raise ValueError("boom")
296
290
 
297
- # sentinella improbabile: un valore come `42` comparirebbe per caso nel
291
+ # sentinelle improbabili: un valore come `42` comparirebbe per caso nel
298
292
  # timestamp del formatter (`[2026-08-27 17:42:32]`), rendendo il test dipendente
299
293
  # dall'ora di esecuzione
300
- sentinella = "ARGOMENTO-CHE-NON-DEVE-COMPARIRE"
294
+ posizionale, per_nome = "ARGOMENTO-POSIZIONALE", "ARGOMENTO-PER-NOME"
301
295
  with pytest.raises(InternalServerException):
302
- esplode(sentinella)
296
+ esplode(posizionale, nota=per_nome)
303
297
 
304
298
  record = record_del_decorator(caplog)[0]
305
- assert record.func_args == (sentinella,), "l'argomento deve essere sul record"
306
-
307
299
  formattato = logging.Formatter("[%(asctime)s] %(levelname)s - %(message)s").format(record)
308
- assert sentinella not in formattato
300
+ assert f"args=('{posizionale}',)" in formattato
301
+ assert f"kwargs={{'nota': '{per_nome}'}}" in formattato
302
+
303
+
304
+ def test_anche_una_http_exception_riporta_gli_argomenti(caplog) -> None:
305
+ """Il 404 propagato invariato ha gli argomenti nel messaggio come un 500."""
306
+ caplog.set_level(logging.INFO)
307
+
308
+ @log
309
+ def cerca(identificativo):
310
+ raise EntityNotFoundException()
311
+
312
+ with pytest.raises(EntityNotFoundException):
313
+ cerca("ID-CERCATO")
314
+
315
+ assert "args=('ID-CERCATO',)" in pulito(record_del_decorator(caplog)[0].getMessage())
316
+
317
+
318
+ def test_gli_argomenti_voluminosi_vengono_troncati(caplog) -> None:
319
+ """Stringhe e collezioni lunghe sono accorciate, non stampate per intero.
320
+
321
+ Senza limite una chiamata con una lista di 1000 elementi o un payload di qualche
322
+ decina di KB produrrebbe una riga di log di quella dimensione a ogni errore. Il
323
+ valore intero resta disponibile in ``func_args``.
324
+ """
325
+ caplog.set_level(logging.INFO)
326
+
327
+ @log
328
+ def esplode(testo, elementi):
329
+ raise ValueError("boom")
330
+
331
+ testo, elementi = "x" * 10_000, list(range(1000))
332
+ with pytest.raises(InternalServerException):
333
+ esplode(testo, elementi)
334
+
335
+ record = record_del_decorator(caplog)[0]
336
+ messaggio = pulito(record.getMessage())
337
+ assert len(messaggio) < 1000, len(messaggio)
338
+ assert "..." in messaggio
339
+ assert record.func_args == (testo, elementi), "il record conserva i valori interi"
340
+
341
+
342
+ @pytest.mark.parametrize(
343
+ ("eccezione", "attesa"),
344
+ [(ValueError("boom"), InternalServerException), (EntityNotFoundException(), EntityNotFoundException)],
345
+ ids=["errore-applicativo", "http-exception"],
346
+ )
347
+ def test_un_repr_che_solleva_non_sostituisce_l_eccezione_originale(caplog, eccezione, attesa) -> None:
348
+ """Un argomento che non si lascia rappresentare non cambia l'eccezione propagata.
349
+
350
+ La rappresentazione si costruisce **dentro** l'``except``: se il ``__repr__`` di un
351
+ argomento sollevasse fin lì — un'entità ORM staccata dalla sessione che tenta un
352
+ lazy load, un ``__repr__`` scritto male — la sua eccezione prenderebbe il posto di
353
+ quella originale, e anche un 404 atteso diventerebbe un 500. Al suo posto compare
354
+ un segnaposto con il nome della classe.
355
+ """
356
+ caplog.set_level(logging.INFO)
357
+
358
+ @log
359
+ def esplode(payload, altro=None):
360
+ raise eccezione
361
+
362
+ with pytest.raises(attesa):
363
+ esplode(ArgomentoOstile(), altro=ArgomentoOstile())
364
+
365
+ messaggio = pulito(record_del_decorator(caplog)[0].getMessage())
366
+ assert messaggio.count("<ArgomentoOstile instance at 0x") == 2, messaggio
309
367
 
310
368
 
311
369
  # --------------------------------------------------------------------- async
@@ -26,6 +26,7 @@ from general.interface.repository.handler.base_handler import BaseHandler
26
26
  from general.interface.repository.handler.handler import Handler
27
27
  from general.interface.repository.handler.ilike_handler import ILikeHandler
28
28
  from general.interface.repository.handler.interval_handler import IntervalHandler
29
+ from general.interface.repository.handler.json_handler import JsonHandler
29
30
  from general.interface.repository.handler.json_ilike_handler import JsonILikeHandler
30
31
  from general.interface.repository.handler.or_handler import OrHandler
31
32
  from models import (
@@ -361,6 +362,55 @@ def test_la_pipeline_serve_anche_le_scritture(engine: Engine) -> None:
361
362
  assert "DELETE FROM documento" in sql_delete and "titolo ILIKE" in sql_delete
362
363
 
363
364
 
365
+ # ------------------------------------- handler passati come classe invece che istanza
366
+
367
+
368
+ def test_un_handler_si_registra_come_classe_oltre_che_come_istanza(engine: Engine) -> None:
369
+ """Il suggerimento di tipo prometteva entrambe le forme e ne reggeva una (voce 1b.9).
370
+
371
+ ``handlers=[SoftDeleteHandler]`` è quello che un consumatore scrive per attivare il soft
372
+ delete, e falliva con ``TypeError: conditions() missing 1 required positional argument:
373
+ 'self'`` **alla prima query**: lontano dalla causa, e con un messaggio che parla di
374
+ ``self``. La forma a classe vale per **tutti** gli handler della libreria: gli argomenti
375
+ oltre alle colonne hanno tutti un default.
376
+ """
377
+ da_classe = CrudRepository(entity=Documento, model=DocumentoModel, engine=engine, handlers=[JsonHandler])
378
+ da_istanza = CrudRepository(entity=Documento, model=DocumentoModel, engine=engine,
379
+ handlers=[JsonHandler(Documento.columns())])
380
+
381
+ sql = uno(captured_sql(da_classe.find, stato="attivo"))
382
+ assert clausola_where(uno(captured_sql(da_istanza.find, stato="attivo"))) == clausola_where(sql)
383
+ assert "attributi" in sql, "la chiave non-colonna è stata letta come sottochiave JSON"
384
+
385
+
386
+ def test_una_classe_che_serve_altri_argomenti_fallisce_alla_costruzione(engine: Engine) -> None:
387
+ """E lo dice: il nome dell'handler e cosa fare, dove il chiamante può leggerlo.
388
+
389
+ Nessun handler della libreria ricade in questo caso — ecco perché serve un handler
390
+ custom per esercitarlo: è il consumatore che può scriverne uno con argomenti
391
+ obbligatori, e prima lo scopriva alla prima query.
392
+ """
393
+
394
+ class HandlerEsigente(Handler):
395
+ __slots__ = ("soglia",)
396
+
397
+ def __init__(self, columns, soglia):
398
+ self.soglia = soglia
399
+
400
+ with pytest.raises(TypeError, match="HandlerEsigente needs more than the columns"):
401
+ CrudRepository(entity=Documento, model=DocumentoModel, engine=engine, handlers=[HandlerEsigente])
402
+
403
+
404
+ def test_handler_class_accetta_la_classe_e_l_istanza(engine: Engine) -> None:
405
+ """Il parametro gemello aveva il difetto opposto: dichiarava istanze e reggeva classi."""
406
+ da_classe = CrudRepository(entity=Documento, model=DocumentoModel, engine=engine, handler_class=BaseHandler)
407
+ da_istanza = CrudRepository(entity=Documento, model=DocumentoModel, engine=engine,
408
+ handler_class=BaseHandler(Documento.columns()))
409
+
410
+ atteso = clausola_where(uno(captured_sql(da_classe.find, titolo="atto")))
411
+ assert clausola_where(uno(captured_sql(da_istanza.find, titolo="atto"))) == atteso
412
+
413
+
364
414
  def test_handler_e_importabile_dal_package() -> None:
365
415
  """``Handler`` è la classe che i BE estendono: deve stare nel package."""
366
416
  from general.interface.repository.handler import Handler as Esportato
@@ -1,12 +0,0 @@
1
- """SQLAlchemy declarative base class and preconfigured Pydantic models."""
2
-
3
- from .base_model import *
4
- from .declarative_base import *
5
-
6
- __all__ = [
7
- "Base",
8
- "retrieve_mapper_entity",
9
- "retrieve_mapper_from_table",
10
- "ExtBaseModel",
11
- "CamelExtBaseModel",
12
- ]
@@ -1,12 +0,0 @@
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
4
- is reflected at runtime and the tables are resolved by name. Unlike the
5
- repositories, this layer does **not** use the handler pipeline and does **not** map
6
- the SQLAlchemy exceptions onto HTTP exceptions.
7
- """
8
-
9
- from .crud_metadata import *
10
- from .geom_metadata import *
11
-
12
- __all__ = ["CrudMetadata", "GeomMetadata"]
@@ -1,13 +0,0 @@
1
- """ORM repositories: generic CRUD, spatial, many-to-many and views."""
2
-
3
- from .crud_repository import *
4
- from .geometry_repository import *
5
- from .many_to_many_repository import *
6
- from .view_repository import *
7
-
8
- __all__ = [
9
- "CrudRepository",
10
- "GeometryRepository",
11
- "ManyToManyRepository",
12
- "ViewRepository",
13
- ]