decaf-api-client 0.0.12__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.
@@ -0,0 +1,2 @@
1
+ #: Defines the name of the :mod:`decaf.api.client` package.
2
+ __version__ = "0.0.12" # x-release-please-version
@@ -0,0 +1,13 @@
1
+ """
2
+ This module provides internal definitions and functionality.
3
+ """
4
+
5
+ __all__ = ["ProgrammingError"]
6
+
7
+
8
+ class ProgrammingError(Exception):
9
+ """
10
+ Provides an exception class for internal programming errors.
11
+ """
12
+
13
+ pass
@@ -0,0 +1,820 @@
1
+ """
2
+ This module provides the essential machinery to perform remote API requests and define endpoint client implementations.
3
+ """
4
+
5
+ __all__ = [
6
+ "APIClientError",
7
+ "APIHeaderAuthorization",
8
+ "APIKeyAuthorization",
9
+ "APIServerError",
10
+ "APIServerErrorBadAuthorization",
11
+ "APIServerErrorBadRequest",
12
+ "APIServerErrorBlowup",
13
+ "APIServerErrorNotFound",
14
+ "APITokenAuthorization",
15
+ "Authorization",
16
+ "BaseCreateForm",
17
+ "BasePatchForm",
18
+ "BaseResource",
19
+ "BaseUpdateForm",
20
+ "Client",
21
+ "DeleteEndpoint",
22
+ "Endpoint",
23
+ "Error",
24
+ "Missing",
25
+ "RFiles",
26
+ "RHeaders",
27
+ "RMethod",
28
+ "RParams",
29
+ "RPath",
30
+ "RTimeout",
31
+ "Request",
32
+ "ResourceCreateEndpoint",
33
+ "ResourceEndpoint",
34
+ "ResourceListEndpoint",
35
+ "ResourcePatchEndpoint",
36
+ "ResourceRetrieveEndpoint",
37
+ "ResourceUpdateEndpoint",
38
+ "StandardResourceEndpoint",
39
+ "command",
40
+ "query",
41
+ ]
42
+
43
+ import re
44
+ import time
45
+ from abc import abstractmethod
46
+ from dataclasses import dataclass, field
47
+ from json import load as loadjson
48
+ from pathlib import Path
49
+ from typing import (
50
+ Any,
51
+ BinaryIO,
52
+ Callable,
53
+ ClassVar,
54
+ Dict,
55
+ Generator,
56
+ Generic,
57
+ Iterable,
58
+ List,
59
+ Literal,
60
+ Optional,
61
+ Set,
62
+ TextIO,
63
+ Tuple,
64
+ Type,
65
+ TypeVar,
66
+ Union,
67
+ )
68
+ from urllib.parse import urljoin
69
+
70
+ import requests
71
+ from pydantic import BaseModel, Field
72
+ from requests.auth import AuthBase
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class Error(Exception):
77
+ """
78
+ Provides a base exception model for the DECAF client library.
79
+
80
+ .. note:: You should probably catch sub-classes of this exception class.
81
+ """
82
+
83
+ #: Error message.
84
+ message: str
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class APIClientError(Error):
89
+ """
90
+ Provides an exception model for errors generated during request-response cycle (not by the API server).
91
+ """
92
+
93
+ #: Underlying exception.
94
+ underlying: Exception
95
+
96
+
97
+ @dataclass(frozen=True)
98
+ class APIServerError(Error):
99
+ """
100
+ Provides an exception for API server generated errors.
101
+ """
102
+
103
+ #: HTTP status code of the error response received from the API server.
104
+ status: int
105
+
106
+ #: Payload of the error response received from the API server.
107
+ payload: Union[str, bytes, None]
108
+
109
+
110
+ @dataclass(frozen=True)
111
+ class APIServerErrorBadRequest(APIServerError):
112
+ """
113
+ Provides an exception for API server generated HTTP 400 errors.
114
+ """
115
+
116
+ #: Error definition.
117
+ feedback: Dict[str, List[str]]
118
+
119
+
120
+ @dataclass(frozen=True)
121
+ class APIServerErrorBadAuthorization(APIServerError):
122
+ """
123
+ Provides an exception for API server generated HTTP 401, 403 and 405 errors.
124
+ """
125
+
126
+ pass
127
+
128
+
129
+ @dataclass(frozen=True)
130
+ class APIServerErrorNotFound(APIServerError):
131
+ """
132
+ Provides an exception for API server generated HTTP 404 errors.
133
+ """
134
+
135
+ pass
136
+
137
+
138
+ @dataclass(frozen=True)
139
+ class APIServerErrorBlowup(APIServerError):
140
+ """
141
+ Provides an exception for API server generated HTTP 5XX errors.
142
+ """
143
+
144
+ pass
145
+
146
+
147
+ #: Defines a type alias for HTTP verbs.
148
+ RMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE"]
149
+
150
+ #: Defines a type alias for relative path or path segments of the remote API endpoint.
151
+ RPath = Union[str, List[Union[str, int]]]
152
+
153
+ #: Defines a type alias for query-string parameters.
154
+ RParams = Dict[str, Union[str, int, List[str], List[int]]]
155
+
156
+ #: Defines a type alias for HTTP request headers.
157
+ RHeaders = Dict[str, str]
158
+
159
+ #: Defines a type alias for file objects.
160
+ _FileObject = Union[str, bytes, bytearray, TextIO, BinaryIO]
161
+
162
+ #: Defines a type alias for file object maps to be passed for multi-part uploads.
163
+ RFiles = Dict[str, Union[_FileObject, Tuple[_FileObject, str], Tuple[_FileObject, str, Dict[str, str]]]]
164
+
165
+ #: Defines a type alias for HTTP request timeouts.
166
+ RTimeout = Tuple[float, float]
167
+
168
+
169
+ @dataclass(frozen=True)
170
+ class Request:
171
+ """
172
+ Defines a high(ish)-level client request model.
173
+ """
174
+
175
+ #: Request method.
176
+ method: RMethod
177
+
178
+ #: Relative path or path segments for the remote API endpoint URL.
179
+ path: RPath
180
+
181
+ #: Request parameters, if any.
182
+ params: Optional[RParams] = None
183
+
184
+ #: Request data payload, if any.
185
+ data: Any = None
186
+
187
+ #: Request data payload in JSON, if any (convenience for sending ``application/json`` payloads).
188
+ json: Any = None
189
+
190
+ #: Request data payload files, if any (convenience for uploading multipart payloads).
191
+ files: Optional[RFiles] = None
192
+
193
+ #: Request headers.
194
+ headers: Optional[RHeaders] = None
195
+
196
+ #: Defines the timeout, if any:
197
+ timeout: Optional[RTimeout] = None
198
+
199
+
200
+ class Authorization:
201
+ """
202
+ Provides a base class for client authorization.
203
+ """
204
+
205
+ @abstractmethod
206
+ def headers(self) -> Dict[str, str]:
207
+ """
208
+ Provides the client authorization header for HTTP requests.
209
+ """
210
+ pass
211
+
212
+
213
+ @dataclass(frozen=True)
214
+ class APIKeyAuthorization(Authorization):
215
+ """
216
+ Provides API key authorization method.
217
+ """
218
+
219
+ #: Defines the API key.
220
+ key: str = field(repr=False)
221
+
222
+ #: Defines the API secret.
223
+ scr: str = field(repr=False)
224
+
225
+ def headers(self) -> Dict[str, str]:
226
+ return {"Authorization": f"Key {self.key}:{self.scr}"}
227
+
228
+
229
+ @dataclass(frozen=True)
230
+ class APITokenAuthorization(Authorization):
231
+ """
232
+ Provides API token authorization method.
233
+ """
234
+
235
+ #: API token.
236
+ token: str = field(repr=False)
237
+
238
+ def headers(self) -> Dict[str, str]:
239
+ return {"Authorization": f"Token {self.token}"}
240
+
241
+
242
+ @dataclass(frozen=True)
243
+ class APIHeaderAuthorization(Authorization):
244
+ """
245
+ Provides authorization method directly from authentication headers.
246
+ """
247
+
248
+ #: HTTP headers
249
+ buffer: RHeaders
250
+
251
+ def headers(self) -> Dict[str, str]:
252
+ return self.buffer
253
+
254
+
255
+ @dataclass(frozen=True)
256
+ class Client:
257
+ """
258
+ Provides a high-level client implementation for DECAF API.
259
+ """
260
+
261
+ #: Base API URI.
262
+ url: str
263
+
264
+ #: API authorization.
265
+ auth: Authorization
266
+
267
+ class _NullAuth(AuthBase):
268
+ """
269
+ Provides the no-op authentication provider implementation.
270
+ """
271
+
272
+ def __call__(self, r: Any) -> Any:
273
+ return r
274
+
275
+ #: Defines the no-op authentication provider.
276
+ _noop_auth: ClassVar[_NullAuth] = _NullAuth()
277
+
278
+ #: Defines success status codes for each HTTP method.
279
+ _successmap: ClassVar[Dict[RMethod, Set[int]]] = {
280
+ "GET": {200},
281
+ "POST": {200, 201, 202},
282
+ "PUT": {200, 201, 202},
283
+ "PATCH": {200, 201, 202},
284
+ "DELETE": {200, 201, 202, 204},
285
+ }
286
+
287
+ def __post_init__(self) -> None:
288
+ """
289
+ Sanitizes the base API URL.
290
+
291
+ >>> client = Client("http://example.com///", Authorization())
292
+ >>> client.url
293
+ 'http://example.com'
294
+ >>> client = Client("http://example.com/api//", Authorization())
295
+ >>> client.url
296
+ 'http://example.com/api'
297
+ """
298
+ object.__setattr__(self, "url", re.sub(r"[/]+$", "", self.url))
299
+
300
+ def urlize(self, path: RPath) -> str:
301
+ """
302
+ Builds the full URL for the given relative path or path segments.
303
+
304
+ :param path: Relative path or path segments of the remote API endpoint.
305
+ :return: Remote endpoint URL.
306
+
307
+ >>> client = Client("http://example.com///", Authorization())
308
+ >>> client.urlize("test")
309
+ 'http://example.com/test/'
310
+ >>> client.urlize("test/")
311
+ 'http://example.com/test/'
312
+ >>> client.urlize("/test")
313
+ 'http://example.com/test/'
314
+ >>> client.urlize("/test/")
315
+ 'http://example.com/test/'
316
+ >>> client.urlize("test//")
317
+ 'http://example.com/test/'
318
+ >>> client.urlize("//test")
319
+ 'http://example.com/test/'
320
+ >>> client.urlize("//test//")
321
+ 'http://example.com/test/'
322
+ >>> client.urlize(["test"])
323
+ 'http://example.com/test/'
324
+ >>> client.urlize(["test/"])
325
+ 'http://example.com/test/'
326
+ >>> client.urlize(["/test"])
327
+ 'http://example.com/test/'
328
+ >>> client.urlize(["/test/"])
329
+ 'http://example.com/test/'
330
+ >>> client.urlize(["test//"])
331
+ 'http://example.com/test/'
332
+ >>> client.urlize(["//test"])
333
+ 'http://example.com/test/'
334
+ >>> client.urlize(["//test//"])
335
+ 'http://example.com/test/'
336
+ >>> client.urlize(["//test//", "//test2//", "//test3/4/5//"])
337
+ 'http://example.com/test/test2/test3/4/5/'
338
+ """
339
+ if isinstance(path, list):
340
+ path = "/".join(str(i).strip("/") for i in path)
341
+ return urljoin(self.url + "/", re.sub(r"[/]*$", "/", re.sub(r"^[/]*", "", path)))
342
+
343
+ def run(self, request: Request) -> requests.Response:
344
+ """
345
+ Runs the given request and returns the raw response.
346
+
347
+ :param request: :py:class:`Request` instance to run.
348
+ :return: Raw :py:class:`requests.Response` instance.
349
+ :raises APIClientError: In case that there is a problem with client-server communication.
350
+ """
351
+ try:
352
+ return requests.request(
353
+ method=request.method,
354
+ url=self.urlize(request.path),
355
+ params=request.params,
356
+ data=request.data,
357
+ json=request.json,
358
+ files=request.files, # type: ignore
359
+ headers={**(request.headers or {}), **self.auth.headers()},
360
+ timeout=request.timeout,
361
+ auth=self._noop_auth,
362
+ )
363
+ except requests.exceptions.HTTPError as err:
364
+ raise APIClientError("HTTP Error", err)
365
+ except requests.exceptions.Timeout as err:
366
+ raise APIClientError("Timeout Error", err)
367
+ except requests.exceptions.ConnectionError as err:
368
+ raise APIClientError("Connection Error", err)
369
+ except requests.exceptions.RequestException as err:
370
+ raise APIClientError("Request Error", err)
371
+
372
+ def request(
373
+ self,
374
+ method: RMethod,
375
+ path: RPath,
376
+ params: Optional[RParams] = None,
377
+ data: Any = None,
378
+ json: Any = None,
379
+ files: Optional[RFiles] = None,
380
+ headers: Optional[RHeaders] = None,
381
+ timeout: Optional[RTimeout] = None,
382
+ asis: bool = False,
383
+ ) -> Any:
384
+ """
385
+ Provides a high-level interface to run HTTP request to the remote API server.
386
+
387
+ :param method: Request method.
388
+ :param path: Relative path or path segments for the remote API endpoint URL.
389
+ :param params: Request parameters, if any.
390
+ :param data: Request data payload, if any.
391
+ :param json: Request data payload in JSON, if any (convenience for sending ``application/json`` payloads).
392
+ :param files: Request data payload files, if any (convenience for uploading multipart payloads).
393
+ :param headers: Request headers.
394
+ :param timeout: Defines the timeout, if any.
395
+ :param asis: Whether to return response content as is or marshall it to Python object (default is ``False``).
396
+ :return: Raw response content or Python object as per ``asis`` parameter.
397
+ :raises APIClientError: In case that there is a problem with client-server communication.
398
+ :raises APIServerError: In case that there is a server generated error reported via HTTP status codes.
399
+ """
400
+ ## Build the request, attempt to run it and get a response:
401
+ response = self.run(
402
+ Request(
403
+ method=method,
404
+ path=path,
405
+ params=params,
406
+ data=data,
407
+ json=json,
408
+ files=files,
409
+ headers=headers,
410
+ timeout=timeout,
411
+ )
412
+ )
413
+
414
+ ## Check the status code and return if successful:
415
+ if response.status_code in self._successmap[method]:
416
+ if not response.content:
417
+ return None
418
+ return response.content if asis else response.json()
419
+
420
+ ## We have a remote error. Encode it into an exception and raise it:
421
+ if response.status_code >= 500:
422
+ raise APIServerErrorBlowup("Server error", response.status_code, response.content)
423
+ elif response.status_code == 404:
424
+ raise APIServerErrorNotFound("Resource not found", response.status_code, response.content)
425
+ elif response.status_code == 400:
426
+ raise APIServerErrorBadRequest("Bad request", response.status_code, response.content, response.json())
427
+ elif response.status_code in {401, 403, 405}:
428
+ raise APIServerErrorBadAuthorization("Bad authorization", response.status_code, response.content)
429
+ else:
430
+ raise APIServerError("Unknown error", response.status_code, response.content)
431
+
432
+ @property
433
+ def version(self) -> str:
434
+ """
435
+ Returns the remote API version.
436
+ """
437
+ return self.request("GET", "version")["version"] # type: ignore
438
+
439
+ @property
440
+ def healthcheck(self) -> str:
441
+ """
442
+ Performs an async healthcheck.
443
+ """
444
+ ## Create a healthcheck job and get its ID:
445
+ jobid = self.request("GET", ["jobs", "healthcheck", "status"])["id"]
446
+
447
+ ## Get job result:
448
+ finished, result = self.get_job_result(jobid)
449
+
450
+ ## Check:
451
+ while not finished:
452
+ ## Sleep a while:
453
+ time.sleep(1)
454
+
455
+ ## Get job result:
456
+ finished, result = self.get_job_result(jobid)
457
+
458
+ ## Done, return:
459
+ return result # type: ignore
460
+
461
+ def get_job_result(self, jobid: str) -> Tuple[bool, Optional[Any]]:
462
+ """
463
+ Returns if the job is finished and associated data if so.
464
+
465
+ :param jobid: Job identifier.
466
+ :return: A tuple of status (``False`` meaning "pending", ``True`` meaning "finished") and optional content.
467
+ :raises APIServerError: In case that the status is unknown.
468
+ """
469
+ ## Get the job status:
470
+ content = self.request("GET", ["jobs", jobid])
471
+
472
+ ## Get the state:
473
+ state = content["state"]
474
+
475
+ ## Check:
476
+ if state == "PENDING":
477
+ return False, None
478
+ elif state == "SUCCESS":
479
+ return True, content["result"]
480
+ else:
481
+ raise APIServerError("Unknown response from Job status endpoint", -1, content)
482
+
483
+ @classmethod
484
+ def from_profile(cls, name: str, path: Optional[Path] = None) -> "Client":
485
+ """
486
+ Attempts the create a :py:class:`Client` instance for the given profile name.
487
+
488
+ :param name: Profile name.
489
+ :param path: Optional path to the configuration file (default is ``~/.decaf.json``).
490
+ :return: A :py:class:`Client` instance.
491
+ :raises FileNotFoundError: In case that the configuration file is not found.
492
+ :raises KeyError: In case that the profile is not found.
493
+ """
494
+ ## If we don't have a configuration path, use the default:
495
+ if path is None:
496
+ path = Path.home() / ".decaf.json"
497
+
498
+ ## Attempt to read in the configuration:
499
+ with path.open() as ifile:
500
+ profile = {p["name"]: p for p in loadjson(ifile)["profiles"]}[name]
501
+
502
+ ## Build the client and return:
503
+ return Client(profile["url"], APIKeyAuthorization(key=profile["key"], scr=profile["secret"]))
504
+
505
+ @classmethod
506
+ def from_apikey(cls, url: str, key: str, secret: str) -> "Client":
507
+ """
508
+ Attempts the create a :py:class:`Client` instance for the given API key/secret.
509
+
510
+ :param url: Base API url.
511
+ :param key: API key.
512
+ :param secret: API secret.
513
+ :return: A :py:class:`Client` instance.
514
+ """
515
+ return Client(url, APIKeyAuthorization(key, secret))
516
+
517
+ @classmethod
518
+ def from_apitoken(cls, url: str, token: str) -> "Client":
519
+ """
520
+ Attempts the create a :py:class:`Client` instance for the given API token.
521
+
522
+ Note that API tokens have shorter expiry than API keys. You may wish to choose API keys for clients with
523
+ longer life time.
524
+
525
+ :param url: Base API url.
526
+ :param token: API token.
527
+ :return: A :py:class:`Client` instance.
528
+ """
529
+ return Client(url, APITokenAuthorization(token))
530
+
531
+ @classmethod
532
+ def from_headers(cls, url: str, headers: RHeaders) -> "Client":
533
+ """
534
+ Attempts the create a :py:class:`Client` instance for the given HTTP headers.
535
+
536
+ :param url: Base API url.
537
+ :param headers: HTTP authentication headers.
538
+ :return: A :py:class:`Client` instance.
539
+ """
540
+ return Client(url, APIHeaderAuthorization(headers))
541
+
542
+
543
+ #: Defines a generic type alias.
544
+ _T = TypeVar("_T")
545
+
546
+
547
+ def query(func: Callable[..., _T]) -> Callable[..., _T]:
548
+ """
549
+ Decorates a query method of an endpoint.
550
+
551
+ :param func: method to be decorated.
552
+ :return: Decorated method.
553
+ """
554
+ setattr(func, "__decaf_endpoint_method_query__", True)
555
+ return func
556
+
557
+
558
+ def command(func: Callable[..., _T]) -> Callable[..., _T]:
559
+ """
560
+ Decorates a command method of an endpoint.
561
+
562
+ :param func: method to be decorated.
563
+ :return: Decorated method.
564
+ """
565
+ setattr(func, "__decaf_endpoint_method_command__", True)
566
+ return func
567
+
568
+
569
+ class BaseResource(BaseModel):
570
+ """
571
+ Provides a base class for standard DECAF resource models.
572
+ """
573
+
574
+ pass
575
+
576
+
577
+ class BaseCreateForm(BaseModel):
578
+ """
579
+ Provides a base class for standard DECAF form models for resource creation.
580
+ """
581
+
582
+ pass
583
+
584
+
585
+ class BaseUpdateForm(BaseModel):
586
+ """
587
+ Provides a base class for standard DECAF form models for resource updation.
588
+ """
589
+
590
+ pass
591
+
592
+
593
+ class BasePatchForm(BaseModel):
594
+ """
595
+ Provides a base class for standard DECAF form models for resource patching.
596
+ """
597
+
598
+ pass
599
+
600
+
601
+ #: Defines a type variable for :py:class:`BaseResource` implementations.
602
+ _R = TypeVar("_R", bound=BaseResource)
603
+
604
+ #: Defines a type variable for :py:class:`BaseCreateForm` implementations.
605
+ _C = TypeVar("_C", bound=BaseCreateForm)
606
+
607
+ #: Defines a type variable for :py:class:`BaseUpdateForm` implementations.
608
+ _U = TypeVar("_U", bound=BaseUpdateForm)
609
+
610
+ #: Defines a type variable for :py:class:`BasePatchForm` implementations.
611
+ _P = TypeVar("_P", bound=BasePatchForm)
612
+
613
+ #: Defines a type variable for resource identifiers.
614
+ _I = TypeVar("_I", bound=Union[str, int])
615
+
616
+
617
+ @dataclass(frozen=True)
618
+ class Endpoint:
619
+ """
620
+ Provides a base endpoint class.
621
+ """
622
+
623
+ __slots__ = ["client"]
624
+
625
+ #: Underlying client.
626
+ client: Client
627
+
628
+ #: Base path segment for the remote resource URI.
629
+ endpoint: ClassVar[str]
630
+
631
+
632
+ class ResourceEndpoint(Generic[_R], Endpoint):
633
+ """
634
+ Provides a base class for resource endpoints.
635
+ """
636
+
637
+ #: Resource model type.
638
+ resource: ClassVar[Type[_R]] # type: ignore
639
+
640
+
641
+ class ResourceListEndpoint(ResourceEndpoint[_R]):
642
+ """
643
+ Provides a base class for resource endpoints with ``list`` query method.
644
+ """
645
+
646
+ @query
647
+ def list(self, params: Optional[RParams] = None) -> Iterable[_R]:
648
+ """
649
+ Lists resources.
650
+
651
+ :param params: Query parameters.
652
+ :return: An iterable of resources.
653
+ :raises APIClientError: In case that there is a problem with client-server communication.
654
+ :raises APIServerError: In case that there is a server generated error reported via HTTP status codes.
655
+ """
656
+ return (
657
+ self.resource(**i)
658
+ for i in self.client.request("GET", self.endpoint, params={**(params or {}), "page_size": "-1"})
659
+ )
660
+
661
+
662
+ class ResourceRetrieveEndpoint(Generic[_I, _R], ResourceEndpoint[_R]):
663
+ """
664
+ Provides a base class for resource endpoints with ``retrieve`` query method.
665
+ """
666
+
667
+ @query
668
+ def retrieve(self, ident: _I) -> Optional[_R]:
669
+ """
670
+ Attempts to retrieve the resource identified by the given identifier.
671
+
672
+ :param ident: Resource identifier.
673
+ :return: Resource if found, ``None`` otherwise.
674
+ :raises APIClientError: In case that there is a problem with client-server communication.
675
+ :raises APIServerError: In case that there is a server generated error reported via HTTP status codes.
676
+ """
677
+ try:
678
+ return self.resource(**self.client.request("GET", [self.endpoint, ident]))
679
+ except APIServerErrorNotFound:
680
+ return None
681
+
682
+
683
+ class ResourceCreateEndpoint(Generic[_C, _R], ResourceEndpoint[_R]):
684
+ """
685
+ Provides a base class for resource endpoints with ``create`` command method.
686
+ """
687
+
688
+ @command
689
+ def create(self, form: _C) -> _R:
690
+ """
691
+ Attempts to create a new resource with the given form data.
692
+
693
+ :param form: Resource create form instance.
694
+ :return: Resource if successfully created.
695
+ :raises APIClientError: In case that there is a problem with client-server communication.
696
+ :raises APIServerError: In case that there is a server generated error reported via HTTP status codes.
697
+ """
698
+ return self.resource(**self.client.request("POST", self.endpoint, json=_form_payload(form)))
699
+
700
+
701
+ class ResourceUpdateEndpoint(Generic[_I, _U, _R], ResourceEndpoint[_R]):
702
+ """
703
+ Provides a base class for resource endpoints with ``update`` command method.
704
+ """
705
+
706
+ @command
707
+ def update(self, ident: _I, form: _U) -> _R:
708
+ """
709
+ Attempts to update an existing resource with the given form data.
710
+
711
+ :param ident: Identifier of the resource to update.
712
+ :param form: Resource update form instance.
713
+ :return: Resource if successfully updated.
714
+ :raises APIClientError: In case that there is a problem with client-server communication.
715
+ :raises APIServerError: In case that there is a server generated error reported via HTTP status codes.
716
+ """
717
+ return self.resource(**self.client.request("PUT", [self.endpoint, ident], json=_form_payload(form)))
718
+
719
+
720
+ class ResourcePatchEndpoint(Generic[_I, _P, _R], ResourceEndpoint[_R]):
721
+ """
722
+ Provides a base class for resource endpoints with ``patch`` command method.
723
+ """
724
+
725
+ @command
726
+ def patch(self, ident: _I, form: _P) -> _R:
727
+ """
728
+ Attempts to patch an existing resource with the given form data.
729
+
730
+ :param ident: Identifier of the resource to patch.
731
+ :param form: Resource patch form instance.
732
+ :return: Resource if successfully patched.
733
+ :raises APIClientError: In case that there is a problem with client-server communication.
734
+ :raises APIServerError: In case that there is a server generated error reported via HTTP status codes.
735
+ """
736
+ return self.resource(**self.client.request("PATCH", [self.endpoint, ident], json=_form_payload(form)))
737
+
738
+
739
+ class DeleteEndpoint(Generic[_I], Endpoint):
740
+ """
741
+ Provides a base class for resource endpoints with ``delete`` command method.
742
+ """
743
+
744
+ @command
745
+ def delete(self, ident: _I) -> None:
746
+ """
747
+ Attempts to delete the resource identified by the given identifier.
748
+
749
+ :param ident: Identifier of the resource to delete.
750
+ :raises APIClientError: In case that there is a problem with client-server communication.
751
+ :raises APIServerError: In case that there is a server generated error reported via HTTP status codes.
752
+ """
753
+ self.client.request("DELETE", [self.endpoint, ident])
754
+
755
+
756
+ class StandardResourceEndpoint(
757
+ Generic[_I, _C, _U, _P, _R],
758
+ ResourceListEndpoint[_R],
759
+ ResourceRetrieveEndpoint[_I, _R],
760
+ ResourceCreateEndpoint[_C, _R],
761
+ ResourceUpdateEndpoint[_I, _U, _R],
762
+ ResourcePatchEndpoint[_I, _P, _R],
763
+ DeleteEndpoint[_I],
764
+ ResourceEndpoint[_R],
765
+ ):
766
+ """
767
+ Provides a generic base endpoint class definition for standard resources with listing, retrieval, creation,
768
+ updation, patching and deletion queries/commands.
769
+ """
770
+
771
+ pass
772
+
773
+
774
+ def _form_payload(form: Union[BaseCreateForm, BaseUpdateForm, BasePatchForm]) -> Dict[str, Any]:
775
+ """
776
+ Compiles HTTP payload from the given form instance.
777
+
778
+ This function omits missing values.
779
+
780
+ :param form: Form instance.
781
+ :return: A dictionary of form field name and corresponding value.
782
+ """
783
+ return {k: v for k, v in form if not isinstance(v, Missing)}
784
+
785
+
786
+ class Missing:
787
+ """
788
+ Provides a placeholder value for missing field values.
789
+ """
790
+
791
+ @classmethod
792
+ def field(cls) -> Any:
793
+ """
794
+ Provides a convenience method for creating a ``pydantic`` field for fields defaulting to missing value.
795
+
796
+ :return: :py:class:`Field` with missing value as default.
797
+ """
798
+ return Field(cls())
799
+
800
+ @classmethod
801
+ def __get_validators__(cls) -> Generator[Any, None, None]:
802
+ """
803
+ Makes this class a first class ``pydantic`` field value class.
804
+
805
+ :return: Validators.
806
+ """
807
+ yield cls.validate
808
+
809
+ @staticmethod
810
+ def validate(v: Any) -> "Missing":
811
+ """
812
+ Checks if the given value is a :py:class:`Missing` instance and returns it as is if so.
813
+
814
+ :param v: Value to be checked.
815
+ :return: Value.
816
+ :raises TypeError: In case that the given value is not a :py:class:`Missing` instance.
817
+ """
818
+ if isinstance(v, Missing):
819
+ return v
820
+ raise TypeError("'Missing' instance required")
File without changes
@@ -0,0 +1,150 @@
1
+ """
2
+ This module provides common type definitions along with a comprehensive resource identifier catalogue.
3
+ """
4
+
5
+ __all__ = [
6
+ "AccountId",
7
+ "AccountMngtFeeSchemeId",
8
+ "AccrualScheduleId",
9
+ "ActionId",
10
+ "ActionTypeId",
11
+ "AgentId",
12
+ "AnalyticalTypeId",
13
+ "ArtifactId",
14
+ "ArtifactTypeId",
15
+ "AssetClassId",
16
+ "ContainerType",
17
+ "CountryId",
18
+ "Currency",
19
+ "Date",
20
+ "DateTime",
21
+ "DateType",
22
+ "GUID",
23
+ "GroupId",
24
+ "InstitutionId",
25
+ "OHLCId",
26
+ "PortfolioGroupId",
27
+ "PortfolioId",
28
+ "QuantId",
29
+ "RiskProfileId",
30
+ "ShareClassId",
31
+ "SharingId",
32
+ "StrategyId",
33
+ "Tags",
34
+ "TeamId",
35
+ "TimeDelta",
36
+ "UserId",
37
+ "_LaterI",
38
+ "_LaterS",
39
+ ]
40
+
41
+ from datetime import date as Date
42
+ from datetime import datetime as DateTime
43
+ from datetime import timedelta as TimeDelta
44
+ from enum import Enum
45
+ from typing import NewType, Set
46
+
47
+ #: Defines a type alias for globally unique resource identifiers.
48
+ GUID = NewType("GUID", str)
49
+
50
+ #: Defines a new-type for currency codes.
51
+ Currency = NewType("Currency", str)
52
+
53
+ #: Defines a type alias for standard set of tags.
54
+ Tags = Set[str]
55
+
56
+ #: Defines a new-type for DECAF quant resource identifier.
57
+ QuantId = NewType("QuantId", int)
58
+
59
+ #: Defines a new-type for DECAF artifact resource identifier.
60
+ ArtifactId = NewType("ArtifactId", int)
61
+
62
+ #: Defines a new-type for DECAF artifact type resource identifier.
63
+ ArtifactTypeId = NewType("ArtifactTypeId", str)
64
+
65
+ #: Defines a new-type for DECAF action resource identifier.
66
+ ActionId = NewType("ActionId", int)
67
+
68
+ #: Defines a new-type for DECAF action type resource identifier.
69
+ ActionTypeId = NewType("ActionTypeId", int)
70
+
71
+ #: Defines a new-type for DECAF account resource identifier.
72
+ AccountId = NewType("AccountId", int)
73
+
74
+ #: Defines a new-type for DECAF agent resource identifier.
75
+ AgentId = NewType("AgentId", int)
76
+
77
+ #: Defines a new-type for DECAF institution resource identifier.
78
+ InstitutionId = NewType("InstitutionId", int)
79
+
80
+ #: Defines a new-type for DECAF strategy resource identifier.
81
+ StrategyId = NewType("StrategyId", int)
82
+
83
+ #: Defines a new-type for DECAF risk profile resource identifier.
84
+ RiskProfileId = NewType("RiskProfileId", int)
85
+
86
+ #: Defines a new-type for DECAF asset class resource identifier.
87
+ AssetClassId = NewType("AssetClassId", int)
88
+
89
+ #: Defines a new-type for DECAF portfolio resource identifier.
90
+ PortfolioId = NewType("PortfolioId", int)
91
+
92
+ #: Defines a new-type for DECAF account management fee scheme resource identifier.
93
+ AccountMngtFeeSchemeId = NewType("AccountMngtFeeSchemeId", int)
94
+
95
+ #: Defines a new-type for DECAF analytical type resource identifier.
96
+ AnalyticalTypeId = NewType("AnalyticalTypeId", int)
97
+
98
+ #: Defines a new-type for DECAF institution sharing resource identifier.
99
+ SharingId = NewType("SharingId", int)
100
+
101
+ #: Defines a new-type for DECAF user resource identifier.
102
+ UserId = NewType("UserId", int)
103
+
104
+ #: Defines a new-type for DECAF team resource identifier.
105
+ TeamId = NewType("TeamId", int)
106
+
107
+ #: Defines a new-type for DECAF group resource identifier.
108
+ GroupId = NewType("GroupId", int)
109
+
110
+ #: Defines a new-type for DECAF portfolio group resource identifier.
111
+ PortfolioGroupId = NewType("PortfolioGroupId", int)
112
+
113
+ #: Defines a new-type for DECAF country resource identifier.
114
+ CountryId = NewType("CountryId", str)
115
+
116
+ #: Defines a new-type for DECAF OHLC series resource identifier.
117
+ OHLCId = NewType("OHLCId", int)
118
+
119
+ #: Defines a new-type for DECAF share class resource identifier.
120
+ ShareClassId = NewType("ShareClassId", int)
121
+
122
+ #: Defines a new-type for DECAF accrual schedule resource identifier.
123
+ AccrualScheduleId = NewType("AccrualScheduleId", int)
124
+
125
+ #: Defines a new-type for DECAF resource identifiers which are yet to be declared (of :py:class:`int` type).
126
+ _LaterI = NewType("_LaterI", int)
127
+
128
+ #: Defines a new-type for DECAF resource identifiers which are yet to be declared (of :py:class:`str` type).
129
+ _LaterS = NewType("_LaterS", str)
130
+
131
+
132
+ class ContainerType(Enum):
133
+ """
134
+ Provides container types.
135
+ """
136
+
137
+ Account = "account"
138
+ Portfolio = "portfolio"
139
+ Institution = "institution"
140
+ Team = "team"
141
+ PortfolioGroup = "portfoliogroup"
142
+
143
+
144
+ class DateType(Enum):
145
+ """
146
+ Provides date types.
147
+ """
148
+
149
+ Commitment = "commitment"
150
+ Settlement = "settlement"
@@ -0,0 +1,201 @@
1
+ """
2
+ This module provides data, type and function definitions which could be useful on import-side. Some of there are used
3
+ internally in this library, too.
4
+ """
5
+
6
+ __all__ = [
7
+ "WithId",
8
+ "identity",
9
+ "make_lookup_table",
10
+ "parse_isodate",
11
+ "percentify",
12
+ "remove_microseconds",
13
+ "skipnone",
14
+ "sort_and_group_by",
15
+ ]
16
+
17
+ from decimal import Decimal
18
+ from itertools import groupby
19
+ from typing import Callable, Dict, Iterable, Optional, Protocol, Tuple, TypeVar, Union
20
+
21
+ from .types import Date, DateTime
22
+
23
+ #: Defines a generic type alias.
24
+ _T = TypeVar("_T")
25
+
26
+
27
+ def identity(x: _T) -> _T:
28
+ """
29
+ Provides an identity function.
30
+
31
+ Return value type is inferred the same as the parameter type.
32
+
33
+ :param x: Value.
34
+ :return: Value.
35
+
36
+ >>> identity(1)
37
+ 1
38
+ >>> identity("value")
39
+ 'value'
40
+ """
41
+ return x
42
+
43
+
44
+ def skipnone(func: Callable[[_T], _T]) -> Callable[[Optional[_T]], Optional[_T]]:
45
+ """
46
+ Provides a function decorator for avoiding function application if the value is ``None``.
47
+
48
+ :param func: Function to decorate.
49
+ :return: Decorated function
50
+
51
+ >>> add1: Callable[[int], int] = lambda x: x + 1
52
+ >>> add1(1)
53
+ 2
54
+ >>> add1(None)
55
+ Traceback (most recent call last):
56
+ ...
57
+ TypeError: unsupported operand type(s) for +: 'NoneType' and 'int'
58
+ >>> skipnone(add1)(1)
59
+ 2
60
+ >>> skipnone(add1)(None)
61
+ >>>
62
+
63
+ You can define a new function using the decorator (note the function signature):
64
+
65
+ >>> safe_add1: Callable[[Optional[int]], Optional[int]] = skipnone(add1)
66
+ >>> safe_add1(1)
67
+ 2
68
+ >>> safe_add1(None)
69
+ >>>
70
+
71
+ ... or decorate functions as such:
72
+
73
+ >>> @skipnone
74
+ ... def double(x: int) -> int:
75
+ ... return x * 2
76
+ >>> double(1)
77
+ 2
78
+ >>> double(None)
79
+ >>>
80
+ """
81
+ return lambda x: x if x is None else func(x)
82
+
83
+
84
+ def remove_microseconds(x: DateTime) -> DateTime:
85
+ """
86
+ Removes microseconds from the given :py:class:`datetime.datetime` instance.
87
+
88
+ :param x: :py:class:`datetime.datetime` instance
89
+ :return: A new :py:class:`datetime.datetime` instance with microseconds set to ``0``.
90
+
91
+ >>> DateTime(2019, 1, 2, 3, 4, 5, 6789)
92
+ datetime.datetime(2019, 1, 2, 3, 4, 5, 6789)
93
+ >>> remove_microseconds(DateTime(2019, 1, 2, 3, 4, 5, 6789))
94
+ datetime.datetime(2019, 1, 2, 3, 4, 5)
95
+ """
96
+ return x.replace(microsecond=0)
97
+
98
+
99
+ def percentify(x: Union[float, int, str, Decimal]) -> Decimal:
100
+ """
101
+ Consumes a value representing a number and returns its percentage points as a :py:class:`Decimal` instance.
102
+
103
+ :param x: A value representing a number.
104
+ :return: Percentage points as a :py:class:`Decimal` instance.
105
+ :raises decimal.InvalidOperation: In case that parameter can not be converted to valid :py:class:`Decimal` instance.
106
+
107
+ >>> percentify(0.01)
108
+ Decimal('1.00')
109
+ >>> percentify(0)
110
+ Decimal('0')
111
+ >>> percentify("0.01")
112
+ Decimal('1.00')
113
+ >>> percentify(Decimal("0.01"))
114
+ Decimal('1.00')
115
+ >>> percentify(1.0)
116
+ Decimal('100.0')
117
+ >>> percentify(1)
118
+ Decimal('100')
119
+ >>> percentify("1")
120
+ Decimal('100')
121
+ >>> percentify(Decimal(1))
122
+ Decimal('100')
123
+ >>> percentify("xyz")
124
+ Traceback (most recent call last):
125
+ ...
126
+ decimal.InvalidOperation: [<class 'decimal.ConversionSyntax'>]
127
+ """
128
+ return Decimal(str(x)) * Decimal(100)
129
+
130
+
131
+ def parse_isodate(x: str) -> Date:
132
+ """
133
+ Attempts to parse an ISO formatted date string and returns it as a :py:class:`datetime.date` instance.
134
+
135
+ :param x: ISO formatted date string
136
+ :return: A :py:class:`datetime.date` instance, if parsing is successful.
137
+ :raises ValueError: If the parameter does not represent a valid date or is not formatted as a valid ISO date string.
138
+
139
+ >>> parse_isodate("2019-01-02")
140
+ datetime.date(2019, 1, 2)
141
+ >>> parse_isodate("2019-02-29")
142
+ Traceback (most recent call last):
143
+ ...
144
+ ValueError: day is out of range for month
145
+ >>> parse_isodate("20190228")
146
+ Traceback (most recent call last):
147
+ ...
148
+ ValueError: time data '20190228' does not match format '%Y-%m-%d'
149
+ """
150
+ return DateTime.strptime(x, "%Y-%m-%d").date()
151
+
152
+
153
+ class WithId(Protocol[_T]):
154
+ """
155
+ Provides a generic protocol for models with ``id`` attribute.
156
+ """
157
+
158
+ #: Unique identifier of the instance.
159
+ id: _T
160
+
161
+
162
+ def make_lookup_table(records: Iterable[WithId[_T]]) -> Dict[_T, WithId[_T]]:
163
+ """
164
+ Creates a lookup table from a iterable of records following :py:class:`WithId` protocol.
165
+
166
+ :param records: Iterable of records.
167
+ :return: A lookup table of record id to record.
168
+
169
+ >>> make_lookup_table([])
170
+ {}
171
+ >>> from dataclasses import dataclass
172
+ >>> @dataclass
173
+ ... class A:
174
+ ... id: int
175
+ ... value: bool
176
+ >>> records = [A(1, True), A(2, False)]
177
+ >>> table = make_lookup_table(records)
178
+ >>> table[1].value
179
+ True
180
+ >>> table[2].value
181
+ False
182
+ """
183
+ return {r.id: r for r in records}
184
+
185
+
186
+ #: Defines a generic type alias.
187
+ _K = TypeVar("_K")
188
+
189
+ #: Defines a generic type alias.
190
+ _V = TypeVar("_V")
191
+
192
+
193
+ def sort_and_group_by(x: Iterable[_V], key: Callable[[_V], _K]) -> Iterable[Tuple[_K, Iterable[_V]]]:
194
+ """
195
+ Sorts and groups an iterable of values by the same key.
196
+
197
+ :param x: Iterable of values.
198
+ :param key: Function to compute the key from a given value.
199
+ :return: An iterable of tuples of key and iterable of values for the key.
200
+ """
201
+ return groupby(sorted(x, key=key), key=key) # type: ignore
@@ -0,0 +1,29 @@
1
+ Copyright (2017-2019) Vehbi Sinan Tunalioglu.
2
+ Copyright (2019-2024) Teloscube Pte Ltd.
3
+
4
+ Redistribution and use in source and binary forms, with or without
5
+ modification, are permitted provided that the following conditions are
6
+ met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright
9
+ notice, this list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright
12
+ notice, this list of conditions and the following disclaimer in the
13
+ documentation and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
20
+ "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
21
+ LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
22
+ A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
23
+ HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
24
+ SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
25
+ LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
26
+ DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
27
+ THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
28
+ (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,121 @@
1
+ Metadata-Version: 2.1
2
+ Name: decaf-api-client
3
+ Version: 0.0.12
4
+ Summary: DECAF API Client
5
+ Author-email: Vehbi Sinan Tunalioglu <vst@vsthost.com>
6
+ Maintainer-email: Vehbi Sinan Tunalioglu <vst@vsthost.com>
7
+ License: Copyright (2017-2019) Vehbi Sinan Tunalioglu.
8
+ Copyright (2019-2024) Teloscube Pte Ltd.
9
+
10
+ Redistribution and use in source and binary forms, with or without
11
+ modification, are permitted provided that the following conditions are
12
+ met:
13
+
14
+ 1. Redistributions of source code must retain the above copyright
15
+ notice, this list of conditions and the following disclaimer.
16
+
17
+ 2. Redistributions in binary form must reproduce the above copyright
18
+ notice, this list of conditions and the following disclaimer in the
19
+ documentation and/or other materials provided with the distribution.
20
+
21
+ 3. Neither the name of the copyright holder nor the names of its
22
+ contributors may be used to endorse or promote products derived from
23
+ this software without specific prior written permission.
24
+
25
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
26
+ "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
27
+ LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
28
+ A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
29
+ HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
30
+ SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
31
+ LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
32
+ DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
33
+ THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
34
+ (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
35
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
36
+
37
+ Project-URL: homepage, https://github.com/telostat/decaf-api-client-python
38
+ Project-URL: documentation, https://github.com/telostat/decaf-api-client-python
39
+ Project-URL: repository, https://github.com/telostat/decaf-api-client-python
40
+ Project-URL: changelog, https://github.com/teloscube/decaf-client-python/blob/main/CHANGELOG.md
41
+ Keywords: decaf,api,client,finance
42
+ Classifier: Development Status :: 3 - Alpha
43
+ Classifier: Intended Audience :: Developers
44
+ Classifier: License :: OSI Approved :: BSD License
45
+ Classifier: Programming Language :: Python :: 3
46
+ Classifier: Programming Language :: Python :: 3.10
47
+ Classifier: Programming Language :: Python :: 3.11
48
+ Classifier: Programming Language :: Python :: 3.12
49
+ Requires-Python: >=3.10
50
+ Description-Content-Type: text/markdown
51
+ License-File: LICENSE
52
+ Requires-Dist: pydantic <3,>=2
53
+ Requires-Dist: requests <3,>=2
54
+ Provides-Extra: dev
55
+ Requires-Dist: build ; extra == 'dev'
56
+ Requires-Dist: ipython ; extra == 'dev'
57
+ Requires-Dist: pip ; extra == 'dev'
58
+ Requires-Dist: pyls-flake8 ; extra == 'dev'
59
+ Requires-Dist: pyls-isort ; extra == 'dev'
60
+ Requires-Dist: pylsp-mypy ; extra == 'dev'
61
+ Requires-Dist: pylsp-rope ; extra == 'dev'
62
+ Requires-Dist: python-lsp-black ; extra == 'dev'
63
+ Requires-Dist: python-lsp-server ; extra == 'dev'
64
+ Requires-Dist: rope ; extra == 'dev'
65
+ Requires-Dist: twine ; extra == 'dev'
66
+ Requires-Dist: wheel ; extra == 'dev'
67
+ Provides-Extra: test
68
+ Requires-Dist: black ; extra == 'test'
69
+ Requires-Dist: flake8 ; extra == 'test'
70
+ Requires-Dist: isort ; extra == 'test'
71
+ Requires-Dist: mypy ; extra == 'test'
72
+ Requires-Dist: pytest ; extra == 'test'
73
+ Requires-Dist: types-requests ; extra == 'test'
74
+
75
+ # DECAF API Client (Python)
76
+
77
+ [![Test](https://github.com/teloscube/decaf-client-python/actions/workflows/test.yml/badge.svg)](https://github.com/teloscube/decaf-client-python/actions/workflows/test.yml)
78
+
79
+ > **TODO:** Provide a complete README file.
80
+
81
+ ## Development
82
+
83
+ A Nix shell is provided for development:
84
+
85
+ ```sh
86
+ nix develop
87
+ ```
88
+
89
+ Build to run tests:
90
+
91
+ ```sh
92
+ nix build
93
+ ```
94
+
95
+ ## Build and Publish
96
+
97
+ Build the source distribution:
98
+
99
+ ```sh
100
+ python -m build --sdist --wheel
101
+ ```
102
+
103
+ Check the distribution(s):
104
+
105
+ ```sh
106
+ twine check dist/*
107
+ ```
108
+
109
+ Upload the distribution:
110
+
111
+ ```sh
112
+ twine upload -s dist/*
113
+ ```
114
+
115
+ ## License and Copyrights
116
+
117
+ This software is licensed under [The 3-Clause BSD License].
118
+
119
+ <!-- REFERENCES -->
120
+
121
+ [The 3-Clause BSD License]: https://opensource.org/licenses/BSD-3-Clause
@@ -0,0 +1,11 @@
1
+ decaf/api/client/__init__.py,sha256=WnlLgUAn6tUogLJNIlC1S2OVOLRJEEUFi5ZjJIKYxsA,111
2
+ decaf/api/client/_internal.py,sha256=p6m7MXUCr8SCZK_Lhc1BqO_mK3I7-Mlk0sGFTsPD_Dw,229
3
+ decaf/api/client/machinery.py,sha256=tNIS3bXqrjIAVDiLqC0zMwA4VZFAPYXtdHeGcJjx9iE,25068
4
+ decaf/api/client/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ decaf/api/client/types.py,sha256=uur23IO8tNm09m1H6jblMhZk4g7OPHzn7bwnIlhmq6k,4231
6
+ decaf/api/client/utils.py,sha256=DmyuXOMo4wKpigklVjY8vPEXjoErcxjhROI3jfVLmE8,5458
7
+ decaf_api_client-0.0.12.dist-info/LICENSE,sha256=Xs-bwjHHT2CraHEgXAz_amdE2z9nZK-f3YozmxtztEY,1516
8
+ decaf_api_client-0.0.12.dist-info/METADATA,sha256=kvi-zkxOqFAJSu9iuRbXV1NOA9Oucp3LoHe3AW50CDw,4364
9
+ decaf_api_client-0.0.12.dist-info/WHEEL,sha256=GV9aMThwP_4oNCtvEC2ec3qUYutgWeAzklro_0m4WJQ,91
10
+ decaf_api_client-0.0.12.dist-info/top_level.txt,sha256=jBvLsYzSXodl4Brh1J9a99Rp7xluwM7Nelyml6gzdXU,6
11
+ decaf_api_client-0.0.12.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (75.1.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ decaf