python3-commons 0.17.7__tar.gz → 0.18.0__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 (75) hide show
  1. {python3_commons-0.17.7/src/python3_commons.egg-info → python3_commons-0.18.0}/PKG-INFO +8 -4
  2. {python3_commons-0.17.7 → python3_commons-0.18.0}/pyproject.toml +8 -3
  3. python3_commons-0.18.0/src/python3_commons/audit.py +22 -0
  4. {python3_commons-0.17.7 → python3_commons-0.18.0}/src/python3_commons/auth.py +23 -19
  5. python3_commons-0.18.0/src/python3_commons/soap_client.py +310 -0
  6. {python3_commons-0.17.7 → python3_commons-0.18.0/src/python3_commons.egg-info}/PKG-INFO +8 -4
  7. {python3_commons-0.17.7 → python3_commons-0.18.0}/src/python3_commons.egg-info/SOURCES.txt +1 -0
  8. {python3_commons-0.17.7 → python3_commons-0.18.0}/src/python3_commons.egg-info/requires.txt +8 -3
  9. {python3_commons-0.17.7 → python3_commons-0.18.0}/uv.lock +71 -9
  10. python3_commons-0.17.7/src/python3_commons/audit.py +0 -67
  11. {python3_commons-0.17.7 → python3_commons-0.18.0}/.coveragerc +0 -0
  12. {python3_commons-0.17.7 → python3_commons-0.18.0}/.devcontainer/Dockerfile +0 -0
  13. {python3_commons-0.17.7 → python3_commons-0.18.0}/.devcontainer/devcontainer.json +0 -0
  14. {python3_commons-0.17.7 → python3_commons-0.18.0}/.devcontainer/docker-compose.yml +0 -0
  15. {python3_commons-0.17.7 → python3_commons-0.18.0}/.env_template +0 -0
  16. {python3_commons-0.17.7 → python3_commons-0.18.0}/.github/workflows/checks.yml +0 -0
  17. {python3_commons-0.17.7 → python3_commons-0.18.0}/.github/workflows/python-publish.yaml +0 -0
  18. {python3_commons-0.17.7 → python3_commons-0.18.0}/.github/workflows/release-on-tag-push.yml +0 -0
  19. {python3_commons-0.17.7 → python3_commons-0.18.0}/.gitignore +0 -0
  20. {python3_commons-0.17.7 → python3_commons-0.18.0}/.pre-commit-config.yaml +0 -0
  21. {python3_commons-0.17.7 → python3_commons-0.18.0}/.python-version +0 -0
  22. {python3_commons-0.17.7 → python3_commons-0.18.0}/AUTHORS.rst +0 -0
  23. {python3_commons-0.17.7 → python3_commons-0.18.0}/CHANGELOG.rst +0 -0
  24. {python3_commons-0.17.7 → python3_commons-0.18.0}/LICENSE +0 -0
  25. {python3_commons-0.17.7 → python3_commons-0.18.0}/README.md +0 -0
  26. {python3_commons-0.17.7 → python3_commons-0.18.0}/README.rst +0 -0
  27. {python3_commons-0.17.7 → python3_commons-0.18.0}/docs/Makefile +0 -0
  28. {python3_commons-0.17.7 → python3_commons-0.18.0}/docs/_static/.gitignore +0 -0
  29. {python3_commons-0.17.7 → python3_commons-0.18.0}/docs/authors.rst +0 -0
  30. {python3_commons-0.17.7 → python3_commons-0.18.0}/docs/changelog.rst +0 -0
  31. {python3_commons-0.17.7 → python3_commons-0.18.0}/docs/conf.py +0 -0
  32. {python3_commons-0.17.7 → python3_commons-0.18.0}/docs/index.rst +0 -0
  33. {python3_commons-0.17.7 → python3_commons-0.18.0}/docs/license.rst +0 -0
  34. {python3_commons-0.17.7 → python3_commons-0.18.0}/setup.cfg +0 -0
  35. {python3_commons-0.17.7 → python3_commons-0.18.0}/src/python3_commons/__init__.py +0 -0
  36. {python3_commons-0.17.7 → python3_commons-0.18.0}/src/python3_commons/api_client.py +0 -0
  37. {python3_commons-0.17.7 → python3_commons-0.18.0}/src/python3_commons/async_functools.py +0 -0
  38. {python3_commons-0.17.7 → python3_commons-0.18.0}/src/python3_commons/cache.py +0 -0
  39. {python3_commons-0.17.7 → python3_commons-0.18.0}/src/python3_commons/conf.py +0 -0
  40. {python3_commons-0.17.7 → python3_commons-0.18.0}/src/python3_commons/db/__init__.py +0 -0
  41. {python3_commons-0.17.7 → python3_commons-0.18.0}/src/python3_commons/db/helpers.py +0 -0
  42. {python3_commons-0.17.7 → python3_commons-0.18.0}/src/python3_commons/db/models/__init__.py +0 -0
  43. {python3_commons-0.17.7 → python3_commons-0.18.0}/src/python3_commons/db/models/auth.py +0 -0
  44. {python3_commons-0.17.7 → python3_commons-0.18.0}/src/python3_commons/db/models/common.py +0 -0
  45. {python3_commons-0.17.7 → python3_commons-0.18.0}/src/python3_commons/db/models/rbac.py +0 -0
  46. {python3_commons-0.17.7 → python3_commons-0.18.0}/src/python3_commons/db/models/users.py +0 -0
  47. {python3_commons-0.17.7 → python3_commons-0.18.0}/src/python3_commons/exceptions.py +0 -0
  48. {python3_commons-0.17.7 → python3_commons-0.18.0}/src/python3_commons/fs.py +0 -0
  49. {python3_commons-0.17.7 → python3_commons-0.18.0}/src/python3_commons/generators.py +0 -0
  50. {python3_commons-0.17.7 → python3_commons-0.18.0}/src/python3_commons/helpers.py +0 -0
  51. {python3_commons-0.17.7 → python3_commons-0.18.0}/src/python3_commons/log/__init__.py +0 -0
  52. {python3_commons-0.17.7 → python3_commons-0.18.0}/src/python3_commons/log/filters.py +0 -0
  53. {python3_commons-0.17.7 → python3_commons-0.18.0}/src/python3_commons/log/formatters.py +0 -0
  54. {python3_commons-0.17.7 → python3_commons-0.18.0}/src/python3_commons/object_storage.py +0 -0
  55. {python3_commons-0.17.7 → python3_commons-0.18.0}/src/python3_commons/permissions.py +0 -0
  56. {python3_commons-0.17.7 → python3_commons-0.18.0}/src/python3_commons/serializers/__init__.py +0 -0
  57. {python3_commons-0.17.7 → python3_commons-0.18.0}/src/python3_commons/serializers/common.py +0 -0
  58. {python3_commons-0.17.7 → python3_commons-0.18.0}/src/python3_commons/serializers/json.py +0 -0
  59. {python3_commons-0.17.7 → python3_commons-0.18.0}/src/python3_commons/serializers/msgpack.py +0 -0
  60. {python3_commons-0.17.7 → python3_commons-0.18.0}/src/python3_commons/serializers/msgspec.py +0 -0
  61. {python3_commons-0.17.7 → python3_commons-0.18.0}/src/python3_commons.egg-info/dependency_links.txt +0 -0
  62. {python3_commons-0.17.7 → python3_commons-0.18.0}/src/python3_commons.egg-info/top_level.txt +0 -0
  63. {python3_commons-0.17.7 → python3_commons-0.18.0}/tests/__init__.py +0 -0
  64. {python3_commons-0.17.7 → python3_commons-0.18.0}/tests/integration/__init__.py +0 -0
  65. {python3_commons-0.17.7 → python3_commons-0.18.0}/tests/integration/test_cache.py +0 -0
  66. {python3_commons-0.17.7 → python3_commons-0.18.0}/tests/integration/test_osc.py +0 -0
  67. {python3_commons-0.17.7 → python3_commons-0.18.0}/tests/unit/__init__.py +0 -0
  68. {python3_commons-0.17.7 → python3_commons-0.18.0}/tests/unit/conftest.py +0 -0
  69. {python3_commons-0.17.7 → python3_commons-0.18.0}/tests/unit/log/__init__.py +0 -0
  70. {python3_commons-0.17.7 → python3_commons-0.18.0}/tests/unit/log/test_formatters.py +0 -0
  71. {python3_commons-0.17.7 → python3_commons-0.18.0}/tests/unit/test_async_functools.py +0 -0
  72. {python3_commons-0.17.7 → python3_commons-0.18.0}/tests/unit/test_audit.py +0 -0
  73. {python3_commons-0.17.7 → python3_commons-0.18.0}/tests/unit/test_helpers.py +0 -0
  74. {python3_commons-0.17.7 → python3_commons-0.18.0}/tests/unit/test_msgpack.py +0 -0
  75. {python3_commons-0.17.7 → python3_commons-0.18.0}/tests/unit/test_msgspec.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python3-commons
3
- Version: 0.17.7
3
+ Version: 0.18.0
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
@@ -16,15 +16,14 @@ Requires-Dist: msgpack~=1.1.2
16
16
  Requires-Dist: msgspec==0.21.1
17
17
  Requires-Dist: pydantic-settings~=2.14.0
18
18
  Provides-Extra: all
19
- Requires-Dist: python3_commons[api-client,audit,authn,authz,cache,database,object-storage]; extra == "all"
19
+ Requires-Dist: python3_commons[api-client,audit,authn,authz,cache,database,object-storage,soap-client]; extra == "all"
20
20
  Provides-Extra: api-client
21
21
  Requires-Dist: aiohttp[speedups]<3.15.0,>=3.13.5; extra == "api-client"
22
22
  Requires-Dist: lxml~=6.1.0; extra == "api-client"
23
- Requires-Dist: zeep~=4.3.2; extra == "api-client"
24
23
  Requires-Dist: python3_commons[object-storage]; extra == "api-client"
25
24
  Provides-Extra: audit
26
25
  Requires-Dist: lxml~=6.1.0; extra == "audit"
27
- Requires-Dist: zeep~=4.3.2; extra == "audit"
26
+ Requires-Dist: zeep[async]~=4.3.2; extra == "audit"
28
27
  Requires-Dist: python3_commons[object-storage]; extra == "audit"
29
28
  Provides-Extra: authn
30
29
  Requires-Dist: aiohttp[speedups]<3.15.0,>=3.13.5; extra == "authn"
@@ -40,6 +39,11 @@ Requires-Dist: SQLAlchemy[asyncio]~=2.0.49; extra == "database"
40
39
  Provides-Extra: object-storage
41
40
  Requires-Dist: aiobotocore~=3.5.0; extra == "object-storage"
42
41
  Requires-Dist: object-storage-client==0.0.23; extra == "object-storage"
42
+ Provides-Extra: soap-client
43
+ Requires-Dist: aiohttp[speedups]<3.15.0,>=3.13.5; extra == "soap-client"
44
+ Requires-Dist: lxml~=6.1.0; extra == "soap-client"
45
+ Requires-Dist: requests~=2.33.1; extra == "soap-client"
46
+ Requires-Dist: zeep[async]~=4.3.2; extra == "soap-client"
43
47
  Dynamic: license-file
44
48
 
45
49
  Re-usable Python3 code
@@ -25,17 +25,16 @@ dependencies = [
25
25
 
26
26
  [project.optional-dependencies]
27
27
  all = [
28
- "python3_commons[api-client,audit,authn,authz,cache,database,object-storage]"
28
+ "python3_commons[api-client,audit,authn,authz,cache,database,object-storage,soap-client]"
29
29
  ]
30
30
  api-client = [
31
31
  "aiohttp[speedups]>=3.13.5,<3.15.0",
32
32
  "lxml~=6.1.0",
33
- "zeep~=4.3.2",
34
33
  "python3_commons[object-storage]"
35
34
  ]
36
35
  audit = [
37
36
  "lxml~=6.1.0",
38
- "zeep~=4.3.2",
37
+ "zeep[async]~=4.3.2",
39
38
  "python3_commons[object-storage]"
40
39
  ]
41
40
  authn = [
@@ -57,6 +56,12 @@ object-storage = [
57
56
  "aiobotocore~=3.5.0",
58
57
  "object-storage-client==0.0.23"
59
58
  ]
59
+ soap-client = [
60
+ "aiohttp[speedups]>=3.13.5,<3.15.0",
61
+ "lxml~=6.1.0",
62
+ "requests~=2.33.1",
63
+ "zeep[async]~=4.3.2",
64
+ ]
60
65
 
61
66
 
62
67
  [dependency-groups]
@@ -0,0 +1,22 @@
1
+ import io
2
+ import logging
3
+ from typing import TYPE_CHECKING
4
+
5
+ from python3_commons import object_storage
6
+
7
+ if TYPE_CHECKING:
8
+ from python3_commons.conf import S3Settings
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ async def write_audit_data(settings: S3Settings, key: str, data: bytes) -> None:
14
+ if settings.aws_secret_access_key:
15
+ try:
16
+ await object_storage.put_object(settings.s3_bucket, f'audit/{key}', io.BytesIO(data), len(data))
17
+ except Exception:
18
+ logger.exception('Failed storing object in storage.')
19
+ else:
20
+ logger.debug('Stored object in storage: %s', key)
21
+ else:
22
+ logger.debug('S3 is not configured, not storing object in storage: %s', key)
@@ -57,6 +57,8 @@ class OIDCTokenResponse(msgspec.Struct):
57
57
  refresh_token: str
58
58
  scope: str
59
59
  id_token: str
60
+ error: str | None = None
61
+ error_description: str | None = None
60
62
 
61
63
 
62
64
  async def fetch_openid_config() -> dict:
@@ -99,13 +101,13 @@ class OIDCAuthError(OIDCError):
99
101
  # TODO: use api_client
100
102
  class OIDCClient:
101
103
  def __init__(
102
- self,
103
- authority_url: str,
104
- client_id: str,
105
- client_secret: str | None = None,
106
- *,
107
- timeout: float = 10.0,
108
- session: aiohttp.ClientSession | None = None,
104
+ self,
105
+ authority_url: str,
106
+ client_id: str,
107
+ client_secret: str | None = None,
108
+ *,
109
+ timeout: float = 10.0,
110
+ session: aiohttp.ClientSession | None = None,
109
111
  ) -> None:
110
112
  self._token_url = f'{authority_url}/protocol/openid-connect/token' # TODO: get it from openid-configuration
111
113
  self._client_id = client_id
@@ -124,11 +126,11 @@ class OIDCClient:
124
126
  await self._session.close()
125
127
 
126
128
  async def fetch_token(
127
- self,
128
- *,
129
- username: str,
130
- password: str,
131
- scope: str = 'openid profile email',
129
+ self,
130
+ *,
131
+ username: str,
132
+ password: str,
133
+ scope: str = 'openid profile email',
132
134
  ) -> OIDCTokenResponse:
133
135
  if self._session is None:
134
136
  msg = 'ClientSession not initialized'
@@ -148,11 +150,13 @@ class OIDCClient:
148
150
 
149
151
  try:
150
152
  async with self._session.post(
151
- self._token_url,
152
- data=data,
153
- headers={'Content-Type': 'application/x-www-form-urlencoded'},
153
+ self._token_url,
154
+ data=data,
155
+ headers={'Content-Type': 'application/x-www-form-urlencoded'},
154
156
  ) as resp:
155
- payload = await resp.json(content_type=None)
157
+ payload = await resp.read()
158
+ decoder = msgspec.json.Decoder(type=OIDCTokenResponse)
159
+ token = decoder.decode(payload)
156
160
 
157
161
  except TimeoutError as e:
158
162
  msg = 'OIDC request timed out'
@@ -164,8 +168,8 @@ class OIDCClient:
164
168
  raise OIDCError(msg) from e
165
169
 
166
170
  if not resp.ok:
167
- error = payload.get('error')
168
- description = payload.get('error_description')
171
+ error = token.error
172
+ description = token.error_description
169
173
 
170
174
  if error in {'invalid_grant', 'invalid_client'}:
171
175
  msg = f'{error}: {description}'
@@ -176,4 +180,4 @@ class OIDCClient:
176
180
 
177
181
  raise OIDCError(msg)
178
182
 
179
- return payload
183
+ return token
@@ -0,0 +1,310 @@
1
+ """
2
+ Async SOAP client built on aiohttp + zeep.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import asyncio
8
+ import logging
9
+ from collections.abc import AsyncIterator, Sequence
10
+ from contextlib import asynccontextmanager
11
+ from dataclasses import dataclass
12
+ from datetime import UTC, datetime
13
+ from typing import TYPE_CHECKING, Any, Self
14
+ from uuid import uuid4
15
+
16
+ try:
17
+ import aiohttp
18
+ from aiohttp import ClientSession, ClientTimeout, TCPConnector
19
+ from lxml import etree
20
+ from requests import Response
21
+ from requests.cookies import RequestsCookieJar
22
+ from zeep import AsyncClient
23
+ from zeep.exceptions import TransportError
24
+ from zeep.plugins import HistoryPlugin, Plugin
25
+ from zeep.transports import Transport
26
+ from zeep.utils import get_version
27
+ from zeep.wsdl.utils import etree_to_string
28
+ except ImportError as e:
29
+ msg = 'Install python3-commons[soap-client] to use this feature'
30
+
31
+ raise RuntimeError(msg) from e
32
+
33
+ from python3_commons.audit import write_audit_data
34
+ from python3_commons.conf import s3_settings
35
+
36
+ if TYPE_CHECKING:
37
+ from zeep.wsdl.definitions import AbstractOperation
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+
42
+ class ZeepAuditPlugin(Plugin):
43
+ def __init__(self, audit_name: str = 'zeep') -> None:
44
+ super().__init__()
45
+ self.audit_name = audit_name
46
+
47
+ def store_audit_in_s3(self, envelope, operation: AbstractOperation, direction: str) -> None:
48
+ xml = etree.tostring(envelope, encoding='UTF-8', pretty_print=True)
49
+ now = datetime.now(tz=UTC)
50
+ date_path = now.strftime('%Y/%m/%d')
51
+ timestamp = now.strftime('%H%M%S')
52
+ path = f'{date_path}/{self.audit_name}/{operation.name}/{timestamp}_{str(uuid4())[-12:]}_{direction}.xml'
53
+ coro = write_audit_data(s3_settings, path, xml)
54
+
55
+ try:
56
+ loop = asyncio.get_running_loop()
57
+ except RuntimeError:
58
+ loop = None
59
+
60
+ if loop and loop.is_running():
61
+ loop.create_task(coro)
62
+ else:
63
+ asyncio.run(coro)
64
+
65
+ def ingress(self, envelope, http_headers, operation: AbstractOperation):
66
+ self.store_audit_in_s3(envelope, operation, 'ingress')
67
+
68
+ return envelope, http_headers
69
+
70
+ def egress(self, envelope, http_headers, operation: AbstractOperation, binding_options):
71
+ self.store_audit_in_s3(envelope, operation, 'egress')
72
+
73
+ return envelope, http_headers
74
+
75
+
76
+ @dataclass(frozen=True, slots=True)
77
+ class TransportConfig:
78
+ """Immutable transport settings passed to AsyncTransport."""
79
+
80
+ timeout: int = 300
81
+ """Total timeout in seconds for WSDL fetches."""
82
+
83
+ operation_timeout: int = 60
84
+ """Total timeout in seconds for SOAP operation calls."""
85
+
86
+ verify_ssl: bool = True
87
+ proxy: str | None = None
88
+
89
+
90
+ class AsyncTransport(Transport):
91
+ """
92
+ Async transport for zeep using aiohttp.
93
+
94
+ Usage::
95
+
96
+ async with AsyncTransport.from_config(config) as transport:
97
+ client = AsyncClient(wsdl_url, transport=transport)
98
+ result = await client.service.SomeOperation(...)
99
+ """
100
+
101
+ def __init__(
102
+ self,
103
+ *,
104
+ session: ClientSession,
105
+ wsdl_session: ClientSession,
106
+ config: TransportConfig,
107
+ _owns_session: bool = False,
108
+ _owns_wsdl_session: bool = False,
109
+ ) -> None:
110
+ super().__init__()
111
+ self._session = session
112
+ self._wsdl_session = wsdl_session
113
+ self._config = config
114
+ self._owns_session = _owns_session
115
+ self._owns_wsdl_session = _owns_wsdl_session
116
+
117
+ @classmethod
118
+ def from_config(
119
+ cls,
120
+ config: TransportConfig | None = None,
121
+ *,
122
+ session: ClientSession | None = None,
123
+ wsdl_session: ClientSession | None = None,
124
+ ) -> AsyncTransport:
125
+ """
126
+ Create a transport, optionally sharing an existing ClientSession.
127
+
128
+ If *session* / *wsdl_session* are omitted the transport owns (and
129
+ will close) the sessions it creates.
130
+ """
131
+ config = config or TransportConfig()
132
+ connector = TCPConnector(ssl=config.verify_ssl)
133
+ user_agent = f'Zeep/{get_version()} (www.python-zeep.org)'
134
+
135
+ owns_session = session is None
136
+ owns_wsdl_session = wsdl_session is None
137
+
138
+ if owns_session:
139
+ session = ClientSession(
140
+ connector=connector,
141
+ timeout=ClientTimeout(total=config.operation_timeout),
142
+ headers={'User-Agent': user_agent},
143
+ )
144
+
145
+ if owns_wsdl_session:
146
+ wsdl_session = ClientSession(
147
+ connector=connector,
148
+ timeout=ClientTimeout(total=config.timeout),
149
+ headers={'User-Agent': user_agent},
150
+ )
151
+
152
+ return cls(
153
+ session=session,
154
+ wsdl_session=wsdl_session,
155
+ config=config,
156
+ _owns_session=owns_session,
157
+ _owns_wsdl_session=owns_wsdl_session,
158
+ )
159
+
160
+ async def aclose(self) -> None:
161
+ if self._owns_session:
162
+ await self._session.close()
163
+ if self._owns_wsdl_session:
164
+ await self._wsdl_session.close()
165
+
166
+ async def __aenter__(self) -> Self:
167
+ return self
168
+
169
+ async def __aexit__(self, *_: object) -> None:
170
+ await self.aclose()
171
+
172
+ @staticmethod
173
+ def _build_response(response: aiohttp.ClientResponse, body: bytes) -> Response:
174
+ """Convert an aiohttp response into a requests.Response for zeep."""
175
+ r = Response()
176
+ r.status_code = response.status
177
+ r._content = body # noqa: SLF001 (zeep reads this attribute directly)
178
+ r.headers = dict(response.headers)
179
+ r.encoding = response.charset
180
+ r.url = str(response.url)
181
+
182
+ # Bridge aiohttp SimpleCookie → RequestsCookieJar so zeep / requests
183
+ # cookie handling works correctly.
184
+ jar = RequestsCookieJar()
185
+
186
+ for name, morsel in response.cookies.items():
187
+ jar.set(name, morsel.value)
188
+
189
+ r.cookies = jar
190
+
191
+ return r
192
+
193
+ async def _load_remote_data(self, url: str) -> bytes:
194
+ """Fetch WSDL / XSD documents (called by zeep during init)."""
195
+
196
+ async def _fetch() -> bytes:
197
+ async with self._wsdl_session.get(url, proxy=self._config.proxy) as resp:
198
+ content = await resp.read()
199
+
200
+ if resp.status >= 400:
201
+ raise TransportError(
202
+ status_code=resp.status,
203
+ message=content.decode(errors='ignore'),
204
+ )
205
+
206
+ return content
207
+
208
+ return await _fetch()
209
+
210
+ async def post(
211
+ self,
212
+ address: str,
213
+ message: bytes,
214
+ headers: dict[str, str],
215
+ *,
216
+ timeout: int | None = None,
217
+ ) -> Response:
218
+ logger.debug('SOAP POST → %s\n%s', address, message)
219
+
220
+ request_timeout = ClientTimeout(total=timeout) if timeout is not None else None
221
+
222
+ async def _post() -> Response:
223
+ async with self._session.post(
224
+ address,
225
+ data=message,
226
+ headers=headers,
227
+ proxy=self._config.proxy,
228
+ timeout=request_timeout,
229
+ ) as resp:
230
+ body = await resp.read()
231
+ logger.debug('SOAP ← %s (HTTP %d)\n%s', address, resp.status, body)
232
+
233
+ return self._build_response(resp, body)
234
+
235
+ return await _post()
236
+
237
+ async def post_xml(
238
+ self,
239
+ address: str,
240
+ envelope: Any,
241
+ headers: dict[str, str],
242
+ ) -> Response:
243
+ message = etree_to_string(envelope)
244
+
245
+ return await self.post(address, message, headers)
246
+
247
+ async def get(
248
+ self,
249
+ address: str,
250
+ params: dict[str, str],
251
+ headers: dict[str, str],
252
+ ) -> Response:
253
+ async with self._session.get(
254
+ address,
255
+ params=params,
256
+ headers=headers,
257
+ proxy=self._config.proxy,
258
+ ) as resp:
259
+ body = await resp.read()
260
+
261
+ return self._build_response(resp, body)
262
+
263
+
264
+ def build_soap_client(
265
+ wsdl_url: str,
266
+ transport: AsyncTransport,
267
+ plugins: Sequence[Plugin] | None = None,
268
+ ) -> AsyncClient:
269
+ """
270
+ Construct a zeep AsyncClient with the supplied transport and plugins.
271
+
272
+ Raises ValueError if *wsdl_url* is empty or None.
273
+ """
274
+ if not wsdl_url:
275
+ msg = 'wsdl_url must be a non-empty string.'
276
+
277
+ raise ValueError(msg)
278
+
279
+ return AsyncClient(wsdl_url, transport=transport, plugins=list(plugins or []))
280
+
281
+
282
+ @asynccontextmanager
283
+ async def soap_client(
284
+ wsdl_url: str,
285
+ *,
286
+ config: TransportConfig | None = None,
287
+ session: ClientSession | None = None,
288
+ plugins: Sequence[Plugin] | None = None,
289
+ ) -> AsyncIterator[AsyncClient]:
290
+ """
291
+ Async context manager that yields a ready-to-use zeep AsyncClient and
292
+ cleans up the transport on exit.
293
+
294
+ Example::
295
+
296
+ async with soap_client('https://example.com/service?wsdl') as client:
297
+ result = await client.service.GetData(id=42)
298
+ """
299
+ transport = AsyncTransport.from_config(config, session=session)
300
+
301
+ async with transport:
302
+ yield build_soap_client(wsdl_url, transport, plugins)
303
+
304
+
305
+ def get_history_plugin(client: AsyncClient) -> HistoryPlugin | None:
306
+ """Return the first HistoryPlugin attached to *client*, or None."""
307
+ return next(
308
+ (p for p in client.plugins if isinstance(p, HistoryPlugin)),
309
+ None,
310
+ )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python3-commons
3
- Version: 0.17.7
3
+ Version: 0.18.0
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
@@ -16,15 +16,14 @@ Requires-Dist: msgpack~=1.1.2
16
16
  Requires-Dist: msgspec==0.21.1
17
17
  Requires-Dist: pydantic-settings~=2.14.0
18
18
  Provides-Extra: all
19
- Requires-Dist: python3_commons[api-client,audit,authn,authz,cache,database,object-storage]; extra == "all"
19
+ Requires-Dist: python3_commons[api-client,audit,authn,authz,cache,database,object-storage,soap-client]; extra == "all"
20
20
  Provides-Extra: api-client
21
21
  Requires-Dist: aiohttp[speedups]<3.15.0,>=3.13.5; extra == "api-client"
22
22
  Requires-Dist: lxml~=6.1.0; extra == "api-client"
23
- Requires-Dist: zeep~=4.3.2; extra == "api-client"
24
23
  Requires-Dist: python3_commons[object-storage]; extra == "api-client"
25
24
  Provides-Extra: audit
26
25
  Requires-Dist: lxml~=6.1.0; extra == "audit"
27
- Requires-Dist: zeep~=4.3.2; extra == "audit"
26
+ Requires-Dist: zeep[async]~=4.3.2; extra == "audit"
28
27
  Requires-Dist: python3_commons[object-storage]; extra == "audit"
29
28
  Provides-Extra: authn
30
29
  Requires-Dist: aiohttp[speedups]<3.15.0,>=3.13.5; extra == "authn"
@@ -40,6 +39,11 @@ Requires-Dist: SQLAlchemy[asyncio]~=2.0.49; extra == "database"
40
39
  Provides-Extra: object-storage
41
40
  Requires-Dist: aiobotocore~=3.5.0; extra == "object-storage"
42
41
  Requires-Dist: object-storage-client==0.0.23; extra == "object-storage"
42
+ Provides-Extra: soap-client
43
+ Requires-Dist: aiohttp[speedups]<3.15.0,>=3.13.5; extra == "soap-client"
44
+ Requires-Dist: lxml~=6.1.0; extra == "soap-client"
45
+ Requires-Dist: requests~=2.33.1; extra == "soap-client"
46
+ Requires-Dist: zeep[async]~=4.3.2; extra == "soap-client"
43
47
  Dynamic: license-file
44
48
 
45
49
  Re-usable Python3 code
@@ -36,6 +36,7 @@ src/python3_commons/generators.py
36
36
  src/python3_commons/helpers.py
37
37
  src/python3_commons/object_storage.py
38
38
  src/python3_commons/permissions.py
39
+ src/python3_commons/soap_client.py
39
40
  src/python3_commons.egg-info/PKG-INFO
40
41
  src/python3_commons.egg-info/SOURCES.txt
41
42
  src/python3_commons.egg-info/dependency_links.txt
@@ -3,17 +3,16 @@ msgspec==0.21.1
3
3
  pydantic-settings~=2.14.0
4
4
 
5
5
  [all]
6
- python3_commons[api-client,audit,authn,authz,cache,database,object-storage]
6
+ python3_commons[api-client,audit,authn,authz,cache,database,object-storage,soap-client]
7
7
 
8
8
  [api-client]
9
9
  aiohttp[speedups]<3.15.0,>=3.13.5
10
10
  lxml~=6.1.0
11
- zeep~=4.3.2
12
11
  python3_commons[object-storage]
13
12
 
14
13
  [audit]
15
14
  lxml~=6.1.0
16
- zeep~=4.3.2
15
+ zeep[async]~=4.3.2
17
16
  python3_commons[object-storage]
18
17
 
19
18
  [authn]
@@ -34,3 +33,9 @@ SQLAlchemy[asyncio]~=2.0.49
34
33
  [object-storage]
35
34
  aiobotocore~=3.5.0
36
35
  object-storage-client==0.0.23
36
+
37
+ [soap-client]
38
+ aiohttp[speedups]<3.15.0,>=3.13.5
39
+ lxml~=6.1.0
40
+ requests~=2.33.1
41
+ zeep[async]~=4.3.2
@@ -129,6 +129,18 @@ wheels = [
129
129
  { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
130
130
  ]
131
131
 
132
+ [[package]]
133
+ name = "anyio"
134
+ version = "4.13.0"
135
+ source = { registry = "https://pypi.org/simple" }
136
+ dependencies = [
137
+ { name = "idna" },
138
+ ]
139
+ sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
140
+ wheels = [
141
+ { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
142
+ ]
143
+
132
144
  [[package]]
133
145
  name = "asyncpg"
134
146
  version = "0.31.0"
@@ -467,6 +479,43 @@ wheels = [
467
479
  { url = "https://files.pythonhosted.org/packages/15/32/77ee8a6c1564fc345a491a4e85b3bf360e4cf26eac98c4532d2fdb96e01f/greenlet-3.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d60097128cb0a1cab9ea541186ea13cd7b847b8449a7787c2e2350da0cb82d86", size = 245324, upload-time = "2026-04-27T12:24:40.295Z" },
468
480
  ]
469
481
 
482
+ [[package]]
483
+ name = "h11"
484
+ version = "0.16.0"
485
+ source = { registry = "https://pypi.org/simple" }
486
+ sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
487
+ wheels = [
488
+ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
489
+ ]
490
+
491
+ [[package]]
492
+ name = "httpcore"
493
+ version = "1.0.9"
494
+ source = { registry = "https://pypi.org/simple" }
495
+ dependencies = [
496
+ { name = "certifi" },
497
+ { name = "h11" },
498
+ ]
499
+ sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
500
+ wheels = [
501
+ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
502
+ ]
503
+
504
+ [[package]]
505
+ name = "httpx"
506
+ version = "0.28.1"
507
+ source = { registry = "https://pypi.org/simple" }
508
+ dependencies = [
509
+ { name = "anyio" },
510
+ { name = "certifi" },
511
+ { name = "httpcore" },
512
+ { name = "idna" },
513
+ ]
514
+ sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
515
+ wheels = [
516
+ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
517
+ ]
518
+
470
519
  [[package]]
471
520
  name = "identify"
472
521
  version = "2.6.19"
@@ -1004,29 +1053,28 @@ all = [
1004
1053
  { name = "asyncpg" },
1005
1054
  { name = "lxml" },
1006
1055
  { name = "object-storage-client" },
1056
+ { name = "requests" },
1007
1057
  { name = "sqlalchemy", extra = ["asyncio"] },
1008
1058
  { name = "valkey", extra = ["libvalkey"] },
1009
- { name = "zeep" },
1059
+ { name = "zeep", extra = ["async"] },
1010
1060
  ]
1011
1061
  api-client = [
1012
1062
  { name = "aiobotocore" },
1013
1063
  { name = "aiohttp", extra = ["speedups"] },
1014
1064
  { name = "lxml" },
1015
1065
  { name = "object-storage-client" },
1016
- { name = "zeep" },
1017
1066
  ]
1018
1067
  audit = [
1019
1068
  { name = "aiobotocore" },
1020
1069
  { name = "lxml" },
1021
1070
  { name = "object-storage-client" },
1022
- { name = "zeep" },
1071
+ { name = "zeep", extra = ["async"] },
1023
1072
  ]
1024
1073
  authn = [
1025
1074
  { name = "aiobotocore" },
1026
1075
  { name = "aiohttp", extra = ["speedups"] },
1027
1076
  { name = "lxml" },
1028
1077
  { name = "object-storage-client" },
1029
- { name = "zeep" },
1030
1078
  ]
1031
1079
  authz = [
1032
1080
  { name = "aiobotocore" },
@@ -1035,7 +1083,6 @@ authz = [
1035
1083
  { name = "lxml" },
1036
1084
  { name = "object-storage-client" },
1037
1085
  { name = "sqlalchemy", extra = ["asyncio"] },
1038
- { name = "zeep" },
1039
1086
  ]
1040
1087
  cache = [
1041
1088
  { name = "valkey", extra = ["libvalkey"] },
@@ -1048,6 +1095,12 @@ object-storage = [
1048
1095
  { name = "aiobotocore" },
1049
1096
  { name = "object-storage-client" },
1050
1097
  ]
1098
+ soap-client = [
1099
+ { name = "aiohttp", extra = ["speedups"] },
1100
+ { name = "lxml" },
1101
+ { name = "requests" },
1102
+ { name = "zeep", extra = ["async"] },
1103
+ ]
1051
1104
 
1052
1105
  [package.dev-dependencies]
1053
1106
  dev = [
@@ -1071,25 +1124,28 @@ requires-dist = [
1071
1124
  { name = "aiobotocore", marker = "extra == 'object-storage'", specifier = "~=3.5.0" },
1072
1125
  { name = "aiohttp", extras = ["speedups"], marker = "extra == 'api-client'", specifier = ">=3.13.5,<3.15.0" },
1073
1126
  { name = "aiohttp", extras = ["speedups"], marker = "extra == 'authn'", specifier = ">=3.13.5,<3.15.0" },
1127
+ { name = "aiohttp", extras = ["speedups"], marker = "extra == 'soap-client'", specifier = ">=3.13.5,<3.15.0" },
1074
1128
  { name = "asyncpg", marker = "extra == 'database'", specifier = "~=0.31.0" },
1075
1129
  { name = "lxml", marker = "extra == 'api-client'", specifier = "~=6.1.0" },
1076
1130
  { name = "lxml", marker = "extra == 'audit'", specifier = "~=6.1.0" },
1131
+ { name = "lxml", marker = "extra == 'soap-client'", specifier = "~=6.1.0" },
1077
1132
  { name = "msgpack", specifier = "~=1.1.2" },
1078
1133
  { name = "msgspec", specifier = "==0.21.1" },
1079
1134
  { name = "object-storage-client", marker = "extra == 'object-storage'", specifier = "==0.0.23" },
1080
1135
  { name = "pydantic-settings", specifier = "~=2.14.0" },
1081
1136
  { name = "python3-commons", extras = ["api-client"], marker = "extra == 'authn'" },
1082
1137
  { name = "python3-commons", extras = ["api-client"], marker = "extra == 'authz'" },
1083
- { name = "python3-commons", extras = ["api-client", "audit", "authn", "authz", "cache", "database", "object-storage"], marker = "extra == 'all'" },
1138
+ { name = "python3-commons", extras = ["api-client", "audit", "authn", "authz", "cache", "database", "object-storage", "soap-client"], marker = "extra == 'all'" },
1084
1139
  { name = "python3-commons", extras = ["database"], marker = "extra == 'authz'" },
1085
1140
  { name = "python3-commons", extras = ["object-storage"], marker = "extra == 'api-client'" },
1086
1141
  { name = "python3-commons", extras = ["object-storage"], marker = "extra == 'audit'" },
1142
+ { name = "requests", marker = "extra == 'soap-client'", specifier = "~=2.33.1" },
1087
1143
  { name = "sqlalchemy", extras = ["asyncio"], marker = "extra == 'database'", specifier = "~=2.0.49" },
1088
1144
  { name = "valkey", extras = ["libvalkey"], marker = "extra == 'cache'", specifier = "~=6.1.1" },
1089
- { name = "zeep", marker = "extra == 'api-client'", specifier = "~=4.3.2" },
1090
- { name = "zeep", marker = "extra == 'audit'", specifier = "~=4.3.2" },
1145
+ { name = "zeep", extras = ["async"], marker = "extra == 'audit'", specifier = "~=4.3.2" },
1146
+ { name = "zeep", extras = ["async"], marker = "extra == 'soap-client'", specifier = "~=4.3.2" },
1091
1147
  ]
1092
- provides-extras = ["all", "api-client", "audit", "authn", "authz", "cache", "database", "object-storage"]
1148
+ provides-extras = ["all", "api-client", "audit", "authn", "authz", "cache", "database", "object-storage", "soap-client"]
1093
1149
 
1094
1150
  [package.metadata.requires-dev]
1095
1151
  dev = [
@@ -1462,3 +1518,9 @@ sdist = { url = "https://files.pythonhosted.org/packages/e8/06/4f1d3ff61e9301635
1462
1518
  wheels = [
1463
1519
  { url = "https://files.pythonhosted.org/packages/cd/78/f43f3feb70d67cbe260ec5b682ecc3c1850c8f437f1df707495126e51817/zeep-4.3.2-py3-none-any.whl", hash = "sha256:ed08c3179709172bfaaa9b76a6a545f8a57043ec6218e64e9deb81ff1e0ff79b", size = 101853, upload-time = "2025-09-15T10:26:02.12Z" },
1464
1520
  ]
1521
+
1522
+ [package.optional-dependencies]
1523
+ async = [
1524
+ { name = "httpx" },
1525
+ { name = "packaging" },
1526
+ ]
@@ -1,67 +0,0 @@
1
- import asyncio
2
- import io
3
- import logging
4
- from datetime import UTC, datetime
5
- from typing import TYPE_CHECKING
6
- from uuid import uuid4
7
-
8
- try:
9
- from lxml import etree
10
- from zeep.plugins import Plugin
11
- except ImportError as e:
12
- msg = 'Install python3-commons[api-client] to use this feature'
13
- raise RuntimeError(msg) from e
14
-
15
- from python3_commons import object_storage
16
- from python3_commons.conf import S3Settings, s3_settings
17
-
18
- if TYPE_CHECKING:
19
- from zeep.wsdl.definitions import AbstractOperation
20
-
21
- logger = logging.getLogger(__name__)
22
-
23
-
24
- async def write_audit_data(settings: S3Settings, key: str, data: bytes) -> None:
25
- if settings.aws_secret_access_key:
26
- try:
27
- await object_storage.put_object(settings.s3_bucket, f'audit/{key}', io.BytesIO(data), len(data))
28
- except Exception:
29
- logger.exception('Failed storing object in storage.')
30
- else:
31
- logger.debug('Stored object in storage: %s', key)
32
- else:
33
- logger.debug('S3 is not configured, not storing object in storage: %s', key)
34
-
35
-
36
- class ZeepAuditPlugin(Plugin):
37
- def __init__(self, audit_name: str = 'zeep') -> None:
38
- super().__init__()
39
- self.audit_name = audit_name
40
-
41
- def store_audit_in_s3(self, envelope, operation: AbstractOperation, direction: str) -> None:
42
- xml = etree.tostring(envelope, encoding='UTF-8', pretty_print=True)
43
- now = datetime.now(tz=UTC)
44
- date_path = now.strftime('%Y/%m/%d')
45
- timestamp = now.strftime('%H%M%S')
46
- path = f'{date_path}/{self.audit_name}/{operation.name}/{timestamp}_{str(uuid4())[-12:]}_{direction}.xml'
47
- coro = write_audit_data(s3_settings, path, xml)
48
-
49
- try:
50
- loop = asyncio.get_running_loop()
51
- except RuntimeError:
52
- loop = None
53
-
54
- if loop and loop.is_running():
55
- loop.create_task(coro)
56
- else:
57
- asyncio.run(coro)
58
-
59
- def ingress(self, envelope, http_headers, operation: AbstractOperation):
60
- self.store_audit_in_s3(envelope, operation, 'ingress')
61
-
62
- return envelope, http_headers
63
-
64
- def egress(self, envelope, http_headers, operation: AbstractOperation, binding_options):
65
- self.store_audit_in_s3(envelope, operation, 'egress')
66
-
67
- return envelope, http_headers