python-general-be-lib 0.12.0__py3-none-any.whl → 0.12.2__py3-none-any.whl

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.
@@ -11,7 +11,7 @@ from sqlalchemy.sql.functions import count
11
11
  from ...exception.crud_exceptions import BadRequestException, EntityNotFoundException, HasNoAttributeException, InternalServerException, raise_http_exception
12
12
  from ...interface.repository.handler.base_handler import BaseHandler
13
13
  from ...interface.repository.handler.statement_manager import StatementManager
14
- from ...paginator_dto import PaginatorResponseModel, parse_pagination
14
+ from ...paginator_dto import PaginatorResponseModel, parse_pagination, _with_tiebreaker
15
15
 
16
16
 
17
17
  class CrudMetadata:
@@ -865,14 +865,17 @@ class CrudMetadata:
865
865
  page (int): Requested page number, 1-based. Default: 1.
866
866
  parsing_model (Type[BaseModel], optional): Model each row is serialized with.
867
867
  orders_by (dict[str, bool], optional): Ordering, as ``{column: ascending}``,
868
- with the clauses following the insertion order of the dictionary. When
869
- omitted no ordering is applied, and without a total order two pages can
870
- overlap.
868
+ with the clauses following the insertion order of the dictionary. The
869
+ primary key columns missing from it are appended, ascending, as a
870
+ tie-breaker, so that two pages cannot overlap; a key column already
871
+ passed keeps its position and direction. A table without a primary key,
872
+ such as a reflected view, gets no tie-breaker. When omitted no ordering
873
+ is applied at all, and the pages are not stable.
871
874
  **kwhere: Additional filters as keyword arguments.
872
875
 
873
876
  Returns:
874
877
  PaginatorResponseModel: Object carrying ``data``, ``tot_pages``, ``page``,
875
- ``limit`` and the ordering.
878
+ ``limit`` and the ordering requested, without the tie-breaker.
876
879
 
877
880
  Raises:
878
881
  HasNoAttributeException: If the table or one of the columns does not exist.
@@ -891,6 +894,9 @@ class CrudMetadata:
891
894
  orders_by: dict[str, bool] = None, **kwhere) -> PaginatorResponseModel:
892
895
  """Read the page and derive the total, running the ``COUNT`` only when needed.
893
896
 
897
+ A non-empty ordering gets the missing primary key columns appended, ascending, so
898
+ that it is total; the response reports it as requested, without them.
899
+
894
900
  Args:
895
901
  table (Table): Table to search on.
896
902
  connection (Connection): Connection to run the statements on.
@@ -898,7 +904,8 @@ class CrudMetadata:
898
904
  columns (list[str], optional): Columns to select.
899
905
  limit (int): Rows per page. 0 means no limit.
900
906
  page (int): Page number, 1-based.
901
- orders_by (dict[str, bool], optional): Ordering to apply.
907
+ orders_by (dict[str, bool], optional): Ordering requested. When empty,
908
+ nothing is ordered.
902
909
  **kwhere: Additional filters as keyword arguments.
903
910
 
904
911
  Returns:
@@ -911,8 +918,9 @@ class CrudMetadata:
911
918
 
912
919
  manager = self._statement_manager(table)
913
920
  stmt = self._select(table=table, columns=columns)
914
- stmt = self._add_orders_by_condition(stmt, table, orders_by)
915
- stmt = manager.limit_offset_condition(stmt=stmt, limit=limit,page=page)
921
+ if orders_by:
922
+ stmt = self._add_orders_by_condition(stmt, table, _with_tiebreaker(orders_by, table.primary_key.columns.keys()))
923
+ stmt = manager.limit_offset_condition(stmt=stmt, limit=limit, page=page)
916
924
  conditions = manager.conditions(where=dict(where))
917
925
  if conditions:
918
926
  stmt = stmt.where(*conditions)
@@ -16,7 +16,7 @@ from .handler.base_handler import BaseHandler
16
16
  from .handler.statement_manager import StatementManager
17
17
  from ...exception.crud_exceptions import BadRequestException, EntityNotFoundException, HasNoAttributeException, raise_http_exception
18
18
  from ...interface.base.declarative_base import Base
19
- from ...paginator_dto import PaginatorResponseModel, parse_pagination
19
+ from ...paginator_dto import PaginatorResponseModel, parse_pagination, _with_tiebreaker
20
20
 
21
21
 
22
22
  @lru_cache(maxsize=None)
@@ -169,14 +169,23 @@ class CrudRepository[Entity: Base | Type[Base]]:
169
169
  return _reduced_model(self.entity, frozenset(selected_columns))
170
170
 
171
171
  def _handle_exception(self, session: Session, e: Exception):
172
- """Close the session, log the error and raise an HTTP-friendly exception.
172
+ """Roll back and close the session, log the error and raise an HTTP-friendly exception.
173
173
 
174
174
  An ``OperationalError`` becomes a ``ServiceUnavailableException``, a
175
175
  ``NoResultFound`` an ``EntityNotFoundException``, a ``DataError`` or an
176
176
  ``IntegrityError`` a ``BadRequestException``; any other exception is re-raised as is.
177
177
 
178
+ ``close`` alone already rolls back the database transaction. What the explicit
179
+ ``rollback`` adds is the expiry of the ORM instances: an entity still held by the
180
+ caller raises ``DetachedInstanceError`` on access instead of exposing values that
181
+ were flushed but never committed. It runs on every error, a failed SELECT included,
182
+ since on PostgreSQL that aborts the transaction as well. An external session is
183
+ rolled back and closed too, so the caller's transaction is lost. A failure of the
184
+ rollback itself is logged and does not replace ``e``, and the session is closed
185
+ anyway.
186
+
178
187
  Args:
179
- session (Session): Session to close.
188
+ session (Session): Session to roll back and close.
180
189
  e (Exception): Caught exception to handle.
181
190
 
182
191
  Raises:
@@ -184,6 +193,11 @@ class CrudRepository[Entity: Base | Type[Base]]:
184
193
  EntityNotFoundException: If no record matches.
185
194
  BadRequestException: If the data is malformed or violates a constraint.
186
195
  """
196
+ try:
197
+ session.rollback()
198
+ except Exception:
199
+ # un rollback fallito non deve sostituire l'errore originale, né saltare la close
200
+ logging.exception("Rollback failed")
187
201
  session.close()
188
202
  logging.error(e)
189
203
  raise_http_exception(e)
@@ -195,12 +209,13 @@ class CrudRepository[Entity: Base | Type[Base]]:
195
209
  session (Session): Session to commit.
196
210
 
197
211
  Raises:
198
- ServiceUnavailableException | BadRequestException: On error during the commit.
212
+ ServiceUnavailableException | BadRequestException: On error during the commit,
213
+ after rolling back and closing the session.
199
214
  """
200
215
  try:
201
216
  session.commit()
202
217
  except Exception as e:
203
- self._handle_exception(session, e)
218
+ self._handle_exception(session=session, e=e)
204
219
 
205
220
  def _flush(self, session: Session):
206
221
  """Flush the session, handling the possible exceptions.
@@ -214,12 +229,13 @@ class CrudRepository[Entity: Base | Type[Base]]:
214
229
  session (Session): Session to flush.
215
230
 
216
231
  Raises:
217
- ServiceUnavailableException | BadRequestException: On error during the flush.
232
+ ServiceUnavailableException | BadRequestException: On error during the flush,
233
+ after rolling back and closing the session.
218
234
  """
219
235
  try:
220
236
  session.flush()
221
237
  except Exception as e:
222
- self._handle_exception(session, e)
238
+ self._handle_exception(session=session, e=e)
223
239
 
224
240
  def _entity_has_columns(self, *columns: str, entity: Type[Base] | Base = None) -> bool:
225
241
  """Check that every given name is a column of the entity.
@@ -441,7 +457,7 @@ class CrudRepository[Entity: Base | Type[Base]]:
441
457
  try:
442
458
  entity = session.get(self.entity, values)
443
459
  except Exception as e:
444
- self._handle_exception(session, e)
460
+ self._handle_exception(session=session, e=e)
445
461
  else:
446
462
  return [entity] if entity is not None else []
447
463
 
@@ -607,7 +623,7 @@ class CrudRepository[Entity: Base | Type[Base]]:
607
623
  try:
608
624
  session.add_all(entities)
609
625
  except Exception as e:
610
- self._handle_exception(session, e)
626
+ self._handle_exception(session=session, e=e)
611
627
  return self._return(session=session, entities=entities, keep_open=keep_open, commit=True, parsing_model=parsing_model)
612
628
 
613
629
  def update(self, session: Session = None, where: dict[str, Any] = None, returning: bool = True, parsing_model: Type[BaseModel] | BaseModel = None, **kwargs) -> list[Entity] | list[BaseModel]:
@@ -869,22 +885,25 @@ class CrudRepository[Entity: Base | Type[Base]]:
869
885
  therefore a single page.
870
886
  page (int): Requested page number, 1-based. Default: 1.
871
887
  order_by (str, optional): Column to order by. When ``None``, the first column of
872
- the primary key is used.
888
+ the primary key is used, followed by the other ones if the key is
889
+ composite.
873
890
  asc (bool): Ascending order if ``True``. Default: ``True``.
874
891
  parsing_model (Type[BaseModel], optional): Alternative model for the
875
892
  serialization.
876
893
  orders_by (dict[str, bool], optional): Ordering on several columns, as
877
894
  ``{column: ascending}``, with the clauses following the insertion order of
878
895
  the dictionary. Takes precedence over ``order_by``/``asc``. The primary key
879
- is **not** appended as a tie-breaker, so an ordering on a non-unique column
880
- does not give a total order and two pages can overlap: pass the key
881
- explicitly as the last entry when the ordering must be stable.
896
+ columns missing from the ordering are appended, ascending, as a
897
+ tie-breaker: an ordering on a non-unique column is not total, and with
898
+ ``LIMIT/OFFSET`` two pages could overlap and some rows never show up. A
899
+ key column the caller already passed keeps its position and direction.
882
900
  **kwhere: Filters as keyword arguments.
883
901
 
884
902
  Returns:
885
903
  PaginatorResponseModel[Entity]: Object carrying ``data``, ``tot_pages``,
886
904
  ``page``, ``limit``, ``orders_by``, plus ``order_by`` and ``asc`` holding
887
- the first pair of the ordering.
905
+ the first pair of the ordering. ``orders_by`` is the ordering requested,
906
+ without the appended tie-breaker.
888
907
  """
889
908
  keep_open = False if session is None else True
890
909
  session = self.open_session if session is None else session
@@ -897,15 +916,36 @@ class CrudRepository[Entity: Base | Type[Base]]:
897
916
  return paginated
898
917
 
899
918
  def _paging_find(self, session: Session, where: dict[str, Any] = None, columns: list[str] = None, exclude_columns: list[str] = None, limit: int = 0, page: int = 1, orders_by: dict[str, bool] = None, **kwhere):
919
+ """Read the page with a total ordering and derive the total, counting only when needed.
920
+
921
+ The primary key columns missing from ``orders_by`` are appended to the ``ORDER BY``,
922
+ ascending, so that ``LIMIT/OFFSET`` cuts every page from the same sequence of rows.
923
+ The response reports ``orders_by`` as requested, without them.
924
+
925
+ Args:
926
+ session (Session): Session to run the statements on.
927
+ where (dict[str, Any], optional): Filters. When ``None``, ``**kwhere`` is used.
928
+ columns (list[str], optional): Columns to include.
929
+ exclude_columns (list[str], optional): Columns to exclude.
930
+ limit (int): Rows per page. 0 means no limit.
931
+ page (int): Page number, 1-based.
932
+ orders_by (dict[str, bool], optional): Ordering requested, as
933
+ ``{column: ascending}``. When empty, the primary key ascending.
934
+ **kwhere: Filters as keyword arguments.
935
+
936
+ Returns:
937
+ PaginatorResponseModel: Page and total, with the raw entities as ``data``.
938
+ """
900
939
  if where is None:
901
940
  where = kwhere if kwhere else {}
941
+ primary_key = [column.key for column in self.entity.primary_key()]
902
942
  if not orders_by:
903
- orders_by = {self.entity.primary_key()[0].key: True}
943
+ orders_by = {primary_key[0]: True}
904
944
 
905
945
  stmt = self._select(entity=self.entity, columns=columns, exclude_columns=exclude_columns)
906
946
  stmt = self.stmt_manager.limit_offset_condition(stmt=stmt, limit=limit, page=page)
907
947
 
908
- for order_by, asc in orders_by.items():
948
+ for order_by, asc in _with_tiebreaker(orders_by, primary_key).items():
909
949
  stmt = self.stmt_manager.order_by_condition(stmt=stmt, order_by=order_by, asc=asc)
910
950
 
911
951
  conditions = self.stmt_manager.conditions(where=where)
@@ -1049,9 +1089,9 @@ class CrudRepository[Entity: Base | Type[Base]]:
1049
1089
  """Execute a SQLAlchemy statement and return the result as it is.
1050
1090
 
1051
1091
  The method owns the error handling and nothing else: every exception goes to
1052
- ``_handle_exception``, which closes the session and maps the error to an HTTP
1053
- status. Reading the result is left to the caller, which is the only one knowing the
1054
- shape it needs — ``.scalars().all()`` for entities, ``.scalar()`` for an aggregate,
1092
+ ``_handle_exception``, which rolls back and closes the session and maps the error
1093
+ to an HTTP status. Reading the result is left to the caller, which is the only one
1094
+ knowing the shape it needs — ``.scalars().all()`` for entities, ``.scalar()`` for an aggregate,
1055
1095
  ``.all()`` for the multi-column rows of a statement built with ``add_columns``,
1056
1096
  ``.rowcount`` for a DML without ``RETURNING``. The result is bound to the session,
1057
1097
  so it has to be consumed before the session is closed. ``Session.execute`` returns
@@ -1076,6 +1116,6 @@ class CrudRepository[Entity: Base | Type[Base]]:
1076
1116
  try:
1077
1117
  result = session.execute(statement=stmt, params=values)
1078
1118
  except Exception as e:
1079
- self._handle_exception(session, e)
1119
+ self._handle_exception(session=session, e=e)
1080
1120
  else:
1081
1121
  return result
general/paginator_dto.py CHANGED
@@ -1,7 +1,7 @@
1
1
  __all__ = ["PaginatorRequestModel", "PaginatorResponseModel", "parse_pagination"]
2
2
 
3
3
  import math
4
- from typing import Any, Optional, Sequence
4
+ from typing import Any, Iterable, Optional, Sequence
5
5
 
6
6
  from pydantic import Field
7
7
 
@@ -46,9 +46,11 @@ class PaginatorResponseModel[ResponseType](PaginatorBaseModel):
46
46
  Attributes:
47
47
  tot_pages (int): Total number of pages, computed by the repository.
48
48
  Alias: ``pagineTotali``.
49
- orders_by (dict[str, bool] | None): Whole ordering applied, as
49
+ orders_by (dict[str, bool] | None): Whole ordering requested, as
50
50
  ``{column: ascending}``. The inherited ``order_by``/``asc`` carry its first
51
- pair, which is the main criterion. Alias: ``campiOrdinamento``.
51
+ pair, which is the main criterion. The primary key columns that
52
+ ``paging_find`` appends as a tie-breaker are not listed: they are an
53
+ internal detail of the query. Alias: ``campiOrdinamento``.
52
54
  data (list[ResponseType]): Items of the current page.
53
55
  """
54
56
 
@@ -70,7 +72,7 @@ def parse_pagination(limit: int, page: int, orders_by: dict[str, bool], count: i
70
72
  Args:
71
73
  limit (int): Number of items per page.
72
74
  page (int): Current page.
73
- orders_by (dict[str, bool]): Ordering applied, as ``{column: ascending}``.
75
+ orders_by (dict[str, bool]): Ordering requested, as ``{column: ascending}``.
74
76
  count (int): Total number of filtered records.
75
77
  data (Sequence): Rows of the current page.
76
78
 
@@ -83,3 +85,27 @@ def parse_pagination(limit: int, page: int, orders_by: dict[str, bool], count: i
83
85
  tot_pages = math.ceil(count / limit) if count and limit > 0 else 1
84
86
 
85
87
  return PaginatorResponseModel(limit=limit, page=page, order_by=order_by, asc=asc, tot_pages=tot_pages, orders_by=orders_by, data=data)
88
+
89
+
90
+ def _with_tiebreaker(orders_by: dict[str, bool], keys: Iterable[str]) -> dict[str, bool]:
91
+ """Append to an ordering the key columns it does not contain yet, ascending.
92
+
93
+ ``LIMIT/OFFSET`` over an ordering that is not total is unstable: the database may
94
+ return the rows tied on the ordering columns in a different order from one query to
95
+ the next, so consecutive pages overlap and some rows are never shown. Appending the
96
+ primary key makes the ordering total. A key column the caller already ordered by is
97
+ left where it is and with its direction, since the ordering is total from that point
98
+ on anyway. Shared by the ORM and the metadata layer.
99
+
100
+ Args:
101
+ orders_by (dict[str, bool]): Ordering as ``{column: ascending}``. Never mutated.
102
+ keys (Iterable[str]): Names of the primary key columns, as the statement
103
+ manager resolves them. Empty when the table has no primary key, in which case
104
+ the ordering is returned as it is.
105
+
106
+ Returns:
107
+ dict[str, bool]: ``orders_by`` itself when nothing is missing, otherwise a new
108
+ dictionary with the missing key columns appended as ``True``.
109
+ """
110
+ missing = [key for key in keys if key not in orders_by]
111
+ return {**orders_by, **dict.fromkeys(missing, True)} if missing else orders_by
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-general-be-lib
3
- Version: 0.12.0
3
+ Version: 0.12.2
4
4
  Summary: General purpose backend library — SQLAlchemy CRUD/Geometry repositories, FastAPI exceptions, Pydantic base models, Keycloak JWT auth, 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
@@ -1,7 +1,7 @@
1
1
  general/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
2
  general/context.py,sha256=AbASV-jPnDM-M-Nih5c9vKHFlIS0kjp8Q1yuHC34FeA,1925
3
3
  general/logger.py,sha256=MnK8ZDA9x4xhX0KcpOxTieosnFIhFVwSa8b52Q4T9tA,5039
4
- general/paginator_dto.py,sha256=DB2hfcrckuEiHEIORLT4EgCcnX4AYYKGlHRr-l-Q1Cw,3741
4
+ general/paginator_dto.py,sha256=0aIdJIOXJlwoQ3d7w8A6tOZ1An6AWBjoHMhIqtZJhic,5221
5
5
  general/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
6
  general/auth/__init__.py,sha256=6cYCA6Ir-nnBK9z_bUfh_io3wEYjJUwHAN8mdhjtKeI,2239
7
7
  general/auth/jwt_model.py,sha256=KcopeNUPRSQOLvreoIuSo6kJybRtrmhzLoJ5oHFngGw,3447
@@ -17,14 +17,14 @@ general/interface/base/__init__.py,sha256=wRKbvb8lF33_2iG100mEQXz1tEkh9BF5bPcIi7
17
17
  general/interface/base/base_model.py,sha256=aIzrRZVUytmbgmEH1bya29rVFOm-Njinn_JXkgkBrr8,2575
18
18
  general/interface/base/declarative_base.py,sha256=J-q6nJIdYixFk9tgWjmGVII2cHeOazf0e4snaO_rHhI,9302
19
19
  general/interface/metadata/__init__.py,sha256=CawnfGK7WcJmtqi9CjEjBqCyX_vuHFK-Td1orXTH__U,1849
20
- general/interface/metadata/crud_metadata.py,sha256=SfV2__zgkTTeX1opLHDMRIeyB1DKiObr7pGOHkGVFlo,48120
20
+ general/interface/metadata/crud_metadata.py,sha256=4pK4ptX5w0HYv2DK8sj4cJeVfnGJRz9lAjiWgGvYyhs,48763
21
21
  general/interface/metadata/geom_metadata.py,sha256=xWNZnWhN_Om91pcqSsURKi5EUYNZh444zDqMwoOpq84,15508
22
22
  general/interface/mixin/__init__.py,sha256=M5-e6T_R63Utwh3_WcmoE3wanwIZjNjbgLYrrFlSpTA,659
23
23
  general/interface/mixin/audit_mixin.py,sha256=N2JDD9JxXDMeBqu0yAlhVWFOK3EeUA2b215hPvXytUU,1421
24
24
  general/interface/mixin/softdelete_mixin.py,sha256=526luzM4DBCorcq99kZ6BYqtQKKJCpnxX9rSEigfyIU,2148
25
25
  general/interface/mixin/timestamp_mixin.py,sha256=wmcIx2Y8jgVqg9A-1E61BvkcKCo52ZjKNHeKfXJmUl0,2220
26
26
  general/interface/repository/__init__.py,sha256=QQD6okLfRozC0uOZPGrwOUDKJyZTgpObvwJ3OOSJdbM,1813
27
- general/interface/repository/crud_repository.py,sha256=Hr7APBsN1uFqEfM78ZSIcgheg4ilvND4OMc7TBNOlcw,55962
27
+ general/interface/repository/crud_repository.py,sha256=W1kZyMw1VCOhbDYS5YpDBgXez0-0pzy6xFgcbR1Scb0,58469
28
28
  general/interface/repository/geometry_repository.py,sha256=Fmv0qhfqumnU5fiETMMAmiB0UyG43b19jlPXQ8cJ9CU,11289
29
29
  general/interface/repository/many_to_many_repository.py,sha256=KRJ2SMUGDYNoGV-ea541wlWg0FW4NQ_Ry85aJBarmC8,11183
30
30
  general/interface/repository/view_repository.py,sha256=wxDGw7kenuVrDd9nfh7-omw2m9Jex5WzF_lhEiqPoA0,7902
@@ -40,8 +40,8 @@ general/interface/repository/handler/soft_delete_handler.py,sha256=1WD1n6q5plyJ0
40
40
  general/interface/repository/handler/statement_manager.py,sha256=JSj6K-iQJ06kLdsMTOqK62cIvR87ioOA1LmLTGpqrB0,8205
41
41
  general/utils/__init__.py,sha256=_SPe88reIZ0JxwK7mp-u_p-9c4hCcZ-daUihi0gymNk,103
42
42
  general/utils/consts.py,sha256=nE75Ud7sVuDw9K_xmIJNjL_5f_BVJmwGNPGFYc26H3Y,264
43
- python_general_be_lib-0.12.0.dist-info/licenses/LICENSE,sha256=iUaO1XZyB9P3Tmog0OILuTisP6vXGe3QKz-4yRTxOFk,1069
44
- python_general_be_lib-0.12.0.dist-info/METADATA,sha256=5RrV-gffYJqAwx09wHOCqK-xN7GDSqK7dKgeP7Rfcyk,3995
45
- python_general_be_lib-0.12.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
46
- python_general_be_lib-0.12.0.dist-info/top_level.txt,sha256=tTZePW8_CNUqSgKFd2SEH72ZbnhS0OYjRsgcv0ikSFY,8
47
- python_general_be_lib-0.12.0.dist-info/RECORD,,
43
+ python_general_be_lib-0.12.2.dist-info/licenses/LICENSE,sha256=iUaO1XZyB9P3Tmog0OILuTisP6vXGe3QKz-4yRTxOFk,1069
44
+ python_general_be_lib-0.12.2.dist-info/METADATA,sha256=y3oeQsu0X1IYTfpOIE4pblBBEvMr9KQu8psiK4TIeiQ,3995
45
+ python_general_be_lib-0.12.2.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
46
+ python_general_be_lib-0.12.2.dist-info/top_level.txt,sha256=tTZePW8_CNUqSgKFd2SEH72ZbnhS0OYjRsgcv0ikSFY,8
47
+ python_general_be_lib-0.12.2.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (84.0.0)
2
+ Generator: setuptools (84.2.1)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5