dsw-tdk 3.12.0__py3-none-any.whl → 4.27.0__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.
@@ -1,15 +1,16 @@
1
- """DSW Template Development Kit
2
-
3
- Template Development Kit for `Data Stewardship Wizard`_.
4
-
5
- .. _Data Stewardship Wizard:
6
- https://ds-wizard.org
7
-
8
- """
9
- from dsw_tdk.cli import main
10
- from dsw_tdk.consts import APP, VERSION
11
-
12
- __app__ = APP
13
- __version__ = VERSION
14
-
15
- __all__ = ['__app__', '__version__', 'main']
1
+ """DSW Template Development Kit
2
+
3
+ Template Development Kit for `Data Stewardship Wizard`_.
4
+
5
+ .. _Data Stewardship Wizard:
6
+ https://ds-wizard.org
7
+
8
+ """
9
+ from . import consts
10
+ from .cli import main
11
+
12
+
13
+ __app__ = consts.APP
14
+ __version__ = consts.VERSION
15
+
16
+ __all__ = ['__app__', '__version__', 'main']
dsw/tdk/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ from .cli import main
2
+
3
+
4
+ # pylint: disable-next=no-value-for-parameter
5
+ main(ctx=None)
dsw/tdk/api_client.py ADDED
@@ -0,0 +1,407 @@
1
+ import functools
2
+ import pathlib
3
+ import urllib.parse
4
+
5
+ import aiohttp
6
+ import aiohttp.client_exceptions
7
+
8
+ from . import consts
9
+ from .model import Template, TemplateFile, TemplateFileType
10
+
11
+
12
+ class WizardCommunicationError(RuntimeError):
13
+
14
+ def __init__(self, reason: str, message: str):
15
+ """
16
+ Exception representing communication error with DSW.
17
+
18
+ Args:
19
+ reason (str): Cause of the error.
20
+ message (str): Additional information about the error.
21
+ """
22
+ self.reason = reason
23
+ self.message = message
24
+
25
+
26
+ def handle_client_errors(func):
27
+ @functools.wraps(func)
28
+ async def handled_client_call(job, *args, **kwargs):
29
+ try:
30
+ return await func(job, *args, **kwargs)
31
+ except WizardCommunicationError as e:
32
+ # Already DSWCommunicationError (re-raise)
33
+ raise e
34
+ except aiohttp.client_exceptions.ContentTypeError as e:
35
+ raise WizardCommunicationError(
36
+ reason='Unexpected response type',
37
+ message=e.message,
38
+ ) from e
39
+ except aiohttp.client_exceptions.ClientResponseError as e:
40
+ raise WizardCommunicationError(
41
+ reason='Error response status',
42
+ message=f'Server responded with error HTTP status {e.status}: {e.message}',
43
+ ) from e
44
+ except aiohttp.client_exceptions.InvalidURL as e:
45
+ raise WizardCommunicationError(
46
+ reason='Invalid URL',
47
+ message=f'Provided API URL seems invalid: {e.url}',
48
+ ) from e
49
+ except aiohttp.client_exceptions.ClientConnectorError as e:
50
+ raise WizardCommunicationError(
51
+ reason='Server unreachable',
52
+ message=f'Desired server is not reachable (errno {e.os_error.errno})',
53
+ ) from e
54
+ except Exception as e:
55
+ raise WizardCommunicationError(
56
+ reason='Communication error',
57
+ message=f'Communication with server failed ({e})',
58
+ ) from e
59
+ return handled_client_call
60
+
61
+
62
+ # pylint: disable-next=too-many-public-methods
63
+ class WizardAPIClient:
64
+
65
+ def _headers(self, extra=None):
66
+ headers = {
67
+ 'Authorization': f'Bearer {self.token}',
68
+ 'User-Agent': f'{consts.APP}/{consts.VERSION}',
69
+ }
70
+ if extra is not None:
71
+ headers.update(extra)
72
+ return headers
73
+
74
+ @staticmethod
75
+ def _check_status(r: aiohttp.ClientResponse, expected_status):
76
+ r.raise_for_status()
77
+ if r.status != expected_status:
78
+ raise WizardCommunicationError(
79
+ reason='Unexpected response status',
80
+ message=f'Server responded with unexpected HTTP status {r.status}: '
81
+ f'{r.reason} (expecting {expected_status})',
82
+ )
83
+
84
+ def __init__(self, api_url: str, api_key: str, session=None):
85
+ """
86
+ Exception representing communication error with DSW.
87
+
88
+ Args:
89
+ api_url (str): URL of DSW API for HTTP communication.
90
+ session (aiohttp.ClientSession): Optional custom session for HTTP communication.
91
+ """
92
+ self.api_url = api_url
93
+ self.token = api_key
94
+ self.session = session or aiohttp.ClientSession(
95
+ connector=aiohttp.TCPConnector(ssl=False),
96
+ )
97
+
98
+ @property
99
+ def templates_endpoint(self):
100
+ return f'{self.api_url}/document-templates'
101
+
102
+ @property
103
+ def drafts_endpoint(self):
104
+ return f'{self.api_url}/document-template-drafts'
105
+
106
+ async def close(self):
107
+ await self.session.close()
108
+
109
+ async def safe_close(self) -> bool:
110
+ try:
111
+ await self.session.close()
112
+ return True
113
+ except Exception:
114
+ return False
115
+
116
+ async def _post_json(self, endpoint, json) -> dict:
117
+ async with self.session.post(
118
+ url=f'{self.api_url}{endpoint}',
119
+ json=json,
120
+ headers=self._headers(),
121
+ ) as r:
122
+ self._check_status(r, expected_status=201)
123
+ return await r.json()
124
+
125
+ async def _put_json(self, endpoint, json) -> dict:
126
+ async with self.session.put(
127
+ url=f'{self.api_url}{endpoint}',
128
+ json=json,
129
+ headers=self._headers(),
130
+ ) as r:
131
+ self._check_status(r, expected_status=200)
132
+ return await r.json()
133
+
134
+ async def _get_json(self, endpoint) -> dict:
135
+ async with self.session.get(
136
+ url=f'{self.api_url}{endpoint}',
137
+ headers=self._headers(),
138
+ ) as r:
139
+ self._check_status(r, expected_status=200)
140
+ return await r.json()
141
+
142
+ async def _get_bytes(self, endpoint) -> bytes:
143
+ async with self.session.get(
144
+ url=f'{self.api_url}{endpoint}',
145
+ headers=self._headers(),
146
+ ) as r:
147
+ self._check_status(r, expected_status=200)
148
+ return await r.read()
149
+
150
+ async def _delete(self, endpoint) -> bool:
151
+ async with self.session.delete(
152
+ url=f'{self.api_url}{endpoint}',
153
+ headers=self._headers(),
154
+ ) as r:
155
+ return r.status == 204
156
+
157
+ @handle_client_errors
158
+ async def login(self, email: str, password: str) -> str | None:
159
+ req = {'email': email, 'password': password}
160
+ body = await self._post_json('/tokens', json=req)
161
+ token_value = body.get('token')
162
+ if not isinstance(token_value, str):
163
+ raise WizardCommunicationError(
164
+ reason='Invalid response',
165
+ message='Server did not return a valid token',
166
+ )
167
+ self.token = token_value
168
+ return self.token
169
+
170
+ @handle_client_errors
171
+ async def get_current_user(self) -> dict:
172
+ return await self._get_json('/users/current')
173
+
174
+ @handle_client_errors
175
+ async def check_template_exists(self, remote_id: str) -> bool:
176
+ async with self.session.get(
177
+ url=f'{self.templates_endpoint}/{remote_id}',
178
+ headers=self._headers(),
179
+ ) as r:
180
+ if r.status == 404:
181
+ return False
182
+ self._check_status(r, expected_status=200)
183
+ return True
184
+
185
+ @handle_client_errors
186
+ async def check_draft_exists(self, remote_id: str) -> bool:
187
+ async with self.session.get(
188
+ url=f'{self.drafts_endpoint}/{remote_id}',
189
+ headers=self._headers(),
190
+ ) as r:
191
+ if r.status == 404:
192
+ return False
193
+ self._check_status(r, expected_status=200)
194
+ return True
195
+
196
+ @handle_client_errors
197
+ async def get_templates(self) -> list[Template]:
198
+ body = await self._get_json('/document-templates/all')
199
+ return list(map(_load_remote_template, body))
200
+
201
+ @handle_client_errors
202
+ async def get_drafts(self) -> list[Template]:
203
+ body = await self._get_json('/document-template-drafts?size=10000')
204
+ drafts = body.get('_embedded', {}).get('documentTemplateDrafts', [])
205
+ return list(map(_load_remote_template, drafts))
206
+
207
+ @handle_client_errors
208
+ async def get_template_bundle(self, remote_id: str) -> bytes:
209
+ remote_file_descriptor = await self._get_json(
210
+ endpoint=f'/document-templates/{remote_id}/bundle'
211
+ f'?Authorization=Bearer%20{self.token}',
212
+ )
213
+ s3_url = remote_file_descriptor['url']
214
+ async with self.session.get(url=s3_url) as r:
215
+ self._check_status(r, expected_status=200)
216
+ return await r.content.read()
217
+
218
+ @handle_client_errors
219
+ async def get_template_draft(self, remote_id: str) -> Template:
220
+ body = await self._get_json(f'/document-template-drafts/{remote_id}')
221
+ return _load_remote_template(body)
222
+
223
+ @handle_client_errors
224
+ async def get_template_draft_files(self, remote_id: str) -> list[TemplateFile]:
225
+ body = await self._get_json(f'/document-template-drafts/{remote_id}/files')
226
+ result = []
227
+ for file_body in body:
228
+ file_id = file_body['uuid']
229
+ file = await self.get_template_draft_file(remote_id, file_id)
230
+ result.append(file)
231
+ return result
232
+
233
+ @handle_client_errors
234
+ async def get_template_draft_file(self, remote_id: str, file_id: str) -> TemplateFile:
235
+ body = await self._get_json(f'/document-template-drafts/{remote_id}/files/{file_id}')
236
+ return _load_remote_file(body)
237
+
238
+ @handle_client_errors
239
+ async def get_template_draft_assets(self, remote_id: str) -> list[TemplateFile]:
240
+ body = await self._get_json(f'/document-template-drafts/{remote_id}/assets')
241
+ result = []
242
+ for file_body in body:
243
+ asset_id = file_body['uuid']
244
+ template_asset = await self.get_template_draft_asset(remote_id, asset_id)
245
+ result.append(template_asset)
246
+ return result
247
+
248
+ @handle_client_errors
249
+ async def get_template_draft_asset(self, remote_id: str, asset_id: str) -> TemplateFile:
250
+ body = await self._get_json(f'/document-template-drafts/{remote_id}'
251
+ f'/assets/{asset_id}')
252
+ content = await self._get_bytes(f'/document-template-drafts/{remote_id}'
253
+ f'/assets/{asset_id}/content')
254
+ return _load_remote_asset(body, content)
255
+
256
+ @handle_client_errors
257
+ async def create_new_template_draft(self, template: Template, remote_id: str) -> Template:
258
+ created_template = await self._post_json(
259
+ endpoint='/document-template-drafts',
260
+ json=template.serialize_for_create(),
261
+ )
262
+ if created_template['id'] != remote_id:
263
+ raise RuntimeError('Organization ID changed during the process')
264
+ body = await self._put_json(
265
+ endpoint=f'/document-template-drafts/{remote_id}',
266
+ json=template.serialize_for_update(),
267
+ )
268
+ return _load_remote_template(body)
269
+
270
+ @handle_client_errors
271
+ async def update_template_draft(self, template: Template, remote_id: str) -> Template:
272
+ body = await self._put_json(
273
+ endpoint=f'/document-template-drafts/{remote_id}',
274
+ json=template.serialize_for_update(),
275
+ )
276
+ return _load_remote_template(body)
277
+
278
+ @handle_client_errors
279
+ async def post_template_draft_file(self, remote_id: str, file: TemplateFile):
280
+ data = await self._post_json(
281
+ endpoint=f'/document-template-drafts/{remote_id}/files',
282
+ json={
283
+ 'fileName': file.filename.as_posix(),
284
+ 'content': file.content.decode(consts.DEFAULT_ENCODING),
285
+ },
286
+ )
287
+ return _load_remote_file(data)
288
+
289
+ @handle_client_errors
290
+ async def put_template_draft_file_content(self, remote_id: str, file: TemplateFile):
291
+ self.session.headers.update(self._headers())
292
+ async with self.session.put(
293
+ f'{self.api_url}/document-template-drafts/{remote_id}'
294
+ f'/files/{file.remote_id}/content',
295
+ data=file.content,
296
+ headers={'Content-Type': 'text/plain;charset=UTF-8'},
297
+ ) as r:
298
+ self._check_status(r, expected_status=200)
299
+ body = await r.json()
300
+ return _load_remote_file(body)
301
+
302
+ @handle_client_errors
303
+ async def post_template_draft_asset(self, remote_id: str, file: TemplateFile):
304
+ data = aiohttp.FormData()
305
+ data.add_field(
306
+ name='file',
307
+ value=file.content,
308
+ filename=file.filename.as_posix(),
309
+ content_type=file.content_type,
310
+ )
311
+ data.add_field(
312
+ name='fileName',
313
+ value=file.filename.as_posix(),
314
+ )
315
+ async with self.session.post(
316
+ f'{self.api_url}/document-template-drafts/{remote_id}/assets',
317
+ data=data,
318
+ headers=self._headers(),
319
+ ) as r:
320
+ self._check_status(r, expected_status=201)
321
+ body = await r.json()
322
+ return _load_remote_asset(body, file.content)
323
+
324
+ @handle_client_errors
325
+ async def put_template_draft_asset_content(self, remote_id: str, file: TemplateFile):
326
+ data = aiohttp.FormData()
327
+ data.add_field(
328
+ name='file',
329
+ value=file.content,
330
+ filename=file.filename.as_posix(),
331
+ content_type=file.content_type,
332
+ )
333
+ data.add_field(
334
+ name='fileName',
335
+ value=file.filename.as_posix(),
336
+ )
337
+ async with self.session.put(
338
+ f'{self.api_url}/document-template-drafts/{remote_id}'
339
+ f'/assets/{file.remote_id}/content',
340
+ data=data,
341
+ headers=self._headers(),
342
+ ) as r:
343
+ self._check_status(r, expected_status=200)
344
+ body = await r.json()
345
+ return _load_remote_asset(body, file.content)
346
+
347
+ @handle_client_errors
348
+ async def delete_template_draft(self, remote_id: str) -> bool:
349
+ return await self._delete(f'/document-template-drafts/{remote_id}')
350
+
351
+ @handle_client_errors
352
+ async def delete_template_draft_file(self, remote_id: str, file_id: str) -> bool:
353
+ if file_id is None:
354
+ raise RuntimeWarning('Tried to delete file without ID (None)')
355
+ return await self._delete(f'/document-template-drafts/{remote_id}/files/{file_id}')
356
+
357
+ @handle_client_errors
358
+ async def delete_template_draft_asset(self, remote_id: str, asset_id: str) -> bool:
359
+ if asset_id is None:
360
+ raise RuntimeWarning('Tried to delete asset without ID (None)')
361
+ return await self._delete(f'/document-template-drafts/{remote_id}/assets/{asset_id}')
362
+
363
+ @handle_client_errors
364
+ async def get_api_version(self) -> tuple[str, str | None]:
365
+ body = await self._get_json('/')
366
+ version = body.get('version')
367
+ metamodel_version = None
368
+ for item in body.get('metamodelVersions', []):
369
+ if item.get('name', '') == 'Document Template':
370
+ metamodel_version = item.get('version')
371
+ if version is None:
372
+ raise WizardCommunicationError(
373
+ reason='Invalid response',
374
+ message='Server did not return valid API version information (incompatible TDK?)',
375
+ )
376
+ return version, metamodel_version
377
+
378
+ @handle_client_errors
379
+ async def get_organization_id(self) -> str:
380
+ body = await self._get_json('/configs/bootstrap')
381
+ return body['organization']['organizationId']
382
+
383
+
384
+ def _load_remote_file(data: dict) -> TemplateFile:
385
+ content: str = data.get('content', '')
386
+ filename: str = str(data.get('fileName', ''))
387
+ return TemplateFile(
388
+ remote_id=data.get('uuid'),
389
+ remote_type=TemplateFileType.FILE,
390
+ filename=pathlib.Path(urllib.parse.unquote(filename)),
391
+ content=content.encode(encoding=consts.DEFAULT_ENCODING),
392
+ )
393
+
394
+
395
+ def _load_remote_asset(data: dict, content: bytes) -> TemplateFile:
396
+ filename = str(data.get('fileName', ''))
397
+ return TemplateFile(
398
+ remote_id=data.get('uuid'),
399
+ remote_type=TemplateFileType.ASSET,
400
+ filename=pathlib.Path(urllib.parse.unquote(filename)),
401
+ content_type=data.get('contentType'),
402
+ content=content,
403
+ )
404
+
405
+
406
+ def _load_remote_template(data: dict) -> Template:
407
+ return Template.load_remote(data)
dsw/tdk/build_info.py ADDED
@@ -0,0 +1,17 @@
1
+ # Generated file
2
+ # - do not overwrite
3
+ # - do not include in git
4
+ from collections import namedtuple
5
+
6
+ BuildInfo = namedtuple(
7
+ 'BuildInfo',
8
+ ['version', 'built_at', 'sha', 'branch', 'tag'],
9
+ )
10
+
11
+ BUILD_INFO = BuildInfo(
12
+ version='v4.27.0~8ec71bd',
13
+ built_at='2026-02-03 08:45:12Z',
14
+ sha='8ec71bd85dfbea66adedb6590f7d76ae5143bbaa',
15
+ branch='HEAD',
16
+ tag='v4.27.0',
17
+ )