superroute-sapp 0.2.1__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.
sapp/__init__.py ADDED
@@ -0,0 +1,25 @@
1
+ """sApp for Python: sign-in, the platform app API, Open Platform capabilities and webhook verification for
2
+ Superroute (https://open.superroute.ca/developer/sapp)."""
3
+ from ._operation import Operation, OperationFile
4
+ from .core import (
5
+ DEFAULT_BASE_URL, SDK_VERSION, AuthorizationRequest, MemoryTokenStore, OpenClient, PartnerClient, SuperrouteError,
6
+ Tokens, TokenStore, Transport, code_challenge, create_pkce, theme_from_branding, urllib_transport,
7
+ )
8
+ from .generated.open_api import OPERATIONS as OPEN_OPERATIONS
9
+ from .generated.open_api import OpenApi
10
+ from .generated.partner_api import OPERATIONS as PARTNER_OPERATIONS
11
+ from .generated.partner_api import PartnerApi
12
+ from .webhooks import (
13
+ DEFAULT_WEBHOOK_TOLERANCE, TENANT_WEBHOOK_TOLERANCE, WEBHOOK_SCHEMES, WebhookEvent, WebhookVerificationError,
14
+ verify_webhook,
15
+ )
16
+
17
+ __version__ = SDK_VERSION
18
+
19
+ __all__ = [
20
+ 'AuthorizationRequest', 'DEFAULT_BASE_URL', 'DEFAULT_WEBHOOK_TOLERANCE', 'MemoryTokenStore', 'OPEN_OPERATIONS',
21
+ 'OpenApi', 'OpenClient', 'Operation', 'OperationFile', 'PARTNER_OPERATIONS', 'PartnerApi', 'PartnerClient',
22
+ 'SDK_VERSION', 'SuperrouteError', 'TENANT_WEBHOOK_TOLERANCE', 'TokenStore', 'Tokens', 'Transport',
23
+ 'WEBHOOK_SCHEMES', 'WebhookEvent', 'WebhookVerificationError', 'code_challenge', 'create_pkce',
24
+ 'theme_from_branding', 'urllib_transport', 'verify_webhook',
25
+ ]
sapp/_operation.py ADDED
@@ -0,0 +1,28 @@
1
+ """One operation of a generated API, and a file sent with a multipart operation."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+ from typing import Optional, Tuple
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class Operation:
10
+ """``auth`` is ``token``, ``key``, ``token_or_key`` or ``none``; ``body`` is ``json``, ``form``, ``multipart`` or None."""
11
+
12
+ id: str
13
+ name: str
14
+ method: str
15
+ path: str
16
+ auth: str
17
+ query: Tuple[str, ...]
18
+ body: Optional[str]
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class OperationFile:
23
+ """A file sent with a multipart operation (photos, signatures)."""
24
+
25
+ field: str
26
+ content: bytes
27
+ filename: str
28
+ content_type: str = 'application/octet-stream'
sapp/core.py ADDED
@@ -0,0 +1,613 @@
1
+ """sApp for Python: the core, sign-in and the generated API clients (docs/PLATFORM-APP-SDK-DESIGN.md §10).
2
+
3
+ Standard library only: urllib for HTTP, hashlib / secrets for PKCE. Every client takes a ``transport`` so tests and
4
+ apps can send requests their own way.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import base64
9
+ import hashlib
10
+ import json
11
+ import re
12
+ import secrets
13
+ import threading
14
+ import time
15
+ import urllib.error
16
+ import urllib.parse
17
+ import urllib.request
18
+ import uuid
19
+ from dataclasses import dataclass, field
20
+ from typing import Any, Callable, Dict, List, Mapping, Optional, Protocol, Sequence, Tuple
21
+
22
+ from ._operation import Operation, OperationFile
23
+ from .generated.open_api import OpenApi
24
+ from .generated.partner_api import PartnerApi
25
+
26
+ SDK_VERSION = '0.2.1'
27
+ DEFAULT_BASE_URL = 'https://open.superroute.ca'
28
+
29
+ _TOKEN_EXCHANGE = 'urn:ietf:params:oauth:grant-type:token-exchange'
30
+ _ID_TOKEN = 'urn:ietf:params:oauth:token-type:id_token'
31
+ #: Refresh this long before the access token expires, so a call never starts with a token about to lapse.
32
+ _REFRESH_MARGIN_MS = 60_000
33
+
34
+ #: transport(method, url, headers, body) -> (status, headers, body). Raises OSError on a lost connection.
35
+ Transport = Callable[[str, str, Dict[str, str], Optional[bytes]], Tuple[int, Mapping[str, str], bytes]]
36
+
37
+ # ------------------------------------------------------------------------------------------------ errors
38
+
39
+
40
+ class SuperrouteError(Exception):
41
+ """Every failed call. ``status`` is the HTTP status (0 when the request never got an answer), ``code`` the
42
+ server's error code (``invalid_token``, ``insufficient_scope``, ``access_denied``, a platform app code such as
43
+ ``APP_ACCESS_DENIED``, …) when it sent one, ``body`` the parsed answer."""
44
+
45
+ def __init__(self, status: int, code: Optional[str] = None, message: Optional[str] = None, body: Any = None,
46
+ request_id: Optional[str] = None) -> None:
47
+ super().__init__(message or code or f'Request failed with status {status}')
48
+ self.status = status
49
+ self.code = code
50
+ self.message = str(self)
51
+ self.body = body
52
+ self.request_id = request_id
53
+
54
+ @property
55
+ def sign_in_required(self) -> bool:
56
+ """The person must sign in again: the session ended or was revoked."""
57
+ return self.status == 401 or self.code == 'invalid_grant'
58
+
59
+
60
+ def _error_from(status: int, body: Any, request_id: Optional[str] = None) -> SuperrouteError:
61
+ b = body if isinstance(body, dict) else {}
62
+ message = b.get('message')
63
+ code = b.get('error') or b.get('code') or (message if isinstance(message, str) and re.fullmatch(r'[A-Z_]+', message) else None)
64
+ text = b.get('error_description') or (' '.join(str(m) for m in message) if isinstance(message, list) else message) or b.get('title')
65
+ return SuperrouteError(status, code, text, body, b.get('request_id') or request_id)
66
+
67
+
68
+ # ------------------------------------------------------------------------------------------------ tokens
69
+
70
+
71
+ @dataclass
72
+ class Tokens:
73
+ access_token: str
74
+ refresh_token: Optional[str] = None
75
+ #: Milliseconds since the epoch.
76
+ expires_at: int = 0
77
+ scope: List[str] = field(default_factory=list)
78
+
79
+
80
+ class TokenStore(Protocol):
81
+ """Where tokens are kept. Use encrypted storage for anything that should last."""
82
+
83
+ def get(self) -> Optional[Tokens]: ...
84
+
85
+ def set(self, tokens: Tokens) -> None: ...
86
+
87
+ def clear(self) -> None: ...
88
+
89
+
90
+ class MemoryTokenStore:
91
+ """Keeps the tokens in memory only."""
92
+
93
+ def __init__(self, tokens: Optional[Tokens] = None) -> None:
94
+ self._tokens = tokens
95
+
96
+ def get(self) -> Optional[Tokens]:
97
+ return self._tokens
98
+
99
+ def set(self, tokens: Tokens) -> None:
100
+ self._tokens = tokens
101
+
102
+ def clear(self) -> None:
103
+ self._tokens = None
104
+
105
+
106
+ def _tokens_from(body: Any, now_ms: int) -> Tokens:
107
+ if not isinstance(body, dict) or not isinstance(body.get('access_token'), str):
108
+ raise SuperrouteError(200, 'invalid_response', 'The token answer had no access token.', body)
109
+ try:
110
+ expires_in = float(body.get('expires_in') or 3600)
111
+ except (TypeError, ValueError):
112
+ expires_in = 3600.0
113
+ scope = body.get('scope')
114
+ return Tokens(
115
+ access_token=body['access_token'],
116
+ refresh_token=body.get('refresh_token') or None,
117
+ expires_at=now_ms + int(expires_in * 1000),
118
+ scope=[s for s in scope.split(' ') if s] if isinstance(scope, str) else [],
119
+ )
120
+
121
+
122
+ # ------------------------------------------------------------------------------------------------ PKCE
123
+
124
+
125
+ def _base64url(data: bytes) -> str:
126
+ return base64.urlsafe_b64encode(data).decode('ascii').rstrip('=')
127
+
128
+
129
+ def code_challenge(verifier: str) -> str:
130
+ """S256 code challenge of a verifier (RFC 7636 §4.2)."""
131
+ return _base64url(hashlib.sha256(verifier.encode('ascii')).digest())
132
+
133
+
134
+ def create_pkce() -> Dict[str, str]:
135
+ """A fresh PKCE pair: keep ``verifier`` until the redirect comes back, send ``challenge`` with the request."""
136
+ verifier = _base64url(secrets.token_bytes(48))
137
+ return {'verifier': verifier, 'challenge': code_challenge(verifier), 'method': 'S256'}
138
+
139
+
140
+ @dataclass(frozen=True)
141
+ class AuthorizationRequest:
142
+ """A started sign-in on the Superroute sign-in page."""
143
+
144
+ url: str
145
+ verifier: str
146
+ state: str
147
+ redirect_uri: str
148
+
149
+
150
+ # ------------------------------------------------------------------------------------------------ requests
151
+
152
+
153
+ def _text(value: Any) -> str:
154
+ if value is None:
155
+ return ''
156
+ if isinstance(value, bool):
157
+ return '1' if value else '0'
158
+ if isinstance(value, float) and value.is_integer() and abs(value) < 1e15:
159
+ return str(int(value))
160
+ if isinstance(value, (dict, list)):
161
+ return json.dumps(value, separators=(',', ':'))
162
+ return str(value)
163
+
164
+
165
+ def _build_url(base_url: str, operation: Operation, params: Mapping[str, Any]) -> str:
166
+ def segment(m: 're.Match[str]') -> str:
167
+ value = params.get(m.group(1))
168
+ text = _text(value)
169
+ return '' if text == '' else urllib.parse.quote(text, safe='')
170
+
171
+ path = re.sub(r'\{([^}]+)\}', segment, operation.path)
172
+ path = re.sub(r'/{2,}', '/', path)
173
+ path = path.rstrip('/') or '/'
174
+ query: List[Tuple[str, str]] = []
175
+ for name in operation.query:
176
+ value = params.get(name)
177
+ if value is None:
178
+ continue
179
+ if isinstance(value, (list, tuple)):
180
+ query.extend((name, _text(v)) for v in value)
181
+ else:
182
+ query.append((name, _text(value)))
183
+ url = base_url + path
184
+ if query:
185
+ url += '?' + urllib.parse.urlencode(query, quote_via=urllib.parse.quote)
186
+ return url
187
+
188
+
189
+ def _encode_body(kind: Optional[str], body: Optional[Mapping[str, Any]], files: Optional[Sequence[OperationFile]]) -> Tuple[Optional[bytes], Dict[str, str]]:
190
+ if kind is None or (body is None and not files):
191
+ return None, {}
192
+ body = body or {}
193
+ if kind == 'multipart':
194
+ boundary = 'sapp-' + uuid.uuid4().hex
195
+ parts: List[bytes] = []
196
+
197
+ def part(name: str, content: bytes, filename: Optional[str] = None, content_type: Optional[str] = None) -> None:
198
+ disposition = f'form-data; name="{name}"' + (f'; filename="{filename}"' if filename is not None else '')
199
+ head = f'--{boundary}\r\nContent-Disposition: {disposition}\r\n'
200
+ if content_type:
201
+ head += f'Content-Type: {content_type}\r\n'
202
+ parts.append(head.encode('utf-8') + b'\r\n' + content + b'\r\n')
203
+
204
+ for key, value in body.items():
205
+ if value is None:
206
+ continue
207
+ if isinstance(value, (list, tuple)):
208
+ for item in value:
209
+ part(f'{key}[]', _text(item).encode('utf-8'))
210
+ else:
211
+ part(key, _text(value).encode('utf-8'))
212
+ for f in files or ():
213
+ part(f.field, f.content, f.filename, f.content_type)
214
+ return b''.join(parts) + f'--{boundary}--\r\n'.encode('ascii'), {'Content-Type': f'multipart/form-data; boundary={boundary}'}
215
+ if kind == 'form':
216
+ fields = [(k, _text(v)) for k, v in body.items() if v is not None]
217
+ return urllib.parse.urlencode(fields).encode('ascii'), {'Content-Type': 'application/x-www-form-urlencoded'}
218
+ return json.dumps(body, separators=(',', ':')).encode('utf-8'), {'Content-Type': 'application/json'}
219
+
220
+
221
+ def urllib_transport(method: str, url: str, headers: Dict[str, str], body: Optional[bytes]) -> Tuple[int, Mapping[str, str], bytes]:
222
+ """The default transport: urllib.request. 4xx / 5xx answers are returned, not raised."""
223
+ if body is None and method in ('POST', 'PUT', 'PATCH'):
224
+ body = b''
225
+ request = urllib.request.Request(url, data=body, headers=headers, method=method)
226
+ try:
227
+ with urllib.request.urlopen(request, timeout=60) as response:
228
+ return response.status, dict(response.headers.items()), response.read()
229
+ except urllib.error.HTTPError as e:
230
+ return e.code, dict(e.headers.items()) if e.headers else {}, e.read() or b''
231
+
232
+
233
+ def _header(headers: Mapping[str, str], name: str) -> Optional[str]:
234
+ lower = name.lower()
235
+ for k, v in headers.items():
236
+ if k.lower() == lower:
237
+ return v
238
+ return None
239
+
240
+
241
+ def _read_body(raw: bytes) -> Any:
242
+ if not raw:
243
+ return None
244
+ text = raw.decode('utf-8', errors='replace')
245
+ try:
246
+ return json.loads(text)
247
+ except ValueError:
248
+ return text
249
+
250
+
251
+ @dataclass
252
+ class _Answer:
253
+ status: int
254
+ headers: Mapping[str, str]
255
+ body: Any
256
+
257
+ def or_raise(self) -> Any:
258
+ if self.status >= 400:
259
+ raise _error_from(self.status, self.body, _header(self.headers, 'X-Request-Id'))
260
+ return self.body
261
+
262
+
263
+ def _send(transport: Transport, sleep: Callable[[float], None], method: str, url: str, headers: Dict[str, str],
264
+ body: Optional[bytes], retries: int, retryable: bool) -> _Answer:
265
+ """Repeatable calls are retried on a lost connection, 502–504, and 429 with Retry-After. Writes never are."""
266
+ attempt = 0
267
+ while True:
268
+ try:
269
+ status, response_headers, raw = transport(method, url, headers, body)
270
+ except OSError as e:
271
+ if retryable and attempt < retries:
272
+ sleep(0.3 * 2 ** attempt)
273
+ attempt += 1
274
+ continue
275
+ raise SuperrouteError(0, 'network_error', 'The request could not reach Superroute.') from e
276
+ retry_after = _header(response_headers, 'Retry-After')
277
+ throttled = status == 429 and retry_after is not None
278
+ if retryable and attempt < retries and (status in (502, 503, 504) or throttled):
279
+ if throttled:
280
+ try:
281
+ wait = min(30.0, float(retry_after) or 1.0)
282
+ except ValueError:
283
+ wait = 1.0
284
+ else:
285
+ wait = 0.3 * 2 ** attempt
286
+ sleep(wait)
287
+ attempt += 1
288
+ continue
289
+ return _Answer(status, response_headers, _read_body(raw))
290
+
291
+
292
+ # ------------------------------------------------------------------------------------------------ partner client
293
+
294
+
295
+ class _Refresh:
296
+ def __init__(self) -> None:
297
+ self.done = threading.Event()
298
+ self.tokens: Optional[Tokens] = None
299
+ self.error: Optional[BaseException] = None
300
+
301
+
302
+ class PartnerClient:
303
+ """The partner profile: an app registered on the Open Platform console, acting for one person at one service
304
+ provider.
305
+
306
+ sr = PartnerClient('p_acme_parcels', client_code='k3m9q2', app_key='sp_appkey_…')
307
+ sr.sign_in_with_id_token(google_id_token)
308
+ wallet = sr.api.platform_app_wallet()
309
+ """
310
+
311
+ def __init__(self, app_code: str, client_code: Optional[str] = None, app_key: Optional[str] = None,
312
+ token_store: Optional[TokenStore] = None, base_url: str = DEFAULT_BASE_URL, locale: Optional[str] = None,
313
+ device_id: Optional[str] = None, retries: int = 2, transport: Optional[Transport] = None,
314
+ now: Optional[Callable[[], int]] = None, on_signed_out: Optional[Callable[[], None]] = None,
315
+ sleep: Optional[Callable[[float], None]] = None) -> None:
316
+ if not app_code:
317
+ raise ValueError('app_code is required')
318
+ self.app_code = app_code
319
+ self.client_code = client_code
320
+ self.app_key = app_key
321
+ self.token_store: TokenStore = token_store if token_store is not None else MemoryTokenStore()
322
+ self.base_url = base_url.rstrip('/')
323
+ self.locale = locale
324
+ self.device_id = device_id
325
+ self.retries = retries
326
+ self._transport: Transport = transport or urllib_transport
327
+ self._now = now or (lambda: int(time.time() * 1000))
328
+ self._sleep = sleep or time.sleep
329
+ self._on_signed_out = on_signed_out
330
+ self._lock = threading.Lock()
331
+ self._refreshing: Optional[_Refresh] = None
332
+ self._listeners: List[Callable[[bool], None]] = []
333
+ #: One method per operation of the partner surface.
334
+ self.api = PartnerApi(self._api_call)
335
+
336
+ # -- session ---------------------------------------------------------------------------------------------------
337
+
338
+ def on_session_change(self, listener: Callable[[bool], None]) -> Callable[[], None]:
339
+ """Called with True when a sign-in starts a session and False when it ends. Answers the unsubscribe function."""
340
+ self._listeners.append(listener)
341
+
342
+ def unsubscribe() -> None:
343
+ if listener in self._listeners:
344
+ self._listeners.remove(listener)
345
+ return unsubscribe
346
+
347
+ def _emit(self, signed_in: bool) -> None:
348
+ for listener in list(self._listeners):
349
+ try:
350
+ listener(signed_in)
351
+ except Exception: # noqa: BLE001 - a listener's error never breaks the session
352
+ pass
353
+
354
+ def set_client_code(self, code: Optional[str]) -> None:
355
+ """Change the service provider, e.g. after the person picked one at sign-up. Sign in again afterwards."""
356
+ self.client_code = code
357
+
358
+ def set_locale(self, locale: Optional[str]) -> None:
359
+ self.locale = locale
360
+
361
+ def _base_headers(self) -> Dict[str, str]:
362
+ headers = {'Accept': 'application/json', 'X-SApp': f'python/{SDK_VERSION}'}
363
+ if self.locale:
364
+ headers['Accept-Language'] = self.locale
365
+ return headers
366
+
367
+ def _require_client_code(self) -> str:
368
+ if not self.client_code:
369
+ raise ValueError('client_code is required to sign in')
370
+ return self.client_code
371
+
372
+ def _form_post(self, path: str, fields: Mapping[str, Optional[str]]) -> _Answer:
373
+ body = urllib.parse.urlencode([(k, v) for k, v in fields.items() if v not in (None, '')]).encode('ascii')
374
+ headers = dict(self._base_headers(), **{'Content-Type': 'application/x-www-form-urlencoded'})
375
+ return _send(self._transport, self._sleep, 'POST', self.base_url + path, headers, body, 0, False)
376
+
377
+ def _token_request(self, fields: Mapping[str, Optional[str]]) -> Tokens:
378
+ answer = self._form_post('/app/oauth/token', dict({'client_id': self.app_code}, **fields)).or_raise()
379
+ tokens = _tokens_from(answer, self._now())
380
+ was_signed_in = self.token_store.get() is not None
381
+ self.token_store.set(tokens)
382
+ if not was_signed_in:
383
+ self._emit(True)
384
+ return tokens
385
+
386
+ def _end_session(self) -> None:
387
+ self.token_store.clear()
388
+ if self._on_signed_out:
389
+ self._on_signed_out()
390
+ self._emit(False)
391
+
392
+ def authorization_request(self, redirect_uri: str, scope: Sequence[str] = (), language: Optional[str] = None,
393
+ state: Optional[str] = None) -> AuthorizationRequest:
394
+ """The Superroute sign-in page (method 1): open ``url`` in the browser, then pass the URL it returned to
395
+ :meth:`complete_sign_in`."""
396
+ pkce = create_pkce()
397
+ s = state or _base64url(secrets.token_bytes(16))
398
+ query = {
399
+ 'response_type': 'code', 'client_id': self.app_code, 'client_code': self._require_client_code(),
400
+ 'redirect_uri': redirect_uri, 'state': s, 'code_challenge': pkce['challenge'], 'code_challenge_method': 'S256',
401
+ 'scope': ' '.join(scope), 'language': language or self.locale,
402
+ }
403
+ url = self.base_url + '/app/oauth/authorize?' + urllib.parse.urlencode([(k, v) for k, v in query.items() if v], quote_via=urllib.parse.quote)
404
+ return AuthorizationRequest(url, pkce['verifier'], s, redirect_uri)
405
+
406
+ def complete_sign_in(self, request: AuthorizationRequest, returned_url: str) -> Tokens:
407
+ """Finishes method 1 with the URL the sign-in page returned to. Raises when it does not belong to the request."""
408
+ answer = dict(urllib.parse.parse_qsl(urllib.parse.urlsplit(returned_url).query, keep_blank_values=True))
409
+ if answer.get('state') != request.state:
410
+ raise SuperrouteError(400, 'invalid_state', 'The sign-in answer does not belong to this request.')
411
+ if answer.get('error'):
412
+ raise SuperrouteError(400, answer['error'], answer.get('error_description'))
413
+ return self.exchange_code(answer.get('code', ''), request.redirect_uri, request.verifier)
414
+
415
+ def exchange_code(self, code: str, redirect_uri: str, verifier: str) -> Tokens:
416
+ """Finish method 1 with the redirect's code and the verifier from :meth:`authorization_request`."""
417
+ return self._token_request({'grant_type': 'authorization_code', 'code': code, 'redirect_uri': redirect_uri,
418
+ 'code_verifier': verifier, 'device_id': self.device_id})
419
+
420
+ def sign_in_with_id_token(self, id_token: str, scope: Sequence[str] = ()) -> Tokens:
421
+ """Method 2: a Google or Apple ID token issued for one of the app's registered audiences."""
422
+ return self._token_request({'grant_type': _TOKEN_EXCHANGE, 'client_code': self._require_client_code(),
423
+ 'subject_token': id_token, 'subject_token_type': _ID_TOKEN, 'scope': ' '.join(scope),
424
+ 'device_id': self.device_id})
425
+
426
+ def sign_in_with_password(self, username: str, password: str, scope: Sequence[str] = ()) -> Tokens:
427
+ """Method 3: email and password in the app's own screens, for apps approved for it."""
428
+ return self._token_request({'grant_type': 'password', 'client_code': self._require_client_code(),
429
+ 'username': username, 'password': password, 'scope': ' '.join(scope),
430
+ 'device_id': self.device_id})
431
+
432
+ def refresh(self) -> Tokens:
433
+ """One refresh at a time: concurrent calls wait for the same one (a refresh token works once)."""
434
+ with self._lock:
435
+ running = self._refreshing
436
+ owner = running is None
437
+ if owner:
438
+ running = self._refreshing = _Refresh()
439
+ assert running is not None
440
+ if not owner:
441
+ running.done.wait()
442
+ if running.error is not None:
443
+ raise running.error
444
+ assert running.tokens is not None
445
+ return running.tokens
446
+ try:
447
+ tokens = self.token_store.get()
448
+ if tokens is None or not tokens.refresh_token:
449
+ self._end_session()
450
+ raise SuperrouteError(401, 'invalid_grant', 'Not signed in.')
451
+ try:
452
+ running.tokens = self._token_request({'grant_type': 'refresh_token', 'refresh_token': tokens.refresh_token,
453
+ 'device_id': self.device_id})
454
+ except SuperrouteError as e:
455
+ if e.code == 'invalid_grant' or e.status == 401:
456
+ self._end_session()
457
+ raise
458
+ return running.tokens
459
+ except BaseException as e:
460
+ running.error = e
461
+ raise
462
+ finally:
463
+ with self._lock:
464
+ self._refreshing = None
465
+ running.done.set()
466
+
467
+ def _access_token(self) -> Optional[str]:
468
+ tokens = self.token_store.get()
469
+ if tokens is None:
470
+ return None
471
+ if tokens.refresh_token and tokens.expires_at - _REFRESH_MARGIN_MS <= self._now():
472
+ return self.refresh().access_token
473
+ return tokens.access_token
474
+
475
+ def sign_out(self) -> None:
476
+ """Ends this sign-in on the server and forgets the tokens."""
477
+ tokens = self.token_store.get()
478
+ if tokens is not None and tokens.refresh_token:
479
+ try:
480
+ self._form_post('/app/oauth/revoke', {'token': tokens.refresh_token, 'client_id': self.app_code})
481
+ except Exception: # noqa: BLE001 - signing out locally never waits on the network
482
+ pass
483
+ self._end_session()
484
+
485
+ def is_signed_in(self) -> bool:
486
+ return self.token_store.get() is not None
487
+
488
+ # -- calls -----------------------------------------------------------------------------------------------------
489
+
490
+ def _headers(self, operation: Operation) -> Dict[str, str]:
491
+ headers = self._base_headers()
492
+ if operation.auth in ('token', 'token_or_key'):
493
+ token = self._access_token()
494
+ if token:
495
+ headers['Authorization'] = f'Bearer {token}'
496
+ return headers
497
+ if operation.auth == 'token':
498
+ raise SuperrouteError(401, 'invalid_token', 'Not signed in.')
499
+ if operation.auth in ('key', 'token_or_key') and self.app_key:
500
+ headers['X-App-Key'] = self.app_key
501
+ return headers
502
+
503
+ def _call(self, operation: Operation, params: Mapping[str, Any], body: Optional[Mapping[str, Any]],
504
+ files: Optional[Sequence[OperationFile]]) -> Any:
505
+ url = _build_url(self.base_url, operation, params)
506
+ payload, extra = _encode_body(operation.body, body, files)
507
+ retryable = operation.method == 'GET'
508
+ headers = dict(self._headers(operation), **extra)
509
+ answer = _send(self._transport, self._sleep, operation.method, url, headers, payload, self.retries, retryable)
510
+ # An access token can end early (revoked, the person's access changed): refresh once and try again.
511
+ if answer.status == 401 and 'Authorization' in headers:
512
+ self.refresh()
513
+ headers = dict(self._headers(operation), **extra)
514
+ answer = _send(self._transport, self._sleep, operation.method, url, headers, payload, self.retries, retryable)
515
+ return answer.or_raise()
516
+
517
+ def _api_call(self, operation: Operation, params: Mapping[str, Any], body: Optional[Mapping[str, Any]],
518
+ files: Optional[Sequence[OperationFile]]) -> Any:
519
+ values = dict(params)
520
+ # Public calls name the service provider in the path; take it from the client unless the caller passes one.
521
+ if '{clientCode}' in operation.path and values.get('clientCode') is None:
522
+ values['clientCode'] = self.client_code
523
+ return self._call(operation, values, body, files)
524
+
525
+ def graphql(self, query: str, variables: Optional[Mapping[str, Any]] = None) -> Any:
526
+ """A partner GraphQL operation (open.superroute.ca/app/graphql), with the token when signed in, else the key."""
527
+ operation = Operation('graphql', 'graphql', 'POST', '/app/graphql', 'token_or_key', (), 'json')
528
+ answer = self._call(operation, {}, {'query': query, 'variables': dict(variables or {})}, None)
529
+ errors = answer.get('errors') if isinstance(answer, dict) else None
530
+ if isinstance(errors, list) and errors:
531
+ raise SuperrouteError(200, 'graphql_error', ' '.join(str(e.get('message', '')) if isinstance(e, dict) else str(e) for e in errors), answer)
532
+ return answer.get('data') if isinstance(answer, dict) else None
533
+
534
+
535
+ # ------------------------------------------------------------------------------------------------ open client
536
+
537
+
538
+ class OpenClient:
539
+ """The open profile: Open Platform capabilities (routing, geocoding, ETA, packing, …) at open.superroute.ca/v1.
540
+ Pass ``get_token``, answered by your backend with a short-lived token, or ``api_key`` on a server."""
541
+
542
+ def __init__(self, get_token: Optional[Callable[[], str]] = None, api_key: Optional[str] = None,
543
+ base_url: str = DEFAULT_BASE_URL, locale: Optional[str] = None, retries: int = 2,
544
+ transport: Optional[Transport] = None, sleep: Optional[Callable[[float], None]] = None) -> None:
545
+ if (get_token is None) == (api_key is None):
546
+ raise ValueError('pass exactly one of get_token or api_key')
547
+ self._get_token = get_token
548
+ self._api_key = api_key
549
+ self.base_url = base_url.rstrip('/')
550
+ self.locale = locale
551
+ self.retries = retries
552
+ self._transport: Transport = transport or urllib_transport
553
+ self._sleep = sleep or time.sleep
554
+ #: One method per Open Platform operation.
555
+ self.api = OpenApi(self._call)
556
+
557
+ def _call(self, operation: Operation, params: Mapping[str, Any], body: Optional[Mapping[str, Any]],
558
+ files: Optional[Sequence[OperationFile]]) -> Any:
559
+ token = self._get_token() if self._get_token is not None else self._api_key
560
+ payload, extra = _encode_body(operation.body, body, files)
561
+ headers = {'Accept': 'application/json', 'Authorization': f'Bearer {token}', 'X-SApp': f'python/{SDK_VERSION}'}
562
+ if self.locale:
563
+ headers['Accept-Language'] = self.locale
564
+ headers.update(extra)
565
+ url = _build_url(self.base_url, operation, params)
566
+ return _send(self._transport, self._sleep, operation.method, url, headers, payload, self.retries,
567
+ operation.method == 'GET').or_raise()
568
+
569
+
570
+ # ------------------------------------------------------------------------------------------------ theme
571
+
572
+
573
+ def _channel(hex_: str, i: int) -> int:
574
+ return int(hex_[1 + i * 2:3 + i * 2], 16)
575
+
576
+
577
+ def _luminance(hex_: str) -> float:
578
+ def lin(i: int) -> float:
579
+ c = _channel(hex_, i) / 255
580
+ return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4
581
+ return 0.2126 * lin(0) + 0.7152 * lin(1) + 0.0722 * lin(2)
582
+
583
+
584
+ def _mix(hex_: str, target: int, amount: float) -> str:
585
+ def js_round(x: float) -> int:
586
+ return int(x + 0.5) if x >= 0 else -int(-x + 0.5)
587
+ return '#' + ''.join(f'{js_round(_channel(hex_, i) + (target - _channel(hex_, i)) * amount):02X}' for i in range(3))
588
+
589
+
590
+ def theme_from_branding(branding: Optional[Mapping[str, Any]] = None) -> Dict[str, Any]:
591
+ """Theme tokens from the service provider's branding (the bootstrap's ``client.settings.branding``)."""
592
+ branding = branding or {}
593
+ raw = branding.get('primary_color') or ''
594
+ primary = raw.upper() if isinstance(raw, str) and re.fullmatch(r'#[0-9a-fA-F]{6}', raw) else '#E02020'
595
+ return {
596
+ 'displayName': branding.get('display_name') or None,
597
+ 'logoUrl': branding.get('logo_url') or None,
598
+ 'color': {
599
+ 'primary': primary,
600
+ 'onPrimary': '#111111' if _luminance(primary) > 0.4 else '#FFFFFF',
601
+ 'primaryContainer': _mix(primary, 255, 0.85),
602
+ 'onPrimaryContainer': _mix(primary, 0, 0.55),
603
+ 'surface': '#FFFFFF',
604
+ 'onSurface': '#1C1C1E',
605
+ 'muted': '#6B7280',
606
+ 'success': '#16A34A',
607
+ 'warning': '#FD7E14',
608
+ 'danger': '#DC2626',
609
+ },
610
+ 'radius': {'small': 6, 'medium': 10, 'large': 16},
611
+ 'spacing': {'xs': 4, 's': 8, 'm': 12, 'l': 16, 'xl': 24},
612
+ 'type': {'body': 15, 'small': 13, 'title': 20, 'headline': 26},
613
+ }
@@ -0,0 +1 @@
1
+ """Generated API clients."""