odg-client 0.1.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.
delivery/__init__.py ADDED
@@ -0,0 +1,62 @@
1
+ import time
2
+
3
+ import jwt as jwt_mod # avoid overwriting delivery.jwt
4
+
5
+ import delivery.client
6
+
7
+
8
+ def _create_github_jwt(
9
+ github_app_id: int,
10
+ github_app_private_key: str | bytes,
11
+ ttl_seconds: int = 600, # 10m
12
+ algorithm: str = 'RS256',
13
+ ) -> str:
14
+ if isinstance(github_app_private_key, str):
15
+ github_app_private_key = github_app_private_key.encode('utf-8')
16
+
17
+ github_app_id = int(github_app_id) # validate input
18
+ now = int(time.time()) - 60 # set to 60s in the past to prevent clock skew failures
19
+
20
+ payload = {
21
+ 'iat': now,
22
+ 'exp': now + ttl_seconds,
23
+ 'iss': str(github_app_id),
24
+ }
25
+
26
+ return jwt_mod.encode(
27
+ payload=payload,
28
+ key=github_app_private_key,
29
+ algorithm=algorithm,
30
+ )
31
+
32
+
33
+ def client_from_github_app_secret(
34
+ github_app_id: int,
35
+ github_app_private_key: str | bytes,
36
+ github_api_url: str,
37
+ delivery_service_base_url: str,
38
+ ) -> delivery.client.DeliveryServiceClient:
39
+ """
40
+ an opinionated factory-function creating DeliveryService-Client-instances using a
41
+ GitHub-App for authentication. This is deemed especially useful for usage in GitHub-Actions
42
+ pipelines, where usage of Service-Accounts is not desirable.
43
+
44
+ github_api_url must match the GH(E)-Instance the used GitHub-App is installed on. The targettted
45
+ Delivery-Service must offer a configuration for this GH(E)-Instance.
46
+ """
47
+
48
+ def token_lookup(api_url: str, /):
49
+ if api_url != github_api_url:
50
+ return None
51
+
52
+ return _create_github_jwt(
53
+ github_app_id=github_app_id,
54
+ github_app_private_key=github_app_private_key,
55
+ )
56
+
57
+ return delivery.client.DeliveryServiceClient(
58
+ routes=delivery.client.DeliveryServiceRoutes(
59
+ base_url=delivery_service_base_url,
60
+ ),
61
+ auth_token_lookup=token_lookup,
62
+ )
delivery/client.py ADDED
@@ -0,0 +1,807 @@
1
+ import collections.abc
2
+ import dataclasses
3
+ import datetime
4
+ import logging
5
+ import time
6
+ import typing
7
+
8
+ import dacite
9
+ import requests.exceptions
10
+ import requests.sessions
11
+
12
+ import ocm
13
+
14
+ import delivery.jwt
15
+ import delivery.model as dm
16
+ import delivery.util
17
+
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ class DeliveryServiceRoutes:
23
+ def __init__(self, base_url: str):
24
+ self._base_url = base_url
25
+
26
+ def auth(self):
27
+ return delivery.util.urljoin(
28
+ self._base_url,
29
+ 'auth',
30
+ )
31
+
32
+ def auth_configs(self):
33
+ return delivery.util.urljoin(
34
+ self._base_url,
35
+ 'auth',
36
+ 'configs',
37
+ )
38
+
39
+ def openid_configuration(self):
40
+ """
41
+ endpoint according to OpenID provider configuration request
42
+ https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationRequest
43
+ """
44
+ return delivery.util.urljoin(
45
+ self._base_url,
46
+ '.well-known',
47
+ 'openid-configuration',
48
+ )
49
+
50
+ def component_descriptor(self):
51
+ return delivery.util.urljoin(
52
+ self._base_url,
53
+ 'ocm',
54
+ 'component',
55
+ )
56
+
57
+ def greatest_component_versions(self):
58
+ return delivery.util.urljoin(
59
+ self._base_url,
60
+ 'ocm',
61
+ 'component',
62
+ 'versions',
63
+ )
64
+
65
+ def component_responsibles(self):
66
+ return delivery.util.urljoin(
67
+ self._base_url,
68
+ 'ocm',
69
+ 'component',
70
+ 'responsibles',
71
+ )
72
+
73
+ def _delivery(self, *suffix: collections.abc.Iterable[str]):
74
+ return delivery.util.urljoin(
75
+ self._base_url,
76
+ 'delivery',
77
+ *suffix,
78
+ )
79
+
80
+ def sprint_infos(self):
81
+ return self._delivery('sprint-infos')
82
+
83
+ def sprint_current(self):
84
+ return self._delivery('sprint-infos', 'current')
85
+
86
+ def artefact_metadata(self):
87
+ return delivery.util.urljoin(
88
+ self._base_url,
89
+ 'artefacts',
90
+ 'metadata',
91
+ )
92
+
93
+ def artefact_metadata_query(self):
94
+ return delivery.util.urljoin(
95
+ self._base_url,
96
+ 'artefacts',
97
+ 'metadata',
98
+ 'query',
99
+ )
100
+
101
+ def cache(self):
102
+ return delivery.util.urljoin(
103
+ self._base_url,
104
+ 'cache',
105
+ )
106
+
107
+ def backlog_items(self):
108
+ return delivery.util.urljoin(
109
+ self._base_url,
110
+ 'service-extensions',
111
+ 'backlog-items',
112
+ )
113
+
114
+ def blob(self):
115
+ return delivery.util.urljoin(
116
+ self._base_url,
117
+ 'blob',
118
+ )
119
+
120
+
121
+ Url: typing.TypeAlias = str
122
+ AuthToken: typing.TypeAlias = str
123
+ """
124
+ A lookup crafted slightly special-cased for auth-token-based authentication. Implementations *must*
125
+ accept a single positional parameter, which is the URL for which the lookup should return a (valid)
126
+ auth-token.
127
+ If the lookup cannot offer an authtoken for a given URL, it *must* return None. Exceptions raised
128
+ by lookups are not handled.
129
+ """
130
+ AuthTokenLookup: typing.TypeAlias = typing.Callable[[Url], AuthToken]
131
+
132
+
133
+ class DeliveryServiceClient:
134
+ def __init__(
135
+ self,
136
+ routes: DeliveryServiceRoutes,
137
+ auth_token_lookup: AuthTokenLookup | None = None,
138
+ auth_token: str | None = None,
139
+ api_url: str | None = None,
140
+ ):
141
+ """
142
+ Initialises a client which can be used to interact with the delivery-service.
143
+
144
+ :param DeliveryServiceRoutes routes
145
+ object which contains information of the base url of the desired instance of the
146
+ delivery-service as well as the available routes
147
+ :param AuthTokenLookup auth_token_lookup (optional)
148
+ the lookup to use for retrieving auth-tokens against oauth-endpoints
149
+ :param str auth_token (optional)
150
+ the auth-token to use for authentication
151
+ :param str api_url (optional)
152
+ the (GitHub) API URL to use for authentication. Only considered if `auth_token` is set
153
+ """
154
+
155
+ if auth_token_lookup and auth_token:
156
+ raise ValueError('at most one of `auth_token_lookup` or `auth_token` must be provided')
157
+
158
+ self._routes = routes
159
+ self.auth_token_lookup = auth_token_lookup
160
+ self.auth_token = auth_token
161
+ self.api_url = api_url
162
+ self.auth_credentials: dm.GitHubAuthCredentials = None # filled lazily as needed
163
+
164
+ self._bearer_token = None
165
+ self._session = requests.sessions.Session()
166
+
167
+ def _openid_configuration(self):
168
+ """
169
+ response according to OpenID provider configuration response
170
+ https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationResponse
171
+ """
172
+ res = self._session.get(
173
+ url=self._routes.openid_configuration(),
174
+ timeout=(4, 31),
175
+ )
176
+
177
+ res.raise_for_status()
178
+
179
+ return res.json()
180
+
181
+ def _openid_jwks(self):
182
+ openid_configuration = self._openid_configuration()
183
+
184
+ res = self._session.get(
185
+ url=openid_configuration.get('jwks_uri'),
186
+ timeout=(4, 31),
187
+ )
188
+
189
+ res.raise_for_status()
190
+
191
+ return res.json()
192
+
193
+ def _authenticate(self):
194
+ if self._bearer_token and not delivery.jwt.is_jwt_token_expired(
195
+ token=self._bearer_token,
196
+ token_expiration_buffer_seconds=30,
197
+ ):
198
+ return
199
+
200
+ if not self.auth_token_lookup and not self.auth_token:
201
+ logger.info(
202
+ 'Delivery-Service-Client has no auth-token-lookup or auth-token - '
203
+ 'attempting anonymous auth',
204
+ )
205
+ return
206
+
207
+ if (
208
+ self.auth_credentials
209
+ and self.auth_credentials.auth_token.startswith('ey')
210
+ and delivery.jwt.is_jwt_token_expired(
211
+ token=self.auth_credentials.auth_token,
212
+ token_expiration_buffer_seconds=30,
213
+ )
214
+ ):
215
+ self.auth_credentials = None
216
+
217
+ if not self.auth_credentials:
218
+ res = self._session.get(
219
+ url=self._routes.auth_configs(),
220
+ timeout=(4, 31),
221
+ )
222
+
223
+ res.raise_for_status()
224
+
225
+ auth_configs = res.json()
226
+
227
+ for auth_config in auth_configs:
228
+ api_url = auth_config.get('api_url')
229
+
230
+ if (auth_token := self.auth_token) and (not self.api_url or api_url == self.api_url):
231
+ break
232
+ elif self.auth_token_lookup and (auth_token := self.auth_token_lookup(api_url)):
233
+ break
234
+ else:
235
+ logger.info('no valid credentials found - attempting anonymous-auth')
236
+ return
237
+
238
+ self.auth_credentials = dm.GitHubAuthCredentials(
239
+ api_url=api_url,
240
+ auth_token=auth_token,
241
+ )
242
+
243
+ params = {
244
+ 'access_token': self.auth_credentials.auth_token,
245
+ 'api_url': self.auth_credentials.api_url,
246
+ }
247
+
248
+ res = self._session.get(
249
+ url=self._routes.auth(),
250
+ params=params,
251
+ timeout=(4, 31),
252
+ )
253
+
254
+ if not res.ok:
255
+ logger.warning(
256
+ 'authentication against delivery-service failed: '
257
+ f'{res.status_code=} {res.reason=} {res.content=}',
258
+ )
259
+
260
+ res.raise_for_status()
261
+
262
+ self._bearer_token = res.cookies.get(delivery.jwt.JWT_KEY)
263
+
264
+ if not self._bearer_token:
265
+ raise ValueError('delivery-service returned no bearer token upon authentication')
266
+
267
+ def request(
268
+ self,
269
+ url: str,
270
+ method: str = 'GET',
271
+ headers: dict = None,
272
+ **kwargs,
273
+ ):
274
+ try:
275
+ self._authenticate()
276
+ except requests.exceptions.HTTPError as e:
277
+ if e.response.status_code != 400:
278
+ raise
279
+ if e.response.json().get('error_id') != 'feature-inactive':
280
+ raise
281
+ logger.info('delivery-service authentication feature is inactive')
282
+
283
+ headers = headers or {}
284
+
285
+ if self._bearer_token:
286
+ headers = {
287
+ 'Authorization': f'Bearer {self._bearer_token}',
288
+ **headers,
289
+ }
290
+
291
+ try:
292
+ timeout = kwargs.pop('timeout')
293
+ except KeyError:
294
+ timeout = (4, 31)
295
+
296
+ res = self._session.request(
297
+ method=method,
298
+ url=url,
299
+ headers=headers,
300
+ timeout=timeout,
301
+ **kwargs,
302
+ )
303
+
304
+ return res
305
+
306
+ def component_descriptor(
307
+ self,
308
+ name: str,
309
+ version: str,
310
+ ocm_repo_url: str = None,
311
+ version_filter: str | None = None,
312
+ validation_mode: ocm.ValidationMode | None = None,
313
+ ):
314
+ params = {
315
+ 'component_name': name,
316
+ 'version': version,
317
+ }
318
+ if ocm_repo_url:
319
+ params['ocm_repo_url'] = ocm_repo_url
320
+ if version_filter is not None:
321
+ params['version_filter'] = version_filter
322
+
323
+ res = self.request(
324
+ url=self._routes.component_descriptor(),
325
+ params=params,
326
+ )
327
+
328
+ res.raise_for_status()
329
+
330
+ return ocm.ComponentDescriptor.from_dict(
331
+ res.json(),
332
+ validation_mode=validation_mode,
333
+ )
334
+
335
+ def greatest_component_versions(
336
+ self,
337
+ component_name: str,
338
+ max_versions: int = 5,
339
+ greatest_version: str = None,
340
+ ocm_repo: ocm.OcmRepository = None,
341
+ version_filter: str | None = None,
342
+ start_date: datetime.date = None,
343
+ end_date: datetime.date = None,
344
+ timeout: tuple[float, float] = (4.0, 121.0),
345
+ ):
346
+ params = {
347
+ 'component_name': component_name,
348
+ 'max': max_versions,
349
+ }
350
+ if greatest_version:
351
+ params['version'] = greatest_version
352
+ if ocm_repo:
353
+ if not isinstance(ocm_repo, ocm.OciOcmRepository):
354
+ raise NotImplementedError(ocm_repo)
355
+ params['ocm_repo_url'] = ocm_repo.oci_ref
356
+ if version_filter is not None:
357
+ params['version_filter'] = version_filter
358
+
359
+ if start_date:
360
+ params['start_date'] = start_date.isoformat()
361
+
362
+ if end_date:
363
+ params['end_date'] = end_date.isoformat()
364
+
365
+ res = self.request(
366
+ url=self._routes.greatest_component_versions(),
367
+ params=params,
368
+ timeout=timeout,
369
+ )
370
+
371
+ res.raise_for_status()
372
+
373
+ return res.json()
374
+
375
+ def update_metadata(
376
+ self,
377
+ data: collections.abc.Iterable[typing.Union[dict, 'ArtefactMetadata']],
378
+ ):
379
+ headers = {
380
+ 'Content-Type': 'application/json',
381
+ }
382
+
383
+ data, headers = delivery.util.encode_request(
384
+ json={
385
+ 'entries': [
386
+ dataclasses.asdict(
387
+ artefact_metadata,
388
+ dict_factory=delivery.util.dict_to_json_factory,
389
+ )
390
+ if dataclasses.is_dataclass(artefact_metadata)
391
+ else artefact_metadata
392
+ for artefact_metadata in data
393
+ ],
394
+ },
395
+ headers=headers,
396
+ )
397
+
398
+ res = self.request(
399
+ url=self._routes.artefact_metadata(),
400
+ method='PUT',
401
+ headers=headers,
402
+ data=data,
403
+ timeout=None,
404
+ )
405
+
406
+ res.raise_for_status()
407
+
408
+ def delete_metadata(
409
+ self,
410
+ data: collections.abc.Iterable[typing.Union[dict, 'ArtefactMetadata']],
411
+ ):
412
+ headers = {
413
+ 'Content-Type': 'application/json',
414
+ }
415
+
416
+ data, headers = delivery.util.encode_request(
417
+ json={
418
+ 'entries': [
419
+ dataclasses.asdict(
420
+ artefact_metadata,
421
+ dict_factory=delivery.util.dict_to_json_factory,
422
+ )
423
+ if dataclasses.is_dataclass(artefact_metadata)
424
+ else artefact_metadata
425
+ for artefact_metadata in data
426
+ ],
427
+ },
428
+ headers=headers,
429
+ )
430
+
431
+ res = self.request(
432
+ url=self._routes.artefact_metadata(),
433
+ method='DELETE',
434
+ headers=headers,
435
+ data=data,
436
+ timeout=None,
437
+ )
438
+
439
+ res.raise_for_status()
440
+
441
+ def component_responsibles(
442
+ self,
443
+ name: str = None,
444
+ version: str = None,
445
+ ocm_repo_url: str = None,
446
+ version_filter: str | None = None,
447
+ component: ocm.Component | ocm.ComponentDescriptor = None,
448
+ artifact: ocm.Artifact | str = None,
449
+ absent_ok: bool = False,
450
+ ) -> tuple[list[dict] | None, list[dm.Status] | None]:
451
+ """
452
+ retrieves component-responsibles and optional status info.
453
+ Status info can be used to communicate additional information, e.g. that responsible-label
454
+ was malformed.
455
+ Responsibles are returned as a list of typed user identities. Optionally, an artifact
456
+ (or artifact name) may be passed. In this case, responsibles are filtered for the given
457
+ resource definition. Note that an error will be raised if the given artifact does not declare
458
+ a artifact of the given name.
459
+
460
+ known types: githubUser, emailAddress, personalName
461
+ example (single user entry): [
462
+ {type: githubUser, username: <username>, source: <url>, github_hostname: <hostname>},
463
+ {type: emailAddress, email: <email-addr>, source: <url>},
464
+ {type: peronalName, firstName, lastName, source: <url>},
465
+ ]
466
+ """
467
+
468
+ if any((name, version, ocm_repo_url)):
469
+ if component:
470
+ raise ValueError('must pass either name (and version and ocm_repo_url) OR component')
471
+ elif component and (component := component.component):
472
+ name = component.name
473
+ version = component.version
474
+ else:
475
+ raise ValueError('must either pass component or name, version (and ocm_repo_url)')
476
+
477
+ url = self._routes.component_responsibles()
478
+
479
+ params = {
480
+ 'component_name': name,
481
+ }
482
+ if version:
483
+ params['version'] = version
484
+ if ocm_repo_url:
485
+ params['ocm_repo_url'] = ocm_repo_url
486
+ if version_filter is not None:
487
+ params['version_filter'] = version_filter
488
+
489
+ if artifact:
490
+ if isinstance(artifact, ocm.Artifact):
491
+ artifact_name = artifact.name
492
+ else:
493
+ artifact_name = artifact
494
+
495
+ params['artifact_name'] = artifact_name
496
+
497
+ if component:
498
+ logger.info(f'{component.identity()=} {params=}')
499
+ else:
500
+ logger.info(f'{params=}')
501
+
502
+ # wait for responsibles result
503
+ # -> delivery service is waiting up to ~2 min for contributor statistics
504
+ for _ in range(24):
505
+ resp = self.request(
506
+ url=url,
507
+ params=params,
508
+ timeout=(4, 121),
509
+ )
510
+ if resp.status_code != 202:
511
+ break
512
+ time.sleep(5)
513
+ else:
514
+ raise TimeoutError(f'timed out waiting for responsibles with {params=}')
515
+
516
+ try:
517
+ resp.raise_for_status()
518
+ except requests.exceptions.HTTPError as e:
519
+ if e.response.status_code == 404 and absent_ok:
520
+ logger.warning(f'delivery service returned 404 for responsibles with {params=}')
521
+ return None, None
522
+ raise
523
+
524
+ resp_json: dict = resp.json()
525
+
526
+ responsibles = resp_json['responsibles']
527
+ statuses_raw = resp_json.get('statuses', [])
528
+ statuses = [
529
+ dacite.from_dict(
530
+ data_class=dm.Status,
531
+ data=status_raw,
532
+ config=dacite.Config(
533
+ cast=[
534
+ dm.StatusType,
535
+ ],
536
+ ),
537
+ )
538
+ for status_raw in statuses_raw
539
+ ]
540
+
541
+ return responsibles, statuses
542
+
543
+ def sprints(self) -> list[dm.Sprint]:
544
+ resp = self.request(
545
+ url=self._routes.sprint_infos(),
546
+ )
547
+
548
+ resp.raise_for_status()
549
+
550
+ sprints_raw = resp.json()['sprints']
551
+
552
+ return [dm.Sprint.from_dict(sprint_info) for sprint_info in sprints_raw]
553
+
554
+ def sprint_current(self, offset: int = 0, before: datetime.date = None) -> dm.Sprint:
555
+ extra_args = {}
556
+ if before:
557
+ if isinstance(before, datetime.date) or isinstance(before, datetime.date):
558
+ extra_args['before'] = before.isoformat()
559
+ else:
560
+ extra_args['before'] = before
561
+
562
+ resp = self.request(
563
+ url=self._routes.sprint_current(),
564
+ params={'offset': offset, **extra_args},
565
+ )
566
+
567
+ resp.raise_for_status()
568
+
569
+ return dm.Sprint.from_dict(resp.json())
570
+
571
+ def query_metadata(
572
+ self,
573
+ components: collections.abc.Iterable[ocm.Component] = (),
574
+ artefacts: collections.abc.Iterable[typing.Union[dict, 'ComponentArtefactId']] = (),
575
+ type: str | collections.abc.Sequence[str] = None,
576
+ referenced_type: str | collections.abc.Sequence[str] = None,
577
+ ) -> tuple[dict]:
578
+ """
579
+ Query artefact metadata from the delivery-db.
580
+
581
+ @param components: component identities used for filtering; if no identities are
582
+ specified, no component filtering is done
583
+ @param type: datatype(s) used for filtering; if no datatype(s) is (are)
584
+ specified, no datatype filtering is done
585
+ @param referenced_type: referenced datatype(s) used for filtering (only applies to artefact
586
+ metadata of type `rescorings`); if no datatype(s) is (are)
587
+ specified, no referenced datatype filtering is done
588
+ """
589
+ if components and artefacts:
590
+ raise ValueError('at most one of `artefacts` or `components` must be specified')
591
+
592
+ params = dict()
593
+
594
+ if type:
595
+ params['type'] = type
596
+
597
+ if referenced_type:
598
+ params['referenced_type'] = referenced_type
599
+
600
+ headers = {
601
+ 'Content-Type': 'application/json',
602
+ }
603
+
604
+ if components:
605
+ entries = [
606
+ {
607
+ 'component_name': c.name,
608
+ 'component_version': c.version,
609
+ }
610
+ for c in components
611
+ ]
612
+ else:
613
+ entries = [
614
+ dataclasses.asdict(artefact) if dataclasses.is_dataclass(artefact) else artefact
615
+ for artefact in artefacts
616
+ ]
617
+
618
+ data, headers = delivery.util.encode_request(
619
+ json={'entries': entries},
620
+ headers=headers,
621
+ )
622
+
623
+ res = self.request(
624
+ url=self._routes.artefact_metadata_query(),
625
+ method='POST',
626
+ headers=headers,
627
+ data=data,
628
+ params=params,
629
+ timeout=None,
630
+ )
631
+
632
+ res.raise_for_status()
633
+
634
+ artefact_metadata_raw = res.json()
635
+
636
+ return tuple(artefact_metadata_raw)
637
+
638
+ def mark_cache_for_deletion(
639
+ self,
640
+ id: str | None = None,
641
+ descriptor: dict | None = None,
642
+ delete_after: datetime.datetime | None = None,
643
+ ):
644
+ if not id and not descriptor:
645
+ raise ValueError('either `id` or `descriptor` must be specified')
646
+
647
+ params = dict()
648
+
649
+ if id:
650
+ params['id'] = id
651
+
652
+ if delete_after:
653
+ params['deleteAfter'] = delete_after.isoformat()
654
+
655
+ res = self.request(
656
+ url=self._routes.cache(),
657
+ method='DELETE',
658
+ params=params,
659
+ json=descriptor,
660
+ )
661
+
662
+ res.raise_for_status()
663
+
664
+ def create_backlog_item(
665
+ self,
666
+ service: str,
667
+ artefacts: collections.abc.Iterable[typing.Union[dict, 'ComponentArtefactId']] = (),
668
+ priority: str | None = None, # see delivery-service k8s/backlog for allowed priorities
669
+ ):
670
+ headers = {
671
+ 'Content-Type': 'application/json',
672
+ }
673
+
674
+ params = {
675
+ 'service': service,
676
+ }
677
+
678
+ if priority:
679
+ params['priority'] = priority
680
+
681
+ data, headers = delivery.util.encode_request(
682
+ json={
683
+ 'artefacts': [
684
+ dataclasses.asdict(artefact) if dataclasses.is_dataclass(artefact) else artefact
685
+ for artefact in artefacts
686
+ ],
687
+ },
688
+ headers=headers,
689
+ )
690
+
691
+ res = self.request(
692
+ url=self._routes.backlog_items(),
693
+ method='POST',
694
+ headers=headers,
695
+ data=data,
696
+ params=params,
697
+ )
698
+ res.raise_for_status()
699
+
700
+ def upload_blob(
701
+ self,
702
+ data: bytes | collections.abc.Iterable[bytes],
703
+ digest: str,
704
+ size: int,
705
+ mime_type: str,
706
+ ) -> requests.Response:
707
+ headers = {
708
+ 'Digest': digest,
709
+ 'Content-Length': str(size),
710
+ 'Content-Type': mime_type,
711
+ }
712
+
713
+ res = self.request(
714
+ url=self._routes.blob(),
715
+ method='POST',
716
+ headers=headers,
717
+ data=data,
718
+ )
719
+
720
+ res.raise_for_status()
721
+ return res
722
+
723
+ def blob_metadata(
724
+ self,
725
+ digest: str,
726
+ ) -> requests.Response:
727
+ res = self.request(
728
+ url=self._routes.blob(),
729
+ method='HEAD',
730
+ params={
731
+ 'digest': digest,
732
+ },
733
+ )
734
+
735
+ res.raise_for_status()
736
+ return res
737
+
738
+ def get_blob(
739
+ self,
740
+ digest: str,
741
+ chunk_size: int = 8192,
742
+ ) -> collections.abc.Generator[bytes, None, None]:
743
+ res = self.request(
744
+ url=self._routes.blob(),
745
+ params={
746
+ 'digest': digest,
747
+ },
748
+ stream=True,
749
+ )
750
+
751
+ res.raise_for_status()
752
+ return res.iter_content(chunk_size=chunk_size)
753
+
754
+ def delete_blob(
755
+ self,
756
+ digest: str,
757
+ ):
758
+ res = self.request(
759
+ url=self._routes.blob(),
760
+ method='DELETE',
761
+ params={
762
+ 'digest': digest,
763
+ },
764
+ )
765
+
766
+ res.raise_for_status()
767
+
768
+
769
+ def _normalise_github_hostname(github_url: str):
770
+ # hack: for github.com, we might get a different subdomain (api.github.com)
771
+ github_hostname = delivery.util.urlparse(github_url).hostname
772
+ parts = github_hostname.strip('.').split('.')
773
+ if parts[0] == 'api':
774
+ parts = parts[1:]
775
+ github_hostname = '.'.join(parts)
776
+
777
+ return github_hostname.lower()
778
+
779
+
780
+ def github_usernames_from_responsibles(
781
+ responsibles: collections.abc.Iterable[dict],
782
+ github_url: str = None,
783
+ ) -> collections.abc.Generator[str, None, None]:
784
+ """
785
+ returns a generator yielding all github-users from the given `responsibles`.
786
+ use `DeliveryServiceClient.component_responsibles` to retrieve responsibles
787
+ if github_url is given, only github-users on a matching github-host are returned.
788
+ This is useful if the returned users should exist on a certain target github-instance.
789
+ github_url is gracefully parsed down to relevant hostname. It is okay to pass-in, e.g.
790
+ a repository- or github-user-URL for convenience.
791
+ """
792
+ if github_url:
793
+ target_github_hostname = _normalise_github_hostname(github_url)
794
+ else:
795
+ target_github_hostname = None
796
+
797
+ for responsible in responsibles:
798
+ for responsible_info in responsible:
799
+ if not responsible_info['type'] == 'githubUser':
800
+ continue
801
+ username = responsible_info['username']
802
+ github_hostname = _normalise_github_hostname(responsible_info['github_hostname'])
803
+
804
+ if target_github_hostname and target_github_hostname != github_hostname:
805
+ continue
806
+
807
+ yield username
delivery/jwt.py ADDED
@@ -0,0 +1,162 @@
1
+ """
2
+ This module comprises model classes to define JSON Web Keys according
3
+ to https://datatracker.ietf.org/doc/html/rfc7518 as well as convenience
4
+ functions to it.
5
+ """
6
+
7
+ import base64
8
+ import dataclasses
9
+ import datetime
10
+ import enum
11
+ import functools
12
+ import logging
13
+ import typing
14
+
15
+ import Crypto.PublicKey.RSA
16
+ import Crypto.Util.number
17
+ import dacite
18
+ import jwt
19
+
20
+
21
+ logger = logging.getLogger(__name__)
22
+ JWT_KEY = 'bearer_token'
23
+ REFRESH_TOKEN_KEY = 'refresh_token'
24
+
25
+
26
+ class KeyType(enum.StrEnum):
27
+ RSA = 'RSA'
28
+ OCTET_SEQUENCE = 'oct' # used to represent symmetric keys
29
+
30
+
31
+ class Use(enum.StrEnum):
32
+ SIGNATURE = 'sig'
33
+ ENCRYPTION = 'enc'
34
+
35
+
36
+ class Algorithm(enum.StrEnum):
37
+ RS256 = 'RS256'
38
+ HS256 = 'HS256'
39
+
40
+
41
+ @dataclasses.dataclass(frozen=True)
42
+ class JSONWebKey:
43
+ kty: KeyType
44
+ use: Use
45
+ alg: Algorithm
46
+ kid: str
47
+
48
+ @property
49
+ def key(self) -> bytes:
50
+ # has to be defined by derived classes
51
+ raise NotImplementedError
52
+
53
+ @staticmethod
54
+ def from_dict(data: dict) -> typing.Self:
55
+ algorithm = Algorithm(data.get('alg'))
56
+
57
+ class_by_algorithm = {
58
+ Algorithm.RS256: RSAPublicKey,
59
+ Algorithm.HS256: SymmetricKey,
60
+ }
61
+
62
+ return dacite.from_dict(
63
+ data_class=class_by_algorithm.get(algorithm),
64
+ data=data,
65
+ config=dacite.Config(
66
+ cast=[enum.Enum],
67
+ ),
68
+ )
69
+
70
+
71
+ @dataclasses.dataclass(frozen=True, kw_only=True)
72
+ class RSAPublicKey(JSONWebKey):
73
+ n: str # modulus (Base64urlUInt encoded)
74
+ e: str # exponent (Base64urlUInt encoded)
75
+ kty: KeyType = KeyType.RSA
76
+ alg: Algorithm = Algorithm.RS256
77
+
78
+ @property
79
+ def key(self) -> bytes:
80
+ return Crypto.PublicKey.RSA.construct(
81
+ rsa_components=(
82
+ decodeBase64urlUInt(self.n),
83
+ decodeBase64urlUInt(self.e),
84
+ ),
85
+ ).export_key(format='PEM')
86
+
87
+
88
+ @dataclasses.dataclass(frozen=True, kw_only=True)
89
+ class SymmetricKey(JSONWebKey):
90
+ k: str # key value (base64url encoded)
91
+ kty: KeyType = KeyType.OCTET_SEQUENCE
92
+ alg: Algorithm = Algorithm.HS256
93
+
94
+ @property
95
+ def key(self) -> bytes:
96
+ return decodeBase64url(self.k)
97
+
98
+
99
+ def encodeBase64url(b: bytes) -> str:
100
+ url_encoding = base64.urlsafe_b64encode(b)
101
+
102
+ return url_encoding.rstrip(b'=').decode('utf-8')
103
+
104
+
105
+ def decodeBase64url(s: str) -> bytes:
106
+ return base64.urlsafe_b64decode(s + '==')
107
+
108
+
109
+ def encodeBase64urlUInt(number: int) -> str:
110
+ b = Crypto.Util.number.long_to_bytes(number)
111
+
112
+ return encodeBase64url(b)
113
+
114
+
115
+ def decodeBase64urlUInt(s: str) -> int:
116
+ b = decodeBase64url(s)
117
+
118
+ return Crypto.Util.number.bytes_to_long(b)
119
+
120
+
121
+ @functools.cache
122
+ def decode_jwt(
123
+ token: str,
124
+ verify_signature: bool = True,
125
+ json_web_key: JSONWebKey | None = None,
126
+ **kwargs,
127
+ ) -> dict:
128
+ """
129
+ This is just a convenience wrapper for `jwt.decode` which eases
130
+ signature validation of a given `token` using a `JSONWebKey`.
131
+ """
132
+ if verify_signature and not json_web_key:
133
+ raise ValueError('`json_web_key` must be specified if `verify_signature` is `True`')
134
+
135
+ return jwt.decode(
136
+ jwt=token,
137
+ key=json_web_key.key if json_web_key else None,
138
+ algorithms=[json_web_key.alg if json_web_key else None],
139
+ options={
140
+ 'verify_signature': verify_signature,
141
+ },
142
+ **kwargs,
143
+ )
144
+
145
+
146
+ def is_jwt_token_expired(
147
+ token: str,
148
+ token_expiration_buffer_seconds: int = 0,
149
+ ) -> bool:
150
+ decoded_jwt = decode_jwt(
151
+ token=token,
152
+ verify_signature=False,
153
+ )
154
+
155
+ expiration_date = datetime.datetime.fromtimestamp(
156
+ timestamp=decoded_jwt.get('exp'),
157
+ tz=datetime.timezone.utc,
158
+ )
159
+
160
+ now = datetime.datetime.now(tz=datetime.timezone.utc)
161
+
162
+ return now + datetime.timedelta(seconds=token_expiration_buffer_seconds) > expiration_date
delivery/model.py ADDED
@@ -0,0 +1,67 @@
1
+ import dataclasses
2
+ import datetime
3
+ import enum
4
+
5
+ import dacite
6
+ import dateutil.parser
7
+
8
+
9
+ def _parse_datetime_if_present(date: str):
10
+ if not date:
11
+ return None
12
+ return dateutil.parser.isoparse(date).replace(tzinfo=datetime.UTC)
13
+
14
+
15
+ @dataclasses.dataclass(frozen=True)
16
+ class SprintDate:
17
+ name: str
18
+ display_name: str
19
+ value: datetime.datetime
20
+
21
+
22
+ @dataclasses.dataclass(frozen=True) # TODO: deduplicate w/ modelclass in delivery-service/yp.py
23
+ class Sprint:
24
+ name: str
25
+ dates: frozenset[SprintDate]
26
+
27
+ def find_sprint_date(
28
+ self,
29
+ name: str,
30
+ absent_ok: bool = False,
31
+ ) -> SprintDate | None:
32
+ for sprint_date in self.dates:
33
+ if sprint_date.name == name:
34
+ return sprint_date
35
+
36
+ if absent_ok:
37
+ return None
38
+
39
+ raise RuntimeError(f'did not find {name=} in {self=}')
40
+
41
+ @staticmethod
42
+ def from_dict(raw: dict):
43
+ return dacite.from_dict(
44
+ data_class=Sprint,
45
+ data=raw,
46
+ config=dacite.Config(
47
+ type_hooks={datetime.datetime: _parse_datetime_if_present},
48
+ cast=[frozenset],
49
+ ),
50
+ )
51
+
52
+
53
+ class StatusType(enum.StrEnum):
54
+ ERROR = enum.auto()
55
+ INFO = enum.auto()
56
+
57
+
58
+ @dataclasses.dataclass(frozen=True) # TODO: deduplicate with model-class delivery-service
59
+ class Status:
60
+ type: StatusType
61
+ msg: str
62
+
63
+
64
+ @dataclasses.dataclass(frozen=True)
65
+ class GitHubAuthCredentials:
66
+ api_url: str
67
+ auth_token: str
delivery/util.py ADDED
@@ -0,0 +1,115 @@
1
+ import collections.abc
2
+ import datetime
3
+ import enum
4
+ import io
5
+ import json as js
6
+ import typing
7
+ import urllib.parse
8
+ import zlib
9
+
10
+
11
+ class EncodingMethod(enum.StrEnum):
12
+ GZIP = 'gzip'
13
+
14
+
15
+ def encode_request(
16
+ data: str | bytes | dict | typing.IO = None,
17
+ json: dict = None,
18
+ headers: dict[str, str] = None,
19
+ encoding_method: EncodingMethod = EncodingMethod.GZIP,
20
+ ) -> tuple[bytes, dict[str, str]] | bytes:
21
+ """
22
+ Encodes the given `data` or `json` property based on the selected `encoding_method`. Only one of
23
+ `data` or `json` must be set, otherwise a `ValueError` is raised.
24
+
25
+ The corresponding `Content-Encoding` header is patched-in to the provided headers dictionary.
26
+
27
+ If `headers` is provided and not `None`, the response is a tuple of the compression result and
28
+ the patched headers, otherwise only the compression result is returned.
29
+ """
30
+ if not (data is not None ^ json is not None):
31
+ raise ValueError('Exactly one of `data` or `json` must be set')
32
+
33
+ if isinstance(data, dict) and encoding_method == EncodingMethod.GZIP:
34
+ raise ValueError('`data` of type `dict` is not supported for gzip encoding')
35
+
36
+ if json is not None:
37
+ data = js.dumps(json)
38
+
39
+ def _encode(obj, encoding: str = 'utf-8') -> bytes:
40
+ if isinstance(obj, bytes):
41
+ return obj
42
+ elif isinstance(obj, str):
43
+ return obj.encode(encoding=encoding)
44
+ elif isinstance(obj, dict):
45
+ return js.dumps(obj).encode(encoding=encoding)
46
+ else:
47
+ raise ValueError(f'Encoding of type {type(obj)} is not (yet) supported')
48
+
49
+ def _compress(data, encoding_method) -> collections.abc.Generator[bytes, None, None]:
50
+ if isinstance(data, io.BufferedIOBase):
51
+ if encoding_method == EncodingMethod.GZIP:
52
+ compressor = zlib.compressobj(wbits=31)
53
+ data.seek(0)
54
+
55
+ while chunk := data.read(4096):
56
+ yield compressor.compress(chunk)
57
+ yield compressor.flush()
58
+
59
+ else:
60
+ raise ValueError(encoding_method)
61
+
62
+ elif hasattr(data, '__iter__') and not isinstance(data, (str, bytes, dict)):
63
+ raise ValueError(f'Encoding of iterable {type(data)} is not (yet) supported')
64
+
65
+ else:
66
+ data = _encode(data)
67
+
68
+ if encoding_method == EncodingMethod.GZIP:
69
+ compressor = zlib.compressobj(wbits=31)
70
+ yield compressor.compress(data) + compressor.flush()
71
+
72
+ else:
73
+ raise ValueError(encoding_method)
74
+
75
+ compressed_data = b''.join(_compress(data, encoding_method))
76
+ content_length = len(compressed_data)
77
+
78
+ if headers is not None:
79
+ headers['Content-Encoding'] = encoding_method
80
+ headers['Content-Length'] = str(content_length)
81
+ return compressed_data, headers
82
+
83
+ return compressed_data
84
+
85
+
86
+ def urljoin(*parts):
87
+ if len(parts) == 1:
88
+ return parts[0]
89
+ first = parts[0]
90
+ last = parts[-1]
91
+ middle = parts[1:-1]
92
+
93
+ first = first.rstrip('/')
94
+ middle = list(map(lambda s: s.strip('/'), middle))
95
+ last = last.lstrip('/')
96
+
97
+ return '/'.join([first] + middle + [last])
98
+
99
+
100
+ def urlparse(url: str) -> urllib.parse.ParseResult:
101
+ if '://' not in url:
102
+ url = f'x://{url}'
103
+
104
+ return urllib.parse.urlparse(url)
105
+
106
+
107
+ def dict_to_json_factory(data):
108
+ def convert_value(obj):
109
+ if isinstance(obj, (datetime.date, datetime.datetime)):
110
+ return obj.isoformat()
111
+ elif isinstance(obj, enum.Enum):
112
+ return obj.value
113
+ return obj
114
+
115
+ return dict((k, convert_value(v)) for k, v in data)
@@ -0,0 +1,73 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
10
+
11
+ "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
12
+
13
+ "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
14
+
15
+ "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
16
+
17
+ "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
18
+
19
+ "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
20
+
21
+ "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
22
+
23
+ "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
24
+
25
+ "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
26
+
27
+ "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
28
+
29
+ 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
30
+
31
+ 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
32
+
33
+ 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
34
+
35
+ (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and
36
+
37
+ (b) You must cause any modified files to carry prominent notices stating that You changed the files; and
38
+
39
+ (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
40
+
41
+ (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.
42
+
43
+ You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
44
+
45
+ 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
46
+
47
+ 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
48
+
49
+ 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
50
+
51
+ 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
52
+
53
+ 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
54
+
55
+ END OF TERMS AND CONDITIONS
56
+
57
+ APPENDIX: How to apply the Apache License to your work.
58
+
59
+ To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives.
60
+
61
+ Copyright [yyyy] [name of copyright owner]
62
+
63
+ Licensed under the Apache License, Version 2.0 (the "License");
64
+ you may not use this file except in compliance with the License.
65
+ You may obtain a copy of the License at
66
+
67
+ http://www.apache.org/licenses/LICENSE-2.0
68
+
69
+ Unless required by applicable law or agreed to in writing, software
70
+ distributed under the License is distributed on an "AS IS" BASIS,
71
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
72
+ See the License for the specific language governing permissions and
73
+ limitations under the License.
@@ -0,0 +1,15 @@
1
+ Metadata-Version: 2.1
2
+ Name: odg-client
3
+ Version: 0.1.0
4
+ Summary: Client library for the Open Delivery Gear
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+ License-File: LICENSE
8
+ Requires-Dist: dacite
9
+ Requires-Dist: gardener-ocm
10
+ Requires-Dist: pycryptodome
11
+ Requires-Dist: pyjwt
12
+ Requires-Dist: python-dateutil
13
+ Requires-Dist: requests
14
+
15
+ Client library for the Delivery Service (part of the Open Delivery Gear)
@@ -0,0 +1,10 @@
1
+ delivery/__init__.py,sha256=1o3aMK95dBf_Y__WkG-yjkhE3ooSk6QW-897Nu7o244,1865
2
+ delivery/client.py,sha256=6t1iPiXOF8B6KDu5xjRmHFIyEwgeEW6yWuvt44aoCv4,24147
3
+ delivery/jwt.py,sha256=HXBQgmG-_8ZwpuiGIV-ubsYw7AWgJ3GzyO4gGXnwIsw,3858
4
+ delivery/model.py,sha256=YEdkY2jb01QzDP7SkGm0sfrHs1l0W5CJrZTg6SAErSg,1496
5
+ delivery/util.py,sha256=CdF1R8w9OCZxsQGUDWk_1aarMdoI2iUYljkhqSnz_1Q,3638
6
+ odg_client-0.1.0.dist-info/LICENSE,sha256=B05uMshqTA74s-0ltyHKI6yoPfJ3zYgQbvcXfDVGFf8,10280
7
+ odg_client-0.1.0.dist-info/METADATA,sha256=fsjn_xXRGqMI2YRIuxpN7fn_SGz5zo4eWvkzsgXoaQ8,419
8
+ odg_client-0.1.0.dist-info/WHEEL,sha256=oiQVh_5PnQM0E3gPdiz09WCNmwiHDMaGer_elqB3coM,92
9
+ odg_client-0.1.0.dist-info/top_level.txt,sha256=tRd7Uz_gfvbKgZoDaVY8pKvaxBce9g_Jt4e26NvQ1FM,9
10
+ odg_client-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: bdist_wheel (0.42.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ delivery