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 +25 -0
- sapp/_operation.py +28 -0
- sapp/core.py +613 -0
- sapp/generated/__init__.py +1 -0
- sapp/generated/open_api.py +272 -0
- sapp/generated/partner_api.py +1452 -0
- sapp/py.typed +0 -0
- sapp/webhooks.py +237 -0
- superroute_sapp-0.2.1.dist-info/METADATA +400 -0
- superroute_sapp-0.2.1.dist-info/RECORD +13 -0
- superroute_sapp-0.2.1.dist-info/WHEEL +5 -0
- superroute_sapp-0.2.1.dist-info/licenses/LICENSE +21 -0
- superroute_sapp-0.2.1.dist-info/top_level.txt +1 -0
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."""
|