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.
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/PKG-INFO +2 -1
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/general/exception/crud_exceptions.py +2 -1
- python_general_be_lib-0.11.3/general/interface/base/__init__.py +59 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/general/interface/base/declarative_base.py +2 -1
- python_general_be_lib-0.11.3/general/interface/metadata/__init__.py +58 -0
- python_general_be_lib-0.11.3/general/interface/repository/__init__.py +59 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/general/interface/repository/crud_repository.py +8 -1
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/general/interface/repository/handler/handler.py +0 -19
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/general/interface/repository/handler/statement_manager.py +36 -4
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/general/logger.py +11 -3
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/pyproject.toml +2 -1
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/python_general_be_lib.egg-info/PKG-INFO +2 -1
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/python_general_be_lib.egg-info/SOURCES.txt +1 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/python_general_be_lib.egg-info/requires.txt +1 -0
- python_general_be_lib-0.11.3/tests/test_costo_import.py +155 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/tests/test_import_pubblici.py +64 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/tests/test_logger.py +81 -23
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/tests/test_pipeline_handler.py +50 -0
- python_general_be_lib-0.11.2/general/interface/base/__init__.py +0 -12
- python_general_be_lib-0.11.2/general/interface/metadata/__init__.py +0 -12
- python_general_be_lib-0.11.2/general/interface/repository/__init__.py +0 -13
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/LICENSE +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/README.md +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/general/__init__.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/general/context.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/general/exception/__init__.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/general/exception/access_exceptions.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/general/exception/exception_interface.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/general/interface/__init__.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/general/interface/base/base_model.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/general/interface/metadata/crud_metadata.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/general/interface/metadata/geom_metadata.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/general/interface/mixin/__init__.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/general/interface/mixin/audit_mixin.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/general/interface/mixin/softdelete_mixin.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/general/interface/mixin/timestamp_mixin.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/general/interface/repository/geometry_repository.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/general/interface/repository/handler/__init__.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/general/interface/repository/handler/base_handler.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/general/interface/repository/handler/ilike_handler.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/general/interface/repository/handler/interval_handler.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/general/interface/repository/handler/json_handler.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/general/interface/repository/handler/json_ilike_handler.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/general/interface/repository/handler/or_handler.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/general/interface/repository/handler/soft_delete_handler.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/general/interface/repository/many_to_many_repository.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/general/interface/repository/view_repository.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/general/paginator_dto.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/general/py.typed +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/general/utils/__init__.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/general/utils/consts.py +0 -0
- {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
- {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
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/setup.cfg +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/tests/test_caricamento_parziale.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/tests/test_compiled_cache.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/tests/test_engine_defaults.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/tests/test_entity_has_columns.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/tests/test_execute_contratto.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/tests/test_exists.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/tests/test_find_one.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/tests/test_get.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/tests/test_has_columns.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/tests/test_many_to_many.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/tests/test_metadata_eccezioni.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/tests/test_metadata_geom.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/tests/test_metadata_paginazione.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/tests/test_metadata_pipeline.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/tests/test_metadata_scorciatoie.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/tests/test_mixin.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/tests/test_negazione.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/tests/test_ordinamento.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/tests/test_query_count.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/tests/test_retrieve_mapper.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/tests/test_smoke_crud.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/tests/test_softdelete.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/tests/test_sql_snapshot.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/tests/test_to_dict.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/tests/test_to_pydantic.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/tests/test_upsert_chiavi.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/tests/test_valori_falsy.py +0 -0
- {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.
|
|
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
|
{python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/general/exception/crud_exceptions.py
RENAMED
|
@@ -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
|
-
|
|
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
|
|
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("
|
|
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
|
|
104
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
@@ -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
|
|
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
|
|
277
|
-
"""
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
294
|
+
posizionale, per_nome = "ARGOMENTO-POSIZIONALE", "ARGOMENTO-PER-NOME"
|
|
301
295
|
with pytest.raises(InternalServerException):
|
|
302
|
-
esplode(
|
|
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
|
|
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
|
{python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/tests/test_pipeline_handler.py
RENAMED
|
@@ -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
|
-
]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/general/exception/access_exceptions.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/general/interface/base/base_model.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/general/interface/mixin/__init__.py
RENAMED
|
File without changes
|
{python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/general/interface/mixin/audit_mixin.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/tests/test_caricamento_parziale.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/tests/test_entity_has_columns.py
RENAMED
|
File without changes
|
{python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/tests/test_execute_contratto.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/tests/test_metadata_eccezioni.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/tests/test_metadata_paginazione.py
RENAMED
|
File without changes
|
{python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/tests/test_metadata_pipeline.py
RENAMED
|
File without changes
|
{python_general_be_lib-0.11.2 → python_general_be_lib-0.11.3}/tests/test_metadata_scorciatoie.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|