python3-commons 0.8.34__tar.gz → 0.8.36__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. {python3_commons-0.8.34 → python3_commons-0.8.36}/.pre-commit-config.yaml +2 -2
  2. {python3_commons-0.8.34/src/python3_commons.egg-info → python3_commons-0.8.36}/PKG-INFO +5 -5
  3. {python3_commons-0.8.34 → python3_commons-0.8.36}/pyproject.toml +7 -6
  4. python3_commons-0.8.36/src/python3_commons/db/models/__init__.py +8 -0
  5. {python3_commons-0.8.34 → python3_commons-0.8.36}/src/python3_commons/helpers.py +9 -7
  6. python3_commons-0.8.36/src/python3_commons/object_storage.py +183 -0
  7. {python3_commons-0.8.34 → python3_commons-0.8.36/src/python3_commons.egg-info}/PKG-INFO +5 -5
  8. {python3_commons-0.8.34 → python3_commons-0.8.36}/src/python3_commons.egg-info/requires.txt +4 -4
  9. {python3_commons-0.8.34 → python3_commons-0.8.36}/uv.lock +291 -234
  10. python3_commons-0.8.34/src/python3_commons/db/models/__init__.py +0 -2
  11. python3_commons-0.8.34/src/python3_commons/object_storage.py +0 -127
  12. {python3_commons-0.8.34 → python3_commons-0.8.36}/.coveragerc +0 -0
  13. {python3_commons-0.8.34 → python3_commons-0.8.36}/.github/workflows/python-publish.yaml +0 -0
  14. {python3_commons-0.8.34 → python3_commons-0.8.36}/.github/workflows/release-on-tag-push.yml +0 -0
  15. {python3_commons-0.8.34 → python3_commons-0.8.36}/.gitignore +0 -0
  16. {python3_commons-0.8.34 → python3_commons-0.8.36}/.python-version +0 -0
  17. {python3_commons-0.8.34 → python3_commons-0.8.36}/AUTHORS.rst +0 -0
  18. {python3_commons-0.8.34 → python3_commons-0.8.36}/CHANGELOG.rst +0 -0
  19. {python3_commons-0.8.34 → python3_commons-0.8.36}/LICENSE +0 -0
  20. {python3_commons-0.8.34 → python3_commons-0.8.36}/README.md +0 -0
  21. {python3_commons-0.8.34 → python3_commons-0.8.36}/README.rst +0 -0
  22. {python3_commons-0.8.34 → python3_commons-0.8.36}/docs/Makefile +0 -0
  23. {python3_commons-0.8.34 → python3_commons-0.8.36}/docs/_static/.gitignore +0 -0
  24. {python3_commons-0.8.34 → python3_commons-0.8.36}/docs/authors.rst +0 -0
  25. {python3_commons-0.8.34 → python3_commons-0.8.36}/docs/changelog.rst +0 -0
  26. {python3_commons-0.8.34 → python3_commons-0.8.36}/docs/conf.py +0 -0
  27. {python3_commons-0.8.34 → python3_commons-0.8.36}/docs/index.rst +0 -0
  28. {python3_commons-0.8.34 → python3_commons-0.8.36}/docs/license.rst +0 -0
  29. {python3_commons-0.8.34 → python3_commons-0.8.36}/setup.cfg +0 -0
  30. {python3_commons-0.8.34 → python3_commons-0.8.36}/src/python3_commons/__init__.py +0 -0
  31. {python3_commons-0.8.34 → python3_commons-0.8.36}/src/python3_commons/api_client.py +0 -0
  32. {python3_commons-0.8.34 → python3_commons-0.8.36}/src/python3_commons/audit.py +0 -0
  33. {python3_commons-0.8.34 → python3_commons-0.8.36}/src/python3_commons/auth.py +0 -0
  34. {python3_commons-0.8.34 → python3_commons-0.8.36}/src/python3_commons/cache.py +0 -0
  35. {python3_commons-0.8.34 → python3_commons-0.8.36}/src/python3_commons/conf.py +0 -0
  36. {python3_commons-0.8.34 → python3_commons-0.8.36}/src/python3_commons/db/__init__.py +0 -0
  37. {python3_commons-0.8.34 → python3_commons-0.8.36}/src/python3_commons/db/helpers.py +0 -0
  38. {python3_commons-0.8.34 → python3_commons-0.8.36}/src/python3_commons/db/models/auth.py +0 -0
  39. {python3_commons-0.8.34 → python3_commons-0.8.36}/src/python3_commons/db/models/common.py +0 -0
  40. {python3_commons-0.8.34 → python3_commons-0.8.36}/src/python3_commons/db/models/rbac.py +0 -0
  41. {python3_commons-0.8.34 → python3_commons-0.8.36}/src/python3_commons/fs.py +0 -0
  42. {python3_commons-0.8.34 → python3_commons-0.8.36}/src/python3_commons/log/__init__.py +0 -0
  43. {python3_commons-0.8.34 → python3_commons-0.8.36}/src/python3_commons/log/filters.py +0 -0
  44. {python3_commons-0.8.34 → python3_commons-0.8.36}/src/python3_commons/log/formatters.py +0 -0
  45. {python3_commons-0.8.34 → python3_commons-0.8.36}/src/python3_commons/permissions.py +0 -0
  46. {python3_commons-0.8.34 → python3_commons-0.8.36}/src/python3_commons/serializers/__init__.py +0 -0
  47. {python3_commons-0.8.34 → python3_commons-0.8.36}/src/python3_commons/serializers/json.py +0 -0
  48. {python3_commons-0.8.34 → python3_commons-0.8.36}/src/python3_commons/serializers/msgpack.py +0 -0
  49. {python3_commons-0.8.34 → python3_commons-0.8.36}/src/python3_commons/serializers/msgspec.py +0 -0
  50. {python3_commons-0.8.34 → python3_commons-0.8.36}/src/python3_commons.egg-info/SOURCES.txt +0 -0
  51. {python3_commons-0.8.34 → python3_commons-0.8.36}/src/python3_commons.egg-info/dependency_links.txt +0 -0
  52. {python3_commons-0.8.34 → python3_commons-0.8.36}/src/python3_commons.egg-info/top_level.txt +0 -0
  53. {python3_commons-0.8.34 → python3_commons-0.8.36}/tests/conftest.py +0 -0
  54. {python3_commons-0.8.34 → python3_commons-0.8.36}/tests/test_audit.py +0 -0
  55. {python3_commons-0.8.34 → python3_commons-0.8.36}/tests/test_cache.py +0 -0
  56. {python3_commons-0.8.34 → python3_commons-0.8.36}/tests/test_helpers.py +0 -0
  57. {python3_commons-0.8.34 → python3_commons-0.8.36}/tests/test_msgpack.py +0 -0
  58. {python3_commons-0.8.34 → python3_commons-0.8.36}/tests/test_msgspec.py +0 -0
@@ -1,12 +1,12 @@
1
1
  repos:
2
2
  - repo: https://github.com/astral-sh/uv-pre-commit
3
- rev: 0.7.13
3
+ rev: 0.7.20
4
4
  hooks:
5
5
  - id: uv-lock
6
6
  # - id: uv-export
7
7
 
8
8
  - repo: https://github.com/astral-sh/ruff-pre-commit
9
- rev: v0.12.0
9
+ rev: v0.12.3
10
10
  hooks:
11
11
  # Run the linter.
12
12
  - id: ruff-check
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python3-commons
3
- Version: 0.8.34
3
+ Version: 0.8.36
4
4
  Summary: Re-usable Python3 code
5
5
  Author-email: Oleg Korsak <kamikaze.is.waiting.you@gmail.com>
6
6
  License-Expression: GPL-3.0
@@ -12,16 +12,16 @@ Requires-Python: ==3.13.*
12
12
  Description-Content-Type: text/x-rst
13
13
  License-File: LICENSE
14
14
  License-File: AUTHORS.rst
15
- Requires-Dist: aiohttp[speedups]~=3.12.13
15
+ Requires-Dist: aiobotocore~=2.23.0
16
+ Requires-Dist: aiohttp[speedups]~=3.12.14
16
17
  Requires-Dist: asyncpg~=0.30.0
17
18
  Requires-Dist: fastapi-users-db-sqlalchemy~=7.0.0
18
19
  Requires-Dist: fastapi-users[sqlalchemy]~=14.0.1
19
- Requires-Dist: lxml~=5.4.0
20
- Requires-Dist: minio~=7.2.15
20
+ Requires-Dist: lxml~=6.0.0
21
21
  Requires-Dist: msgpack~=1.1.1
22
22
  Requires-Dist: msgspec~=0.19.0
23
23
  Requires-Dist: pydantic[email]~=2.11.7
24
- Requires-Dist: pydantic-settings~=2.10.0
24
+ Requires-Dist: pydantic-settings~=2.10.1
25
25
  Requires-Dist: python-jose==3.5.0
26
26
  Requires-Dist: SQLAlchemy[asyncio]~=2.0.40
27
27
  Requires-Dist: valkey[libvalkey]~=6.1.0
@@ -19,16 +19,16 @@ keywords = []
19
19
  requires-python = "==3.13.*"
20
20
 
21
21
  dependencies = [
22
- "aiohttp[speedups]~=3.12.13",
22
+ "aiobotocore~=2.23.0",
23
+ "aiohttp[speedups]~=3.12.14",
23
24
  "asyncpg~=0.30.0",
24
25
  "fastapi-users-db-sqlalchemy~=7.0.0",
25
26
  "fastapi-users[sqlalchemy]~=14.0.1",
26
- "lxml~=5.4.0",
27
- "minio~=7.2.15",
27
+ "lxml~=6.0.0",
28
28
  "msgpack~=1.1.1",
29
29
  "msgspec~=0.19.0",
30
30
  "pydantic[email]~=2.11.7",
31
- "pydantic-settings~=2.10.0",
31
+ "pydantic-settings~=2.10.1",
32
32
  "python-jose==3.5.0",
33
33
  "SQLAlchemy[asyncio]~=2.0.40",
34
34
  "valkey[libvalkey]~=6.1.0",
@@ -40,10 +40,11 @@ dev = [
40
40
  "build",
41
41
  "pip==25.1.1",
42
42
  "pre-commit==4.2.0",
43
- "pyright==1.1.402",
44
- "ruff==0.12.0",
43
+ "pyright==1.1.403",
44
+ "ruff==0.12.3",
45
45
  "setuptools==80.9.0",
46
46
  "setuptools_scm==8.3.1",
47
+ "types-aiobotocore-s3",
47
48
  "wheel==0.45.1",
48
49
  ]
49
50
  testing = [
@@ -0,0 +1,8 @@
1
+ from python3_commons.db.models.auth import ApiKey as ApiKey
2
+ from python3_commons.db.models.auth import User as User
3
+ from python3_commons.db.models.auth import UserGroup as UserGroup
4
+ from python3_commons.db.models.rbac import RBACApiKeyRole as RBACApiKeyRole
5
+ from python3_commons.db.models.rbac import RBACPermission as RBACPermission
6
+ from python3_commons.db.models.rbac import RBACRole as RBACRole
7
+ from python3_commons.db.models.rbac import RBACRolePermission as RBACRolePermission
8
+ from python3_commons.db.models.rbac import RBACUserRole as RBACUserRole
@@ -1,6 +1,8 @@
1
1
  import logging
2
2
  import shlex
3
3
  import threading
4
+ from abc import ABCMeta
5
+ from collections import defaultdict
4
6
  from datetime import date, datetime, timedelta
5
7
  from decimal import ROUND_HALF_UP, Decimal
6
8
  from json import dumps
@@ -12,24 +14,24 @@ from python3_commons.serializers.json import CustomJSONEncoder
12
14
  logger = logging.getLogger(__name__)
13
15
 
14
16
 
15
- class SingletonMeta(type):
17
+ class SingletonMeta(ABCMeta):
16
18
  """
17
19
  A metaclass that creates a Singleton base class when called.
18
20
  """
19
21
 
20
- _instances = {}
21
- _lock = threading.Lock()
22
+ __instances = {}
23
+ __locks = defaultdict(threading.Lock)
22
24
 
23
25
  def __call__(cls, *args, **kwargs):
24
26
  try:
25
- return cls._instances[cls]
27
+ return cls.__instances[cls]
26
28
  except KeyError:
27
- with cls._lock:
29
+ with cls.__locks[cls]:
28
30
  try:
29
- return cls._instances[cls]
31
+ return cls.__instances[cls]
30
32
  except KeyError:
31
33
  instance = super(SingletonMeta, cls).__call__(*args, **kwargs)
32
- cls._instances[cls] = instance
34
+ cls.__instances[cls] = instance
33
35
 
34
36
  return instance
35
37
 
@@ -0,0 +1,183 @@
1
+ from __future__ import annotations
2
+
3
+ import io
4
+ import logging
5
+ from contextlib import asynccontextmanager
6
+ from datetime import datetime
7
+ from typing import TYPE_CHECKING, AsyncGenerator, Iterable, Mapping, Sequence
8
+
9
+ import aiobotocore.session
10
+ from aiobotocore.response import StreamingBody
11
+
12
+ if TYPE_CHECKING:
13
+ from types_aiobotocore_s3.client import S3Client
14
+
15
+ from python3_commons.conf import S3Settings, s3_settings
16
+ from python3_commons.helpers import SingletonMeta
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class ObjectStorage(metaclass=SingletonMeta):
22
+ def __init__(self, settings: S3Settings):
23
+ if not settings.s3_endpoint_url:
24
+ raise ValueError('s3_endpoint_url must be set')
25
+
26
+ self._session = aiobotocore.session.get_session()
27
+ self._config = {
28
+ 'endpoint_url': settings.s3_endpoint_url,
29
+ 'region_name': settings.s3_region_name,
30
+ 'aws_access_key_id': settings.s3_access_key_id.get_secret_value(),
31
+ 'aws_secret_access_key': settings.s3_secret_access_key.get_secret_value(),
32
+ 'use_ssl': settings.s3_secure,
33
+ 'verify': settings.s3_cert_verify,
34
+ }
35
+
36
+ @asynccontextmanager
37
+ async def get_client(self) -> AsyncGenerator[S3Client, None]:
38
+ async with self._session.create_client('s3', **self._config) as client:
39
+ yield client
40
+
41
+
42
+ def get_absolute_path(path: str) -> str:
43
+ if path.startswith('/'):
44
+ path = path[1:]
45
+
46
+ if bucket_root := s3_settings.s3_bucket_root:
47
+ path = f'{bucket_root[:1] if bucket_root.startswith("/") else bucket_root}/{path}'
48
+
49
+ return path
50
+
51
+
52
+ async def put_object(bucket_name: str, path: str, data: io.BytesIO, length: int, part_size: int = 0) -> str | None:
53
+ storage = ObjectStorage(s3_settings)
54
+
55
+ async with storage.get_client() as s3_client:
56
+ try:
57
+ data.seek(0)
58
+
59
+ await s3_client.put_object(Bucket=bucket_name, Key=path, Body=data, ContentLength=length)
60
+
61
+ logger.debug(f'Stored object into object storage: {bucket_name}:{path}')
62
+
63
+ return f's3://{bucket_name}/{path}'
64
+
65
+ except Exception as e:
66
+ logger.error(f'Failed to put object to object storage: {bucket_name}:{path}', exc_info=e)
67
+
68
+ return None
69
+
70
+
71
+ @asynccontextmanager
72
+ async def get_object_stream(bucket_name: str, path: str) -> AsyncGenerator[StreamingBody]:
73
+ storage = ObjectStorage(s3_settings)
74
+
75
+ async with storage.get_client() as s3_client:
76
+ logger.debug(f'Getting object from object storage: {bucket_name}:{path}')
77
+
78
+ try:
79
+ response = await s3_client.get_object(Bucket=bucket_name, Key=path)
80
+
81
+ async with response['Body'] as stream:
82
+ yield stream
83
+ except Exception as e:
84
+ logger.debug(f'Failed getting object from object storage: {bucket_name}:{path}', exc_info=e)
85
+
86
+ raise
87
+
88
+
89
+ async def get_object(bucket_name: str, path: str) -> bytes:
90
+ async with get_object_stream(bucket_name, path) as stream:
91
+ body = await stream.read()
92
+
93
+ logger.debug(f'Loaded object from object storage: {bucket_name}:{path}')
94
+
95
+ return body
96
+
97
+
98
+ async def list_objects(bucket_name: str, prefix: str, recursive: bool = True) -> AsyncGenerator[Mapping, None]:
99
+ storage = ObjectStorage(s3_settings)
100
+
101
+ async with storage.get_client() as s3_client:
102
+ paginator = s3_client.get_paginator('list_objects_v2')
103
+
104
+ page_iterator = paginator.paginate(Bucket=bucket_name, Prefix=prefix, Delimiter='' if recursive else '/')
105
+
106
+ async for page in page_iterator:
107
+ if 'Contents' in page:
108
+ for obj in page['Contents']:
109
+ yield dict(obj)
110
+
111
+
112
+ async def get_object_streams(
113
+ bucket_name: str, path: str, recursive: bool = True
114
+ ) -> AsyncGenerator[tuple[str, datetime, StreamingBody], None]:
115
+ async for obj in list_objects(bucket_name, path, recursive):
116
+ object_name = obj['Key']
117
+ last_modified = obj['LastModified']
118
+
119
+ async with get_object_stream(bucket_name, path) as stream:
120
+ yield object_name, last_modified, stream
121
+
122
+
123
+ async def get_objects(
124
+ bucket_name: str, path: str, recursive: bool = True
125
+ ) -> AsyncGenerator[tuple[str, datetime, bytes], None]:
126
+ async for object_name, last_modified, stream in get_object_streams(bucket_name, path, recursive):
127
+ data = await stream.read()
128
+
129
+ yield object_name, last_modified, data
130
+
131
+
132
+ async def remove_object(bucket_name: str, object_name: str):
133
+ storage = ObjectStorage(s3_settings)
134
+
135
+ async with storage.get_client() as s3_client:
136
+ try:
137
+ await s3_client.delete_object(Bucket=bucket_name, Key=object_name)
138
+ logger.debug(f'Removed object from object storage: {bucket_name}:{object_name}')
139
+ except Exception as e:
140
+ logger.error(f'Failed to remove object from object storage: {bucket_name}:{object_name}', exc_info=e)
141
+
142
+ raise
143
+
144
+
145
+ async def remove_objects(
146
+ bucket_name: str, prefix: str = None, object_names: Iterable[str] = None
147
+ ) -> Sequence[Mapping] | None:
148
+ storage = ObjectStorage(s3_settings)
149
+
150
+ async with storage.get_client() as s3_client:
151
+ objects_to_delete = []
152
+
153
+ if prefix:
154
+ async for obj in list_objects(bucket_name, prefix, recursive=True):
155
+ objects_to_delete.append({'Key': obj['Key']})
156
+ elif object_names:
157
+ objects_to_delete = [{'Key': name} for name in object_names]
158
+ else:
159
+ return None
160
+
161
+ if not objects_to_delete:
162
+ return None
163
+
164
+ try:
165
+ errors = []
166
+ # S3 delete_objects can handle up to 1000 objects at once
167
+ chunk_size = 1000
168
+
169
+ for i in range(0, len(objects_to_delete), chunk_size):
170
+ chunk = objects_to_delete[i : i + chunk_size]
171
+
172
+ response = await s3_client.delete_objects(Bucket=bucket_name, Delete={'Objects': chunk})
173
+
174
+ if 'Errors' in response:
175
+ errors.extend(response['Errors'])
176
+
177
+ logger.debug(f'Removed {len(objects_to_delete)} objects from object storage: {bucket_name}')
178
+
179
+ return errors if errors else None
180
+ except Exception as e:
181
+ logger.error(f'Failed to remove objects from object storage: {bucket_name}', exc_info=e)
182
+
183
+ raise
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python3-commons
3
- Version: 0.8.34
3
+ Version: 0.8.36
4
4
  Summary: Re-usable Python3 code
5
5
  Author-email: Oleg Korsak <kamikaze.is.waiting.you@gmail.com>
6
6
  License-Expression: GPL-3.0
@@ -12,16 +12,16 @@ Requires-Python: ==3.13.*
12
12
  Description-Content-Type: text/x-rst
13
13
  License-File: LICENSE
14
14
  License-File: AUTHORS.rst
15
- Requires-Dist: aiohttp[speedups]~=3.12.13
15
+ Requires-Dist: aiobotocore~=2.23.0
16
+ Requires-Dist: aiohttp[speedups]~=3.12.14
16
17
  Requires-Dist: asyncpg~=0.30.0
17
18
  Requires-Dist: fastapi-users-db-sqlalchemy~=7.0.0
18
19
  Requires-Dist: fastapi-users[sqlalchemy]~=14.0.1
19
- Requires-Dist: lxml~=5.4.0
20
- Requires-Dist: minio~=7.2.15
20
+ Requires-Dist: lxml~=6.0.0
21
21
  Requires-Dist: msgpack~=1.1.1
22
22
  Requires-Dist: msgspec~=0.19.0
23
23
  Requires-Dist: pydantic[email]~=2.11.7
24
- Requires-Dist: pydantic-settings~=2.10.0
24
+ Requires-Dist: pydantic-settings~=2.10.1
25
25
  Requires-Dist: python-jose==3.5.0
26
26
  Requires-Dist: SQLAlchemy[asyncio]~=2.0.40
27
27
  Requires-Dist: valkey[libvalkey]~=6.1.0
@@ -1,13 +1,13 @@
1
- aiohttp[speedups]~=3.12.13
1
+ aiobotocore~=2.23.0
2
+ aiohttp[speedups]~=3.12.14
2
3
  asyncpg~=0.30.0
3
4
  fastapi-users-db-sqlalchemy~=7.0.0
4
5
  fastapi-users[sqlalchemy]~=14.0.1
5
- lxml~=5.4.0
6
- minio~=7.2.15
6
+ lxml~=6.0.0
7
7
  msgpack~=1.1.1
8
8
  msgspec~=0.19.0
9
9
  pydantic[email]~=2.11.7
10
- pydantic-settings~=2.10.0
10
+ pydantic-settings~=2.10.1
11
11
  python-jose==3.5.0
12
12
  SQLAlchemy[asyncio]~=2.0.40
13
13
  valkey[libvalkey]~=6.1.0