arkindex-client 1.0.16__py3-none-any.whl → 1.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.
apistar/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ # -*- coding: utf-8 -*-
2
+ import warnings
3
+
4
+ warnings.warn(
5
+ "The Arkindex API client no longer depends on APIStar. "
6
+ "Please update your `apistar` imports to use the `arkindex` package.",
7
+ FutureWarning,
8
+ stacklevel=2,
9
+ )
apistar/exceptions.py ADDED
@@ -0,0 +1,13 @@
1
+ # -*- coding: utf-8 -*-
2
+ import warnings
3
+
4
+ from arkindex.exceptions import ErrorResponse
5
+
6
+ __all__ = ["ErrorResponse"]
7
+
8
+ warnings.warn(
9
+ "The Arkindex API client no longer depends on APIStar. "
10
+ "Please update your `apistar.exceptions` imports to use the `arkindex.exceptions` module.",
11
+ FutureWarning,
12
+ stacklevel=2,
13
+ )
arkindex/auth.py CHANGED
@@ -1,11 +1,10 @@
1
1
  # -*- coding: utf-8 -*-
2
- from apistar.client.auth import SessionAuthentication, TokenAuthentication
2
+ from requests.auth import AuthBase
3
3
 
4
4
 
5
- class TokenSessionAuthentication(TokenAuthentication):
6
- """
7
- A token authentication that takes care of CSRF tokens.
8
- """
5
+ class TokenSessionAuthentication(AuthBase):
6
+
7
+ safe_methods = ("GET", "HEAD", "OPTIONS", "TRACE")
9
8
 
10
9
  def __init__(
11
10
  self,
@@ -20,14 +19,30 @@ class TokenSessionAuthentication(TokenAuthentication):
20
19
  :param str csrf_cookie_name: Name of the CSRF token cookie.
21
20
  :param str csrf_header_name: Name of the CSRF request header.
22
21
  """
23
- self.session_auth = SessionAuthentication(
24
- csrf_cookie_name=csrf_cookie_name,
25
- csrf_header_name=csrf_header_name,
26
- )
27
- super().__init__(token, scheme=scheme)
22
+ self.token = token
23
+ self.scheme = scheme
24
+ self.csrf_cookie_name = csrf_cookie_name
25
+ self.csrf_header_name = csrf_header_name
26
+ self.csrf_token = None
27
+
28
+ def store_csrf_token(self, response, **kwargs):
29
+ if self.csrf_cookie_name in response.cookies:
30
+ self.csrf_token = response.cookies[self.csrf_cookie_name]
28
31
 
29
32
  def __call__(self, request):
30
- request = self.session_auth(request)
31
- if self.token is None:
32
- return request
33
- return super().__call__(request)
33
+ # Add CSRF token
34
+ if (
35
+ self.csrf_token
36
+ and self.csrf_header_name is not None
37
+ and (request.method not in self.safe_methods)
38
+ ):
39
+ request.headers[self.csrf_header_name] = self.csrf_token
40
+
41
+ if self.csrf_cookie_name is not None:
42
+ request.register_hook("response", self.store_csrf_token)
43
+
44
+ # Add API token
45
+ if self.token is not None:
46
+ request.headers["Authorization"] = f"{self.scheme} {self.token}"
47
+
48
+ return request
@@ -0,0 +1,4 @@
1
+ # -*- coding: utf-8 -*-
2
+ from arkindex.client.client import ArkindexClient, options_from_env
3
+
4
+ __all__ = ["ArkindexClient", "options_from_env"]
@@ -0,0 +1,98 @@
1
+ # -*- coding: utf-8 -*-
2
+ from urllib.parse import quote, urljoin, urlparse
3
+
4
+ import typesystem
5
+
6
+ from arkindex import exceptions
7
+ from arkindex.client.transports import HTTPTransport
8
+ from arkindex.schema.validator import validate
9
+
10
+
11
+ class BaseClient:
12
+ def __init__(
13
+ self,
14
+ schema,
15
+ encoding=None,
16
+ auth=None,
17
+ decoders=None,
18
+ headers=None,
19
+ session=None,
20
+ allow_cookies=True,
21
+ verify=True,
22
+ ):
23
+ self.document = validate(schema, encoding=encoding)
24
+ self.transport = HTTPTransport(
25
+ auth=auth,
26
+ decoders=decoders,
27
+ headers=headers,
28
+ session=session,
29
+ allow_cookies=allow_cookies,
30
+ verify=verify,
31
+ )
32
+
33
+ def lookup_operation(self, operation_id: str):
34
+ for item in self.document.walk_links():
35
+ if item.link.name == operation_id:
36
+ return item.link
37
+ text = 'Operation ID "%s" not found in schema.' % operation_id
38
+ message = exceptions.ErrorMessage(text=text, code="invalid-operation")
39
+ raise exceptions.ClientError(messages=[message])
40
+
41
+ def get_url(self, link, params):
42
+ url = urljoin(self.document.url, link.url)
43
+
44
+ scheme = urlparse(url).scheme.lower()
45
+
46
+ if not scheme:
47
+ text = "URL missing scheme '%s'." % url
48
+ message = exceptions.ErrorMessage(text=text, code="invalid-url")
49
+ raise exceptions.ClientError(messages=[message])
50
+
51
+ if scheme not in self.transport.schemes:
52
+ text = "Unsupported URL scheme '%s'." % scheme
53
+ message = exceptions.ErrorMessage(text=text, code="invalid-url")
54
+ raise exceptions.ClientError(messages=[message])
55
+
56
+ for field in link.get_path_fields():
57
+ value = str(params[field.name])
58
+ if "{%s}" % field.name in url:
59
+ url = url.replace("{%s}" % field.name, quote(value, safe=""))
60
+ elif "{+%s}" % field.name in url:
61
+ url = url.replace("{+%s}" % field.name, quote(value, safe="/"))
62
+
63
+ return url
64
+
65
+ def get_query_params(self, link, params):
66
+ return {
67
+ field.name: params[field.name]
68
+ for field in link.get_query_fields()
69
+ if field.name in params
70
+ }
71
+
72
+ def get_content_and_encoding(self, link, params):
73
+ body_field = link.get_body_field()
74
+ if body_field and body_field.name in params:
75
+ return (params[body_field.name], link.encoding)
76
+ return (None, None)
77
+
78
+ def request(self, operation_id: str, **params):
79
+ link = self.lookup_operation(operation_id)
80
+
81
+ validator = typesystem.Object(
82
+ properties={field.name: typesystem.Any() for field in link.fields},
83
+ required=[field.name for field in link.fields if field.required],
84
+ additional_properties=False,
85
+ )
86
+ try:
87
+ validator.validate(params)
88
+ except typesystem.ValidationError as exc:
89
+ raise exceptions.ClientError(messages=exc.messages()) from None
90
+
91
+ method = link.method
92
+ url = self.get_url(link, params)
93
+ query_params = self.get_query_params(link, params)
94
+ (content, encoding) = self.get_content_and_encoding(link, params)
95
+
96
+ return self.transport.send(
97
+ method, url, query_params=query_params, content=content, encoding=encoding
98
+ )
@@ -8,10 +8,8 @@ import warnings
8
8
  from time import sleep
9
9
  from urllib.parse import urljoin, urlsplit, urlunsplit
10
10
 
11
- import apistar
12
11
  import requests
13
12
  import yaml
14
- from apistar.exceptions import ErrorResponse
15
13
  from tenacity import (
16
14
  before_sleep_log,
17
15
  retry,
@@ -21,9 +19,20 @@ from tenacity import (
21
19
  )
22
20
 
23
21
  from arkindex.auth import TokenSessionAuthentication
24
- from arkindex.exceptions import SchemaError
22
+ from arkindex.client.base import BaseClient
23
+ from arkindex.exceptions import ErrorResponse, SchemaError
25
24
  from arkindex.pagination import ResponsePaginator
26
- from arkindex.transports import ArkindexHTTPTransport
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ try:
29
+ from yaml import CSafeLoader as SafeLoader
30
+
31
+ logger.debug("Using LibYAML-based parser")
32
+ except ImportError:
33
+ from yaml import SafeLoader
34
+
35
+ logger.debug("Using default PyYAML parser")
27
36
 
28
37
  BASE_DIR = os.path.dirname(os.path.abspath(__file__))
29
38
 
@@ -32,8 +41,6 @@ DEFAULT_BASE_URL = "https://arkindex.teklia.com/"
32
41
  # Endpoint accessed by the client on instantiation to retrieve the OpenAPI schema
33
42
  SCHEMA_ENDPOINT = "/api/v1/openapi/?format=json"
34
43
 
35
- logger = logging.getLogger(__name__)
36
-
37
44
 
38
45
  def _is_500_error(exc: Exception) -> bool:
39
46
  """
@@ -90,7 +97,7 @@ def _find_param(operation, param_name):
90
97
  raise KeyError("Parameter '{}' not found".format(param_name))
91
98
 
92
99
 
93
- class ArkindexClient(apistar.Client):
100
+ class ArkindexClient(BaseClient):
94
101
  """
95
102
  An Arkindex API client.
96
103
  """
@@ -118,7 +125,7 @@ class ArkindexClient(apistar.Client):
118
125
  :type csrf_cookie: str or None
119
126
  :param float sleep: Number of seconds to wait before sending each API request,
120
127
  as a simple means of throttling.
121
- :param \**kwargs: Keyword arguments to send to ``apistar.Client``.
128
+ :param \**kwargs: Keyword arguments to send to ``arkindex.client.base.BaseClient``.
122
129
  """
123
130
  if not schema_url:
124
131
  schema_url = urljoin(base_url, SCHEMA_ENDPOINT)
@@ -129,11 +136,11 @@ class ArkindexClient(apistar.Client):
129
136
  if split.scheme == "file" or not (split.scheme or split.netloc):
130
137
  # This is a local path
131
138
  with open(schema_url) as f:
132
- schema = yaml.safe_load(f)
139
+ schema = yaml.load(f, Loader=SafeLoader)
133
140
  else:
134
141
  resp = requests.get(schema_url, verify=self.verify)
135
142
  resp.raise_for_status()
136
- schema = yaml.safe_load(resp.content)
143
+ schema = yaml.load(resp.content, Loader=SafeLoader)
137
144
  except Exception as e:
138
145
  raise SchemaError(
139
146
  f"Could not retrieve a proper OpenAPI schema from {schema_url}"
@@ -141,7 +148,7 @@ class ArkindexClient(apistar.Client):
141
148
 
142
149
  super().__init__(schema, **kwargs)
143
150
 
144
- # APIStar will treat a schema as valid even when there are no endpoints, making the client completely useless.
151
+ # An OpenAPI schema is considered valid even when there are no endpoints, making the client completely useless.
145
152
  if not len(self.document.walk_links()):
146
153
  raise SchemaError(
147
154
  f"The OpenAPI schema from {base_url} has no defined endpoints"
@@ -161,7 +168,7 @@ class ArkindexClient(apistar.Client):
161
168
  if "x-paginated" in operation:
162
169
  link_info.link._paginated = operation["x-paginated"]
163
170
 
164
- # Remove domains from each endpoint; allows APIStar to properly handle our base URL
171
+ # Remove domains from each endpoint; allows to properly handle our base URL
165
172
  # https://github.com/encode/apistar/issues/657
166
173
  original_url = urlsplit(link_info.link.url)
167
174
  # Removes the scheme and netloc
@@ -193,9 +200,6 @@ class ArkindexClient(apistar.Client):
193
200
  self.document.url if hasattr(self, "document") else "",
194
201
  )
195
202
 
196
- def init_transport(self, *args, **kwargs):
197
- return ArkindexHTTPTransport(self.verify, *args, **kwargs)
198
-
199
203
  def configure(
200
204
  self,
201
205
  token=None,
@@ -242,7 +246,7 @@ class ArkindexClient(apistar.Client):
242
246
 
243
247
  def paginate(self, operation_id, *args, **kwargs):
244
248
  """
245
- Perform a usual request as done by APIStar, but handle paginated endpoints.
249
+ Perform a usual API request, but handle paginated endpoints.
246
250
 
247
251
  :return: An iterator for a paginated endpoint.
248
252
  :rtype: Union[arkindex.pagination.ResponsePaginator, dict, list]
@@ -267,8 +271,8 @@ class ArkindexClient(apistar.Client):
267
271
  def single_request(self, operation_id, *args, **kwargs):
268
272
  """
269
273
  Perform an API request.
270
- :param args: Arguments passed to the APIStar client.
271
- :param kwargs: Keyword arguments passed to the APIStar client.
274
+ :param args: Arguments passed to the BaseClient.
275
+ :param kwargs: Keyword arguments passed to the BaseClient.
272
276
  """
273
277
  link = self.lookup_operation(operation_id)
274
278
  if link.deprecated:
@@ -309,7 +313,7 @@ class ArkindexClient(apistar.Client):
309
313
  If the 5th call still gives a 50x, the exception is re-raised and the caller should catch it.
310
314
  Log messages are displayed before sleeping (when at least one exception occurred).
311
315
 
312
- :param args: Arguments passed to the APIStar client.
313
- :param kwargs: Keyword arguments passed to the APIStar client.
316
+ :param args: Arguments passed to the BaseClient.
317
+ :param kwargs: Keyword arguments passed to the BaseClient.
314
318
  """
315
319
  return self.single_request(operation_id, *args, **kwargs)
@@ -0,0 +1,252 @@
1
+ # -*- coding: utf-8 -*-
2
+ import cgi
3
+ import json
4
+ import os
5
+ import posixpath
6
+ import shutil
7
+ import tempfile
8
+ from urllib.parse import urlparse
9
+
10
+ from arkindex.compat import DownloadedFile
11
+
12
+
13
+ class BaseDecoder:
14
+ media_type = None
15
+
16
+ def decode(self, bytestring, **options):
17
+ raise NotImplementedError()
18
+
19
+
20
+ class JSONDecoder(BaseDecoder):
21
+ media_type = "application/json"
22
+
23
+ def decode(self, response):
24
+ """
25
+ Return raw JSON data.
26
+ """
27
+ content = response.content.decode("utf-8")
28
+ return json.loads(content)
29
+
30
+
31
+ class TextDecoder(BaseDecoder):
32
+ media_type = "text/*"
33
+
34
+ def decode(self, response):
35
+ return response.text
36
+
37
+
38
+ class DownloadDecoder(BaseDecoder):
39
+ """
40
+ A codec to handle raw file downloads, such as images and other media.
41
+ """
42
+
43
+ media_type = "*/*"
44
+
45
+ def __init__(self, download_dir=None):
46
+ """
47
+ `download_dir` - If `None` then downloaded files will be temporary files
48
+ that are deleted on close. If set to a value, then downloaded files
49
+ will be saved to this directory, and will not be automatically deleted.
50
+ """
51
+ self._delete_on_close = download_dir is None
52
+ self.download_dir = download_dir
53
+
54
+ def decode(self, response):
55
+ base_url = response.url
56
+ content_type = response.headers.get("content-type")
57
+ content_disposition = response.headers.get("content-disposition")
58
+
59
+ # Write the download to a temporary .download file.
60
+ fd, temp_path = tempfile.mkstemp(suffix=".download")
61
+ with os.fdopen(fd, "wb") as file_handle:
62
+ for chunk in response.iter_content(chunk_size=4096):
63
+ file_handle.write(chunk)
64
+
65
+ # Determine the output filename.
66
+ output_filename = _get_filename(base_url, content_type, content_disposition)
67
+
68
+ # Determine the output directory.
69
+ output_dir = self.download_dir
70
+ if output_dir is None:
71
+ output_dir = os.path.dirname(temp_path)
72
+
73
+ # Determine the full output path.
74
+ output_path = os.path.join(output_dir, output_filename)
75
+
76
+ # Move the temporary download file to the final location.
77
+ if output_path != temp_path:
78
+ output_path = _unique_output_path(output_path)
79
+ shutil.move(temp_path, output_path)
80
+
81
+ # Open the file and return the file object.
82
+ output_file = open(output_path, "rb")
83
+ downloaded = DownloadedFile(
84
+ output_file, output_path, delete=self._delete_on_close
85
+ )
86
+ downloaded.basename = output_filename
87
+ return downloaded
88
+
89
+
90
+ def _guess_extension(content_type):
91
+ """
92
+ Python's `mimetypes.guess_extension` is no use because it simply returns
93
+ the first of an unordered set. We use the same set of media types here,
94
+ but take a reasonable preference on what extension to map to.
95
+ """
96
+ return {
97
+ "application/javascript": ".js",
98
+ "application/msword": ".doc",
99
+ "application/octet-stream": ".bin",
100
+ "application/oda": ".oda",
101
+ "application/pdf": ".pdf",
102
+ "application/pkcs7-mime": ".p7c",
103
+ "application/postscript": ".ps",
104
+ "application/vnd.apple.mpegurl": ".m3u",
105
+ "application/vnd.ms-excel": ".xls",
106
+ "application/vnd.ms-powerpoint": ".ppt",
107
+ "application/x-bcpio": ".bcpio",
108
+ "application/x-cpio": ".cpio",
109
+ "application/x-csh": ".csh",
110
+ "application/x-dvi": ".dvi",
111
+ "application/x-gtar": ".gtar",
112
+ "application/x-hdf": ".hdf",
113
+ "application/x-latex": ".latex",
114
+ "application/x-mif": ".mif",
115
+ "application/x-netcdf": ".nc",
116
+ "application/x-pkcs12": ".p12",
117
+ "application/x-pn-realaudio": ".ram",
118
+ "application/x-python-code": ".pyc",
119
+ "application/x-sh": ".sh",
120
+ "application/x-shar": ".shar",
121
+ "application/x-shockwave-flash": ".swf",
122
+ "application/x-sv4cpio": ".sv4cpio",
123
+ "application/x-sv4crc": ".sv4crc",
124
+ "application/x-tar": ".tar",
125
+ "application/x-tcl": ".tcl",
126
+ "application/x-tex": ".tex",
127
+ "application/x-texinfo": ".texinfo",
128
+ "application/x-troff": ".tr",
129
+ "application/x-troff-man": ".man",
130
+ "application/x-troff-me": ".me",
131
+ "application/x-troff-ms": ".ms",
132
+ "application/x-ustar": ".ustar",
133
+ "application/x-wais-source": ".src",
134
+ "application/xml": ".xml",
135
+ "application/zip": ".zip",
136
+ "audio/basic": ".au",
137
+ "audio/mpeg": ".mp3",
138
+ "audio/x-aiff": ".aif",
139
+ "audio/x-pn-realaudio": ".ra",
140
+ "audio/x-wav": ".wav",
141
+ "image/gif": ".gif",
142
+ "image/ief": ".ief",
143
+ "image/jpeg": ".jpe",
144
+ "image/png": ".png",
145
+ "image/svg+xml": ".svg",
146
+ "image/tiff": ".tiff",
147
+ "image/vnd.microsoft.icon": ".ico",
148
+ "image/x-cmu-raster": ".ras",
149
+ "image/x-ms-bmp": ".bmp",
150
+ "image/x-portable-anymap": ".pnm",
151
+ "image/x-portable-bitmap": ".pbm",
152
+ "image/x-portable-graymap": ".pgm",
153
+ "image/x-portable-pixmap": ".ppm",
154
+ "image/x-rgb": ".rgb",
155
+ "image/x-xbitmap": ".xbm",
156
+ "image/x-xpixmap": ".xpm",
157
+ "image/x-xwindowdump": ".xwd",
158
+ "message/rfc822": ".eml",
159
+ "text/css": ".css",
160
+ "text/csv": ".csv",
161
+ "text/html": ".html",
162
+ "text/plain": ".txt",
163
+ "text/richtext": ".rtx",
164
+ "text/tab-separated-values": ".tsv",
165
+ "text/x-python": ".py",
166
+ "text/x-setext": ".etx",
167
+ "text/x-sgml": ".sgml",
168
+ "text/x-vcard": ".vcf",
169
+ "text/xml": ".xml",
170
+ "video/mp4": ".mp4",
171
+ "video/mpeg": ".mpeg",
172
+ "video/quicktime": ".mov",
173
+ "video/webm": ".webm",
174
+ "video/x-msvideo": ".avi",
175
+ "video/x-sgi-movie": ".movie",
176
+ }.get(content_type, "")
177
+
178
+
179
+ def _unique_output_path(path):
180
+ """
181
+ Given a path like '/a/b/c.txt'
182
+
183
+ Return the first available filename that doesn't already exist,
184
+ using an incrementing suffix if needed.
185
+
186
+ For example: '/a/b/c.txt' or '/a/b/c (1).txt' or '/a/b/c (2).txt'...
187
+ """
188
+ basename, ext = os.path.splitext(path)
189
+ idx = 0
190
+ while os.path.exists(path):
191
+ idx += 1
192
+ path = "%s (%d)%s" % (basename, idx, ext)
193
+ return path
194
+
195
+
196
+ def _safe_filename(filename):
197
+ """
198
+ Sanitize output filenames, to remove any potentially unsafe characters.
199
+ """
200
+ filename = os.path.basename(filename)
201
+
202
+ keepcharacters = (" ", ".", "_", "-")
203
+ filename = (
204
+ "".join(char for char in filename if char.isalnum() or char in keepcharacters)
205
+ .strip()
206
+ .strip(".")
207
+ )
208
+
209
+ return filename
210
+
211
+
212
+ def _get_filename_from_content_disposition(content_disposition):
213
+ """
214
+ Determine an output filename based on the `Content-Disposition` header.
215
+ """
216
+ params = value, params = cgi.parse_header(content_disposition)
217
+
218
+ if "filename" in params:
219
+ filename = params["filename"]
220
+ return _safe_filename(filename)
221
+
222
+ return None
223
+
224
+
225
+ def _get_filename_from_url(url, content_type=None):
226
+ """
227
+ Determine an output filename based on the download URL.
228
+ """
229
+ parsed = urlparse(url)
230
+ final_path_component = posixpath.basename(parsed.path.rstrip("/"))
231
+ filename = _safe_filename(final_path_component)
232
+ suffix = _guess_extension(content_type or "")
233
+
234
+ if filename:
235
+ if "." not in filename:
236
+ return filename + suffix
237
+ return filename
238
+
239
+ return "download" + suffix
240
+
241
+
242
+ def _get_filename(base_url, content_type=None, content_disposition=None):
243
+ """
244
+ Determine an output filename to use for the download.
245
+ """
246
+ filename = None
247
+ if content_disposition:
248
+ filename = _get_filename_from_content_disposition(content_disposition)
249
+ if filename is not None:
250
+ return filename
251
+
252
+ return _get_filename_from_url(base_url, content_type)