python-general-be-lib 0.11.1__tar.gz → 0.11.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/PKG-INFO +2 -1
- {python_general_be_lib-0.11.1 → 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.1 → python_general_be_lib-0.11.3}/general/interface/base/declarative_base.py +9 -1
- python_general_be_lib-0.11.3/general/interface/metadata/__init__.py +58 -0
- {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/general/interface/metadata/crud_metadata.py +2 -2
- python_general_be_lib-0.11.3/general/interface/repository/__init__.py +59 -0
- {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/general/interface/repository/crud_repository.py +10 -3
- {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/general/interface/repository/handler/handler.py +0 -19
- {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/general/interface/repository/handler/statement_manager.py +36 -4
- {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/general/logger.py +11 -3
- {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/pyproject.toml +2 -1
- {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/python_general_be_lib.egg-info/PKG-INFO +2 -1
- {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/python_general_be_lib.egg-info/SOURCES.txt +1 -0
- {python_general_be_lib-0.11.1 → 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.1 → python_general_be_lib-0.11.3}/tests/test_find_one.py +8 -0
- {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/tests/test_get.py +8 -0
- {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/tests/test_import_pubblici.py +64 -0
- {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/tests/test_logger.py +81 -23
- {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/tests/test_metadata_scorciatoie.py +6 -2
- {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/tests/test_pipeline_handler.py +50 -0
- {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/tests/test_to_pydantic.py +25 -0
- python_general_be_lib-0.11.1/general/interface/base/__init__.py +0 -12
- python_general_be_lib-0.11.1/general/interface/metadata/__init__.py +0 -12
- python_general_be_lib-0.11.1/general/interface/repository/__init__.py +0 -13
- {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/LICENSE +0 -0
- {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/README.md +0 -0
- {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/general/__init__.py +0 -0
- {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/general/context.py +0 -0
- {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/general/exception/__init__.py +0 -0
- {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/general/exception/access_exceptions.py +0 -0
- {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/general/exception/exception_interface.py +0 -0
- {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/general/interface/__init__.py +0 -0
- {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/general/interface/base/base_model.py +0 -0
- {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/general/interface/metadata/geom_metadata.py +0 -0
- {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/general/interface/mixin/__init__.py +0 -0
- {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/general/interface/mixin/audit_mixin.py +0 -0
- {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/general/interface/mixin/softdelete_mixin.py +0 -0
- {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/general/interface/mixin/timestamp_mixin.py +0 -0
- {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/general/interface/repository/geometry_repository.py +0 -0
- {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/general/interface/repository/handler/__init__.py +0 -0
- {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/general/interface/repository/handler/base_handler.py +0 -0
- {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/general/interface/repository/handler/ilike_handler.py +0 -0
- {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/general/interface/repository/handler/interval_handler.py +0 -0
- {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/general/interface/repository/handler/json_handler.py +0 -0
- {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/general/interface/repository/handler/json_ilike_handler.py +0 -0
- {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/general/interface/repository/handler/or_handler.py +0 -0
- {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/general/interface/repository/handler/soft_delete_handler.py +0 -0
- {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/general/interface/repository/many_to_many_repository.py +0 -0
- {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/general/interface/repository/view_repository.py +0 -0
- {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/general/paginator_dto.py +0 -0
- {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/general/py.typed +0 -0
- {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/general/utils/__init__.py +0 -0
- {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/general/utils/consts.py +0 -0
- {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/python_general_be_lib.egg-info/dependency_links.txt +0 -0
- {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/python_general_be_lib.egg-info/top_level.txt +0 -0
- {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/setup.cfg +0 -0
- {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/tests/test_caricamento_parziale.py +0 -0
- {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/tests/test_compiled_cache.py +0 -0
- {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/tests/test_engine_defaults.py +0 -0
- {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/tests/test_entity_has_columns.py +0 -0
- {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/tests/test_execute_contratto.py +0 -0
- {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/tests/test_exists.py +0 -0
- {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/tests/test_has_columns.py +0 -0
- {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/tests/test_many_to_many.py +0 -0
- {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/tests/test_metadata_eccezioni.py +0 -0
- {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/tests/test_metadata_geom.py +0 -0
- {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/tests/test_metadata_paginazione.py +0 -0
- {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/tests/test_metadata_pipeline.py +0 -0
- {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/tests/test_mixin.py +0 -0
- {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/tests/test_negazione.py +0 -0
- {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/tests/test_ordinamento.py +0 -0
- {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/tests/test_query_count.py +0 -0
- {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/tests/test_retrieve_mapper.py +0 -0
- {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/tests/test_smoke_crud.py +0 -0
- {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/tests/test_softdelete.py +0 -0
- {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/tests/test_sql_snapshot.py +0 -0
- {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/tests/test_to_dict.py +0 -0
- {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/tests/test_upsert_chiavi.py +0 -0
- {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/tests/test_valori_falsy.py +0 -0
- {python_general_be_lib-0.11.1 → python_general_be_lib-0.11.3}/tests/test_view_repository.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-general-be-lib
|
|
3
|
-
Version: 0.11.
|
|
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.1 → 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
|
|
@@ -31,6 +30,11 @@ class Base:
|
|
|
31
30
|
or nothing: passing ``__config__`` puts the whole configuration in the hands of the
|
|
32
31
|
caller, and passing ``__base__`` lets the base carry its own.
|
|
33
32
|
|
|
33
|
+
The generated class is stamped with the module of the entity, unless the caller
|
|
34
|
+
passes ``__module__``: ``create_model`` reads the calling frame otherwise, so every
|
|
35
|
+
generated model would claim to live in this module and a ``repr`` would point at
|
|
36
|
+
the library instead of the entity.
|
|
37
|
+
|
|
34
38
|
Args:
|
|
35
39
|
model_name (str, optional): Name of the generated model. When not given,
|
|
36
40
|
``{ClassName}Model`` is used.
|
|
@@ -49,6 +53,10 @@ class Base:
|
|
|
49
53
|
Returns:
|
|
50
54
|
Type[BaseModel]: Dynamically generated Pydantic class.
|
|
51
55
|
"""
|
|
56
|
+
from geoalchemy2 import Geometry, WKBElement
|
|
57
|
+
|
|
58
|
+
create_model_kwargs.setdefault("__module__", cls.__module__)
|
|
59
|
+
|
|
52
60
|
if "__config__" not in create_model_kwargs.keys() and "__base__" not in create_model_kwargs.keys():
|
|
53
61
|
create_model_kwargs["__config__"] = ConfigDict(from_attributes=True, populate_by_name=True, arbitrary_types_allowed=True, validate_default=True)
|
|
54
62
|
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""CRUD and spatial layer over reflected ``Table`` objects, without the ORM.
|
|
2
|
+
|
|
3
|
+
Alternative to the repositories to operate on tables having no ORM model: the schema is
|
|
4
|
+
reflected at runtime and the tables are resolved by name. It goes through the **same**
|
|
5
|
+
handler pipeline as the ORM layer, built per table from the reflected ``Table.c``, and maps
|
|
6
|
+
the SQLAlchemy exceptions onto the same HTTP exceptions.
|
|
7
|
+
|
|
8
|
+
The public symbols are resolved lazily (PEP 562): importing this package does not import
|
|
9
|
+
the modules behind them, so reaching one of them does not pay for the heavier siblings.
|
|
10
|
+
The ``TYPE_CHECKING`` block is what keeps the static types — without it a type checker
|
|
11
|
+
would see every symbol as ``Any``, silently, and the ``py.typed`` marker would promise
|
|
12
|
+
types the consumer does not get.
|
|
13
|
+
"""
|
|
14
|
+
from typing import TYPE_CHECKING
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"CrudMetadata",
|
|
18
|
+
"GeomMetadata",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING: # i type checker leggono da qui, il runtime no
|
|
22
|
+
from .crud_metadata import CrudMetadata
|
|
23
|
+
from .geom_metadata import GeomMetadata
|
|
24
|
+
|
|
25
|
+
_MODULES = {
|
|
26
|
+
"CrudMetadata": ".crud_metadata",
|
|
27
|
+
"GeomMetadata": ".geom_metadata",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def __getattr__(name: str):
|
|
32
|
+
"""Resolve a public symbol, importing its module on first access.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
name (str): Name of the attribute being read.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
Any: The requested symbol.
|
|
39
|
+
|
|
40
|
+
Raises:
|
|
41
|
+
AttributeError: If the name is not part of the public surface.
|
|
42
|
+
"""
|
|
43
|
+
module = _MODULES.get(name)
|
|
44
|
+
if module is None:
|
|
45
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
46
|
+
|
|
47
|
+
from importlib import import_module
|
|
48
|
+
|
|
49
|
+
return getattr(import_module(module, __name__), name)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def __dir__() -> list[str]:
|
|
53
|
+
"""List the public surface without importing anything.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
list[str]: The names in ``__all__``.
|
|
57
|
+
"""
|
|
58
|
+
return list(__all__)
|
|
@@ -342,7 +342,7 @@ class CrudMetadata:
|
|
|
342
342
|
if rows:
|
|
343
343
|
return rows[0]
|
|
344
344
|
if strict:
|
|
345
|
-
raise EntityNotFoundException()
|
|
345
|
+
raise EntityNotFoundException(custom_message=table.name)
|
|
346
346
|
return None
|
|
347
347
|
|
|
348
348
|
def _get(self, table: Table, connection: Connection, pk: Any, columns: list[str] = None, **kwhere):
|
|
@@ -429,7 +429,7 @@ class CrudMetadata:
|
|
|
429
429
|
if rows:
|
|
430
430
|
return rows[0]
|
|
431
431
|
if strict:
|
|
432
|
-
raise EntityNotFoundException()
|
|
432
|
+
raise EntityNotFoundException(custom_message=table.name)
|
|
433
433
|
return None
|
|
434
434
|
|
|
435
435
|
def insert(self, from_table: str, connection: Connection = None, models: list[Type[BaseModel] | BaseModel] = None, values: list[dict[str, Any]] = None, returning: bool = True,
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""ORM repositories: generic CRUD, spatial, many-to-many and views.
|
|
2
|
+
|
|
3
|
+
The public symbols are resolved lazily (PEP 562): importing this package does not import
|
|
4
|
+
the modules behind them, so reaching one of them does not pay for the heavier siblings.
|
|
5
|
+
The ``TYPE_CHECKING`` block is what keeps the static types — without it a type checker
|
|
6
|
+
would see every symbol as ``Any``, silently, and the ``py.typed`` marker would promise
|
|
7
|
+
types the consumer does not get.
|
|
8
|
+
"""
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"CrudRepository",
|
|
13
|
+
"GeometryRepository",
|
|
14
|
+
"ManyToManyRepository",
|
|
15
|
+
"ViewRepository",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING: # i type checker leggono da qui, il runtime no
|
|
19
|
+
from .crud_repository import CrudRepository
|
|
20
|
+
from .geometry_repository import GeometryRepository
|
|
21
|
+
from .many_to_many_repository import ManyToManyRepository
|
|
22
|
+
from .view_repository import ViewRepository
|
|
23
|
+
|
|
24
|
+
_MODULES = {
|
|
25
|
+
"CrudRepository": ".crud_repository",
|
|
26
|
+
"GeometryRepository": ".geometry_repository",
|
|
27
|
+
"ManyToManyRepository": ".many_to_many_repository",
|
|
28
|
+
"ViewRepository": ".view_repository",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def __getattr__(name: str):
|
|
33
|
+
"""Resolve a public symbol, importing its module on first access.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
name (str): Name of the attribute being read.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
Any: The requested symbol.
|
|
40
|
+
|
|
41
|
+
Raises:
|
|
42
|
+
AttributeError: If the name is not part of the public surface.
|
|
43
|
+
"""
|
|
44
|
+
module = _MODULES.get(name)
|
|
45
|
+
if module is None:
|
|
46
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
47
|
+
|
|
48
|
+
from importlib import import_module
|
|
49
|
+
|
|
50
|
+
return getattr(import_module(module, __name__), name)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def __dir__() -> list[str]:
|
|
54
|
+
"""List the public surface without importing anything.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
list[str]: The names in ``__all__``.
|
|
58
|
+
"""
|
|
59
|
+
return list(__all__)
|
|
@@ -147,7 +147,14 @@ class CrudRepository[Entity: Base | Type[Base]]:
|
|
|
147
147
|
self.columns = self.entity.columns()
|
|
148
148
|
self._relationship_attributes = None # popolato lazy dalla property relationship_attributes
|
|
149
149
|
|
|
150
|
-
|
|
150
|
+
# ``handler_class`` accetta una classe — che si costruisce dalle colonne — oppure
|
|
151
|
+
# un'istanza già pronta: il suggerimento di tipo prometteva entrambe e ne reggeva una
|
|
152
|
+
if handler_class is None:
|
|
153
|
+
base_handler = BaseHandler(self.columns)
|
|
154
|
+
elif isinstance(handler_class, type):
|
|
155
|
+
base_handler = handler_class(self.columns)
|
|
156
|
+
else:
|
|
157
|
+
base_handler = handler_class
|
|
151
158
|
|
|
152
159
|
self.stmt_manager = StatementManager(handlers=handlers, base_handler=base_handler)
|
|
153
160
|
|
|
@@ -423,7 +430,7 @@ class CrudRepository[Entity: Base | Type[Base]]:
|
|
|
423
430
|
if records:
|
|
424
431
|
return records[0]
|
|
425
432
|
if strict:
|
|
426
|
-
raise EntityNotFoundException()
|
|
433
|
+
raise EntityNotFoundException(custom_message=self.entity.__name__)
|
|
427
434
|
return None
|
|
428
435
|
|
|
429
436
|
def _get(self, session: Session, pk: Any, columns: list[str] = None, exclude_columns: list[str] = None, **kwhere):
|
|
@@ -523,7 +530,7 @@ class CrudRepository[Entity: Base | Type[Base]]:
|
|
|
523
530
|
if records:
|
|
524
531
|
return records[0]
|
|
525
532
|
if strict:
|
|
526
|
-
raise EntityNotFoundException()
|
|
533
|
+
raise EntityNotFoundException(custom_message=self.entity.__name__)
|
|
527
534
|
return None
|
|
528
535
|
|
|
529
536
|
def _find(self, session: Session, where: dict[str, Any] = None, columns: list[str] = None, exclude_columns: list[str] = None, limit: int = 0, orders_by: dict[str, bool] = None, **kwhere):
|
|
@@ -32,22 +32,3 @@ class Handler:
|
|
|
32
32
|
the handler claims nothing.
|
|
33
33
|
"""
|
|
34
34
|
return []
|
|
35
|
-
|
|
36
|
-
def handle(self, stmt, neq=False, where=None):
|
|
37
|
-
"""Apply the conditions of this handler alone to a statement.
|
|
38
|
-
|
|
39
|
-
Adapter for the isolated use of a handler, outside the ``StatementManager``
|
|
40
|
-
pipeline: it collects the conditions of ``conditions`` and adds them with a single
|
|
41
|
-
``.where()`` call. Nothing is applied when the handler produces no condition.
|
|
42
|
-
|
|
43
|
-
Args:
|
|
44
|
-
stmt (Select | Update | Delete): Statement to apply the filters to.
|
|
45
|
-
neq (bool): If ``True``, the conditions are negated. Default: ``False``.
|
|
46
|
-
where (dict[str, Any]): Filters. The keys the handler claims are removed
|
|
47
|
-
in-place. Default: ``None``.
|
|
48
|
-
|
|
49
|
-
Returns:
|
|
50
|
-
Select | Update | Delete: Statement with the WHERE conditions applied.
|
|
51
|
-
"""
|
|
52
|
-
conditions = self.conditions(neq=neq, where=where)
|
|
53
|
-
return stmt.where(*conditions) if conditions else stmt
|
|
@@ -5,6 +5,8 @@ from typing import Any, Type
|
|
|
5
5
|
from sqlalchemy import ColumnElement, Delete, Select, Update
|
|
6
6
|
|
|
7
7
|
from general.exception.crud_exceptions import HasNoAttributeException
|
|
8
|
+
from sqlalchemy.sql.base import ReadOnlyColumnCollection
|
|
9
|
+
|
|
8
10
|
from .base_handler import BaseHandler
|
|
9
11
|
|
|
10
12
|
|
|
@@ -27,19 +29,49 @@ class StatementManager:
|
|
|
27
29
|
def __init__(self, handlers: list[BaseHandler | Type[BaseHandler]] = None, base_handler: BaseHandler = None):
|
|
28
30
|
"""Initialize the manager with the handler list and the mandatory base handler.
|
|
29
31
|
|
|
32
|
+
A handler can be given as an instance or as a class: a class is built with the
|
|
33
|
+
columns of the ``base_handler``, which is what every handler needs and the only
|
|
34
|
+
thing some of them need. Building it here is what makes a wrong handler fail at
|
|
35
|
+
construction, where the caller can read the cause, instead of at the first query.
|
|
36
|
+
|
|
30
37
|
Args:
|
|
31
|
-
handlers (list[BaseHandler], optional): Additional handlers
|
|
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
|
+
)
|
|
@@ -56,6 +56,14 @@ def test_con_strict_alza_il_404(repo: CrudRepository) -> None:
|
|
|
56
56
|
assert errore.value.status_code == 404
|
|
57
57
|
|
|
58
58
|
|
|
59
|
+
def test_il_404_nomina_l_entita(repo: CrudRepository) -> None:
|
|
60
|
+
"""Con più letture in fila il 404 deve dire **quale** è andata a vuoto."""
|
|
61
|
+
with pytest.raises(EntityNotFoundException) as errore:
|
|
62
|
+
repo.find_one(nome="nessuno", strict=True)
|
|
63
|
+
|
|
64
|
+
assert errore.value.detail == "Dato non trovato - Utente"
|
|
65
|
+
|
|
66
|
+
|
|
59
67
|
def test_con_strict_la_sessione_viene_chiusa_prima_del_raise(repo: CrudRepository, engine: Engine) -> None:
|
|
60
68
|
"""Il ``raise`` sta **dopo** ``_return``, che è quello che chiude la sessione.
|
|
61
69
|
|
|
@@ -62,6 +62,14 @@ def test_con_strict_alza_il_404(repo: CrudRepository) -> None:
|
|
|
62
62
|
assert errore.value.status_code == 404
|
|
63
63
|
|
|
64
64
|
|
|
65
|
+
def test_il_404_nomina_l_entita(repo: CrudRepository) -> None:
|
|
66
|
+
"""Con più letture in fila il 404 deve dire **quale** è andata a vuoto."""
|
|
67
|
+
with pytest.raises(EntityNotFoundException) as errore:
|
|
68
|
+
repo.get(999, strict=True)
|
|
69
|
+
|
|
70
|
+
assert errore.value.detail == "Dato non trovato - Utente"
|
|
71
|
+
|
|
72
|
+
|
|
65
73
|
def test_costa_una_sola_query(repo: CrudRepository, engine: Engine) -> None:
|
|
66
74
|
with assert_query_count(engine, 1, verbs=["SELECT"]):
|
|
67
75
|
repo.get(1)
|