andon-client 1.0.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.
- andon_client/__init__.py +25 -0
- andon_client/_accepts.py +222 -0
- andon_client/_env.py +73 -0
- andon_client/_js.py +191 -0
- andon_client/_relay.py +157 -0
- andon_client/_runtime.py +409 -0
- andon_client/_seal.py +110 -0
- andon_client/_tiles.py +419 -0
- andon_client/_validate.py +616 -0
- andon_client/_view.py +252 -0
- andon_client/_words.py +51 -0
- andon_client/py.typed +0 -0
- andon_client/schema/view-v1.schema.json +732 -0
- andon_client-1.0.0.dist-info/METADATA +195 -0
- andon_client-1.0.0.dist-info/RECORD +17 -0
- andon_client-1.0.0.dist-info/WHEEL +4 -0
- andon_client-1.0.0.dist-info/licenses/LICENSE +21 -0
andon_client/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""
|
|
2
|
+
andon-client: send values from Python to the Andon app for iPhone and iPad.
|
|
3
|
+
|
|
4
|
+
What is exported here is the public API, covered by semantic versioning;
|
|
5
|
+
everything in the modules whose names start with an underscore is internal.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from ._relay import PutResult
|
|
9
|
+
from ._runtime import ReceiveResult, UploadReport, ValidationError
|
|
10
|
+
from ._tiles import InputError
|
|
11
|
+
from ._validate import Issue, Validation, validate
|
|
12
|
+
from ._view import AndonView, UploadFailure
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
'AndonView',
|
|
16
|
+
'InputError',
|
|
17
|
+
'Issue',
|
|
18
|
+
'PutResult',
|
|
19
|
+
'ReceiveResult',
|
|
20
|
+
'UploadFailure',
|
|
21
|
+
'UploadReport',
|
|
22
|
+
'Validation',
|
|
23
|
+
'ValidationError',
|
|
24
|
+
'validate',
|
|
25
|
+
]
|
andon_client/_accepts.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""
|
|
2
|
+
What a tile takes, read off the tile itself: its kind, whether it has
|
|
3
|
+
categories, how many series or lanes it has, and which form its first series
|
|
4
|
+
uses - a series in the step form (start, stepSec, points) takes no single number,
|
|
5
|
+
because there is no timestamp to append it at.
|
|
6
|
+
|
|
7
|
+
The hint a refused value gets, the same sentence as in the TypeScript package.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
import time
|
|
14
|
+
from typing import Any, Dict, List, Mapping, NamedTuple, Optional
|
|
15
|
+
|
|
16
|
+
from ._js import is_number, iso
|
|
17
|
+
from ._words import API_WORDS, Words
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Form(NamedTuple):
|
|
21
|
+
key: str
|
|
22
|
+
"""names the wording"""
|
|
23
|
+
params: Dict[str, Any]
|
|
24
|
+
"""what the wording needs (count, states)"""
|
|
25
|
+
example: Any
|
|
26
|
+
"""a value to show"""
|
|
27
|
+
extra: Dict[str, Any]
|
|
28
|
+
"""further parts of the update the form needs, by name: timestamp, series, lane"""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _text(w: Words) -> Dict[str, Dict[str, str]]:
|
|
32
|
+
return {
|
|
33
|
+
'text': {'payload': 'a text or a number', 'effect': 'replaces the value'},
|
|
34
|
+
'number': {'payload': 'a number', 'effect': 'replaces the value'},
|
|
35
|
+
'append': {'payload': 'a number', 'effect': 'is appended to the series, at the time it arrives'},
|
|
36
|
+
'appendAt': {'payload': 'a number with ' + w.timestamp, 'effect': 'is appended at that time, in milliseconds or ISO 8601'},
|
|
37
|
+
'appendTo': {'payload': 'a number with ' + w.series, 'effect': 'is appended to the series of that name'},
|
|
38
|
+
'pairs': {'payload': 'an array of [timestamp, value] pairs', 'effect': 'replaces the series; ' + w.series + ' picks which'},
|
|
39
|
+
'samples': {'payload': 'an object {series: [{name, samples}]}', 'effect': 'replaces every series'},
|
|
40
|
+
'steps': {'payload': 'an object {series: [{name, start, stepSec, points}]}', 'effect': 'replaces every series: a point every stepSec from start, in UTC'},
|
|
41
|
+
'values': {'payload': 'an array of numbers, one per category', 'effect': 'replaces the values; categories: __count__'},
|
|
42
|
+
'valuesTo': {'payload': 'an array of numbers with ' + w.series, 'effect': 'replaces the values of the series of that name'},
|
|
43
|
+
'categories': {'payload': 'an object {categories, series}', 'effect': 'replaces the categories and their values together'},
|
|
44
|
+
'statuses': {'payload': 'an object {series: [{name, values, statuses}]}', 'effect': 'a verdict per bar: ok, warning, critical or null'},
|
|
45
|
+
'rows': {'payload': 'an array of rows, each an array of cells', 'effect': 'replaces the rows; columns: __count__'},
|
|
46
|
+
'rowStatus': {'payload': 'an array of {cells, status}', 'effect': 'replaces the rows, with a status per row'},
|
|
47
|
+
'state': {'payload': 'a state ID (__states__)', 'effect': 'starts a new segment, at the time it arrives'},
|
|
48
|
+
'stateAt': {'payload': 'a state ID with ' + w.timestamp, 'effect': 'starts the segment at that time'},
|
|
49
|
+
'stateLane': {'payload': 'a state ID with ' + w.lane, 'effect': 'starts a segment in the lane of that name'},
|
|
50
|
+
'window': {'payload': 'an object {from, to}', 'effect': 'moves the time axis; what lies before from is trimmed'},
|
|
51
|
+
'fields': {'payload': 'an object of fields, e.g. {"status": "warning"}', 'effect': 'sets them; null removes one'},
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# The forms an error message names: what a value alone can be. Objects and forms
|
|
56
|
+
# that need a further part of the update are left to the documentation.
|
|
57
|
+
_IN_HINT = {'text', 'number', 'append', 'pairs', 'values', 'rows', 'state'}
|
|
58
|
+
|
|
59
|
+
_MINUTE = 60 * 1000
|
|
60
|
+
_HOUR = 60 * _MINUTE
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _form(key: str, example: Any, params: Optional[Dict[str, Any]] = None, extra: Optional[Dict[str, Any]] = None) -> Form:
|
|
64
|
+
return Form(key, params or {}, example, extra or {})
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _number_of(tile: Mapping[str, Any], fallback: float) -> Any:
|
|
68
|
+
return tile.get('value') if is_number(tile.get('value')) else fallback
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _series_of(tile: Mapping[str, Any]) -> List[Any]:
|
|
72
|
+
series = tile.get('series')
|
|
73
|
+
return [s for s in series if isinstance(s, dict) and isinstance(s.get('name'), str)] if isinstance(series, list) else []
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _values_of(series: Mapping[str, Any], fallback: Any) -> List[Any]:
|
|
77
|
+
"""The last few values a series holds, whatever its form: what the examples are made of."""
|
|
78
|
+
v: List[Any] = []
|
|
79
|
+
if isinstance(series.get('samples'), list):
|
|
80
|
+
v = [p[1] if isinstance(p, list) and len(p) > 1 else None for p in series['samples']]
|
|
81
|
+
elif isinstance(series.get('points'), list):
|
|
82
|
+
v = series['points']
|
|
83
|
+
v = [x for x in v if is_number(x)][-4:]
|
|
84
|
+
return v if v else [fallback, fallback + 1, fallback - 1]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _pairs_of(values: List[Any], now: float) -> List[Any]:
|
|
88
|
+
"""[timestamp, value] pairs one minute apart, the last one now."""
|
|
89
|
+
n = len(values) - 1
|
|
90
|
+
return [[iso(now - (n - i) * _MINUTE), v] for i, v in enumerate(values)]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _time_series_forms(tile: Mapping[str, Any], now: float) -> List[Form]:
|
|
94
|
+
every = _series_of(tile)
|
|
95
|
+
if not every:
|
|
96
|
+
return []
|
|
97
|
+
vals = [_values_of(s, _number_of(tile, 42)) for s in every]
|
|
98
|
+
last = vals[0][-1]
|
|
99
|
+
out: List[Form] = []
|
|
100
|
+
if not isinstance(every[0].get('points'), list):
|
|
101
|
+
out += [_form('append', last), _form('appendAt', last, {}, {'timestamp': {'example': iso(now)}})]
|
|
102
|
+
if len(every) > 1:
|
|
103
|
+
out.append(_form('appendTo', vals[1][-1], {}, {'series': {'example': every[1]['name']}}))
|
|
104
|
+
|
|
105
|
+
def step(s: Mapping[str, Any]) -> Any:
|
|
106
|
+
return s['stepSec'] if is_number(s.get('stepSec')) and s['stepSec'] > 0 else 3600
|
|
107
|
+
|
|
108
|
+
out += [
|
|
109
|
+
_form('pairs', _pairs_of(vals[0], now)),
|
|
110
|
+
_form('samples', {'series': [{'name': s['name'], 'samples': _pairs_of(vals[k], now)} for k, s in enumerate(every)]}),
|
|
111
|
+
# The step form with its last point at now.
|
|
112
|
+
_form('steps', {'series': [{'name': s['name'], 'start': iso(now - (len(vals[k]) - 1) * step(s) * 1000),
|
|
113
|
+
'stepSec': step(s), 'points': vals[k]} for k, s in enumerate(every)]}),
|
|
114
|
+
]
|
|
115
|
+
return out
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _category_forms(tile: Mapping[str, Any], kind: str) -> List[Form]:
|
|
119
|
+
count = len(tile['categories'])
|
|
120
|
+
every = _series_of(tile)
|
|
121
|
+
|
|
122
|
+
def values_for(s: Optional[Mapping[str, Any]]) -> List[Any]:
|
|
123
|
+
if s and isinstance(s.get('values'), list) and len(s['values']) == count:
|
|
124
|
+
return s['values']
|
|
125
|
+
return [i + 1 for i in range(count)]
|
|
126
|
+
|
|
127
|
+
out = [_form('values', values_for(every[0] if every else None), {'count': count})]
|
|
128
|
+
if len(every) > 1:
|
|
129
|
+
out.append(_form('valuesTo', values_for(every[1]), {}, {'series': {'example': every[1]['name']}}))
|
|
130
|
+
out.append(_form('categories', {
|
|
131
|
+
'categories': tile['categories'],
|
|
132
|
+
'series': [{'name': s['name'], 'values': values_for(s)} for s in (every or [{'name': 'A'}])],
|
|
133
|
+
}))
|
|
134
|
+
# A verdict per bar exists only on a bar with categories and exactly one series.
|
|
135
|
+
if kind == 'bar' and len(every) <= 1:
|
|
136
|
+
v = values_for(every[0] if every else None)
|
|
137
|
+
out.append(_form('statuses', {'series': [{'name': every[0]['name'] if every else 'A', 'values': v,
|
|
138
|
+
'statuses': ['critical' if i == len(v) - 1 else None for i in range(len(v))]}]}))
|
|
139
|
+
return out
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _table_forms(tile: Mapping[str, Any]) -> List[Form]:
|
|
143
|
+
columns = len(tile['columns']) if isinstance(tile.get('columns'), list) and tile['columns'] else 1
|
|
144
|
+
rows = tile.get('rows')
|
|
145
|
+
first = rows[0].get('cells') if isinstance(rows, list) and rows and isinstance(rows[0], dict) else None
|
|
146
|
+
row = first if isinstance(first, list) and len(first) == columns else ['A' if i == 0 else i for i in range(columns)]
|
|
147
|
+
return [_form('rows', [row], {'count': columns}), _form('rowStatus', [{'cells': row, 'status': 'warning'}])]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _timeline_forms(tile: Mapping[str, Any], now: float) -> List[Form]:
|
|
151
|
+
states = [s.get('id') for s in (tile.get('states') if isinstance(tile.get('states'), list) else [])
|
|
152
|
+
if isinstance(s, dict) and s.get('id')]
|
|
153
|
+
if not states:
|
|
154
|
+
return []
|
|
155
|
+
lanes = [lane for lane in (tile.get('lanes') if isinstance(tile.get('lanes'), list) else [])
|
|
156
|
+
if isinstance(lane, dict) and isinstance(lane.get('name'), str)]
|
|
157
|
+
out = [
|
|
158
|
+
_form('state', states[0], {'states': ', '.join(str(s) for s in states)}),
|
|
159
|
+
_form('stateAt', states[0], {}, {'timestamp': {'example': iso(now)}}),
|
|
160
|
+
]
|
|
161
|
+
if len(lanes) > 1:
|
|
162
|
+
out.append(_form('stateLane', states[0], {}, {'lane': {'example': lanes[1]['name']}}))
|
|
163
|
+
out.append(_form('window', {'from': iso(now - 8 * _HOUR), 'to': iso(now)}))
|
|
164
|
+
return out
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def forms(tile: Any, now: Optional[float] = None) -> List[Form]:
|
|
168
|
+
"""forms returns every form the tile takes, the most common first."""
|
|
169
|
+
if not isinstance(tile, dict):
|
|
170
|
+
return []
|
|
171
|
+
at = now if is_number(now) else time.time() * 1000
|
|
172
|
+
categories = isinstance(tile.get('categories'), list)
|
|
173
|
+
kind = tile.get('view')
|
|
174
|
+
if kind == 'label':
|
|
175
|
+
value = tile.get('value')
|
|
176
|
+
out = [_form('text', value if is_number(value) or isinstance(value, str) else 'Running')]
|
|
177
|
+
elif kind in ('gauge', 'progress'):
|
|
178
|
+
out = [_form('number', _number_of(tile, 0))]
|
|
179
|
+
elif kind == 'donut':
|
|
180
|
+
out = [_form('number', _number_of(tile, 0))] + (_category_forms(tile, 'donut') if categories else [])
|
|
181
|
+
elif kind == 'pie':
|
|
182
|
+
out = _category_forms(tile, 'pie') if categories else []
|
|
183
|
+
elif kind == 'bar':
|
|
184
|
+
out = _category_forms(tile, 'bar') if categories else _time_series_forms(tile, at)
|
|
185
|
+
elif kind == 'line':
|
|
186
|
+
out = _time_series_forms(tile, at)
|
|
187
|
+
elif kind == 'table':
|
|
188
|
+
out = _table_forms(tile)
|
|
189
|
+
elif kind == 'timeline':
|
|
190
|
+
out = _timeline_forms(tile, at)
|
|
191
|
+
else:
|
|
192
|
+
return []
|
|
193
|
+
if not out:
|
|
194
|
+
return []
|
|
195
|
+
out.append(_form('fields', {'status': 'warning'}))
|
|
196
|
+
return out
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _fill(s: str, params: Mapping[str, Any]) -> str:
|
|
200
|
+
return re.sub(r'__(\w+)__', lambda m: str(params[m.group(1)]) if m.group(1) in params else m.group(0), s)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def describe(f: Form, words: Words = API_WORDS) -> str:
|
|
204
|
+
"""describe is the English wording of one form: "a number (replaces the value)"."""
|
|
205
|
+
t = _text(words)[f.key]
|
|
206
|
+
return _fill(t['payload'], f.params) + ' (' + _fill(t['effect'], f.params) + ')'
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def hint(tile: Any, words: Words = API_WORDS) -> str:
|
|
210
|
+
"""
|
|
211
|
+
hint is one English sentence for an error message: what a value for the tile
|
|
212
|
+
can be. A refused value is the moment someone needs it. Objects and forms that
|
|
213
|
+
need a further part of the update are left out.
|
|
214
|
+
"""
|
|
215
|
+
if not tile:
|
|
216
|
+
return ''
|
|
217
|
+
every = [describe(f, words) for f in forms(tile) if f.key in _IN_HINT]
|
|
218
|
+
if not every:
|
|
219
|
+
return ''
|
|
220
|
+
kind = str(tile.get('view')) + (' with categories' if isinstance(tile.get('categories'), list) else '')
|
|
221
|
+
last = every.pop()
|
|
222
|
+
return 'A ' + kind + ' takes ' + (', '.join(every) + ' or ' if every else '') + last
|
andon_client/_env.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The view's values from the environment: the names the .env from the Andon
|
|
3
|
+
configurator uses, so that file can be handed to a process unedited:
|
|
4
|
+
|
|
5
|
+
ANDON_URL ANDON_VIEW ANDON_WRITE_SECRET ANDON_CONTENT_KEY ANDON_KEY_VERSION
|
|
6
|
+
|
|
7
|
+
A prefix replaces the leading ANDON_ of each: with ANDON_CLOUD_, the view ID is
|
|
8
|
+
read from ANDON_CLOUD_VIEW. That is what lets several views share one
|
|
9
|
+
environment - one prefix per view.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
from typing import Dict, Mapping, Optional
|
|
17
|
+
|
|
18
|
+
from ._js import trim
|
|
19
|
+
|
|
20
|
+
# Upper case, ending in an underscore, so that ANDON_CLOUD_ + VIEW reads like a
|
|
21
|
+
# variable name.
|
|
22
|
+
PREFIX = re.compile(r'[A-Z][A-Z0-9_]*_')
|
|
23
|
+
|
|
24
|
+
SUFFIXES = ('URL', 'VIEW', 'WRITE_SECRET', 'CONTENT_KEY', 'KEY_VERSION')
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def name(prefix: Optional[str], suffix: str) -> str:
|
|
28
|
+
"""
|
|
29
|
+
name is the variable a suffix is read from. Every message about a missing value
|
|
30
|
+
has to go through here, otherwise it names a variable nobody set.
|
|
31
|
+
"""
|
|
32
|
+
return (prefix or 'ANDON_') + suffix
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def pick(prefix: Optional[str], suffix: str, env: Optional[Mapping[str, str]] = None) -> str:
|
|
36
|
+
"""pick reads one value, trimmed; the empty string counts as unset."""
|
|
37
|
+
return trim((env if env is not None else os.environ).get(name(prefix, suffix)) or '')
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def check_prefix(prefix: Optional[str]) -> str:
|
|
41
|
+
"""
|
|
42
|
+
check_prefix raises on a prefix that is not one. An empty prefix is not an
|
|
43
|
+
error: it means the default, ANDON_.
|
|
44
|
+
"""
|
|
45
|
+
if not prefix:
|
|
46
|
+
return ''
|
|
47
|
+
if not PREFIX.fullmatch(prefix):
|
|
48
|
+
raise ValueError('environment prefix "' + prefix + '" has to be upper case and end in an '
|
|
49
|
+
'underscore, for example ANDON_ or ANDON_CLOUD_')
|
|
50
|
+
return prefix
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def read_file(path: 'os.PathLike[str] | str') -> Dict[str, str]:
|
|
54
|
+
"""
|
|
55
|
+
read_file reads a .env as the configurator writes it: NAME=value lines, # for a
|
|
56
|
+
comment, a value in quotes taken without them, a leading `export ` allowed.
|
|
57
|
+
"""
|
|
58
|
+
out: Dict[str, str] = {}
|
|
59
|
+
with open(path, encoding='utf-8-sig') as f:
|
|
60
|
+
for line in f:
|
|
61
|
+
line = trim(line)
|
|
62
|
+
if not line or line.startswith('#'):
|
|
63
|
+
continue
|
|
64
|
+
if line.startswith('export '):
|
|
65
|
+
line = trim(line[len('export '):])
|
|
66
|
+
name, eq, value = line.partition('=')
|
|
67
|
+
if not eq or not trim(name):
|
|
68
|
+
continue
|
|
69
|
+
value = trim(value)
|
|
70
|
+
if len(value) >= 2 and value[0] in ('"', "'") and value[-1] == value[0]:
|
|
71
|
+
value = value[1:-1]
|
|
72
|
+
out[trim(name)] = value
|
|
73
|
+
return out
|
andon_client/_js.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The few places where this package has to behave like JavaScript, because the
|
|
3
|
+
TypeScript package @smartrsolutions/andon-client is its twin: both read the same
|
|
4
|
+
template the same way, refuse the same values with the same words and seal the
|
|
5
|
+
same document. Each helper names the JavaScript it stands in for.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import datetime as _dt
|
|
11
|
+
import json
|
|
12
|
+
import math
|
|
13
|
+
import re
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
# What String.prototype.trim() removes: JavaScript's white space and line
|
|
17
|
+
# terminators. str.strip() removes a different set (it keeps U+FEFF and takes
|
|
18
|
+
# U+001C to U+001F and U+0085), and MQTT payloads do arrive with a BOM.
|
|
19
|
+
_WS = '\t\n\v\f\r \u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a' \
|
|
20
|
+
'\u2028\u2029\u202f\u205f\u3000\ufeff'
|
|
21
|
+
WS_CLASS = '[' + re.escape(_WS) + ']'
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def trim(text: str) -> str:
|
|
25
|
+
"""trim is String.prototype.trim()."""
|
|
26
|
+
return text.strip(_WS)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def is_number(value: Any) -> bool:
|
|
30
|
+
"""is_number is typeof value === 'number': bool is an int in Python, not here."""
|
|
31
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def is_finite_number(value: Any) -> bool:
|
|
35
|
+
"""is_finite_number is typeof value === 'number' && Number.isFinite(value)."""
|
|
36
|
+
return is_number(value) and math.isfinite(value)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def utf16_len(text: str) -> int:
|
|
40
|
+
"""utf16_len is String.prototype.length: UTF-16 code units, not code points."""
|
|
41
|
+
return len(text.encode('utf-16-le')) // 2
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def utf16_key(text: str) -> bytes:
|
|
45
|
+
"""utf16_key sorts strings by UTF-16 code unit, as sort() without a function does."""
|
|
46
|
+
return text.encode('utf-16-be', 'surrogatepass')
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def number_text(x: Any) -> str:
|
|
50
|
+
"""number_text is String(x) for a number: the shortest digits, in JavaScript's notation."""
|
|
51
|
+
if isinstance(x, bool):
|
|
52
|
+
return 'true' if x else 'false'
|
|
53
|
+
if isinstance(x, int):
|
|
54
|
+
return str(x)
|
|
55
|
+
if math.isnan(x):
|
|
56
|
+
return 'NaN'
|
|
57
|
+
if math.isinf(x):
|
|
58
|
+
return 'Infinity' if x > 0 else '-Infinity'
|
|
59
|
+
if x == 0:
|
|
60
|
+
return '0'
|
|
61
|
+
sign = '-' if x < 0 else ''
|
|
62
|
+
# repr gives the shortest digits that read back as x, as JavaScript does; only
|
|
63
|
+
# where the point goes and when an exponent is written differ.
|
|
64
|
+
r = repr(abs(x))
|
|
65
|
+
mantissa, _, exp = r.partition('e')
|
|
66
|
+
whole, _, frac = mantissa.partition('.')
|
|
67
|
+
e = int(exp) if exp else 0
|
|
68
|
+
digits = (whole + frac).lstrip('0')
|
|
69
|
+
n = len(whole) + e if whole.strip('0') else e - (len(frac) - len(frac.lstrip('0')))
|
|
70
|
+
digits = digits.rstrip('0')
|
|
71
|
+
k = len(digits)
|
|
72
|
+
if k <= n <= 21:
|
|
73
|
+
return sign + digits + '0' * (n - k)
|
|
74
|
+
if 0 < n <= 21:
|
|
75
|
+
return sign + digits[:n] + '.' + digits[n:]
|
|
76
|
+
if -6 < n <= 0:
|
|
77
|
+
return sign + '0.' + '0' * (-n) + digits
|
|
78
|
+
e = n - 1
|
|
79
|
+
return sign + digits[0] + ('.' + digits[1:] if k > 1 else '') + 'e' + ('+' if e >= 0 else '-') + str(abs(e))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def to_string(value: Any) -> str:
|
|
83
|
+
"""to_string is String(value) for what an update can carry."""
|
|
84
|
+
if value is None:
|
|
85
|
+
return 'null'
|
|
86
|
+
if isinstance(value, bool) or is_number(value):
|
|
87
|
+
return number_text(value)
|
|
88
|
+
return str(value)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _plain(value: Any) -> Any:
|
|
92
|
+
"""_plain is the value as JSON.stringify sees it, in the types json.dumps writes alike."""
|
|
93
|
+
if isinstance(value, dict):
|
|
94
|
+
return {str(k): _plain(v) for k, v in value.items()}
|
|
95
|
+
if isinstance(value, (list, tuple)):
|
|
96
|
+
return [_plain(v) for v in value]
|
|
97
|
+
if isinstance(value, float):
|
|
98
|
+
if not math.isfinite(value):
|
|
99
|
+
return None # JSON.stringify writes NaN and Infinity as null
|
|
100
|
+
if value.is_integer() and abs(value) < 1e21:
|
|
101
|
+
return int(value) # 81.0 is 81 in JavaScript, and is written so
|
|
102
|
+
return value
|
|
103
|
+
if isinstance(value, _dt.datetime):
|
|
104
|
+
return iso_ms(value) # what Date.prototype.toJSON writes
|
|
105
|
+
if isinstance(value, (str, int, bool)) or value is None:
|
|
106
|
+
return value
|
|
107
|
+
raise TypeError('a ' + type(value).__name__ + ' cannot be part of a view document')
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def stringify(value: Any) -> str:
|
|
111
|
+
"""
|
|
112
|
+
stringify is JSON.stringify(value): no spaces, keys in insertion order, a float
|
|
113
|
+
with nothing after the point written as an integer, as JavaScript writes it.
|
|
114
|
+
Floats below 1e-4 or from 1e16 keep Python's notation; both read back the same.
|
|
115
|
+
"""
|
|
116
|
+
return json.dumps(_plain(value), separators=(',', ':'), ensure_ascii=False, allow_nan=False)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def clone(value: Any) -> Any:
|
|
120
|
+
"""clone is JSON.parse(JSON.stringify(value)): tuples become lists, and nothing is shared."""
|
|
121
|
+
return json.loads(stringify(value))
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def iso_ms(moment: _dt.datetime) -> str:
|
|
125
|
+
"""iso_ms is Date.prototype.toISOString(); a naive datetime is local time, as datetime.timestamp() takes it."""
|
|
126
|
+
ms = math.floor(moment.timestamp() * 1000 + 0.5) if moment.tzinfo is None else \
|
|
127
|
+
round((moment - _EPOCH).total_seconds() * 1000)
|
|
128
|
+
when = _EPOCH + _dt.timedelta(milliseconds=ms)
|
|
129
|
+
return _stamp(when) + '.%03dZ' % (when.microsecond // 1000)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
_EPOCH = _dt.datetime(1970, 1, 1, tzinfo=_dt.timezone.utc)
|
|
133
|
+
|
|
134
|
+
# Date.parse for the date-time strings ISO 8601 allows and ECMAScript reads: a date
|
|
135
|
+
# alone is UTC, a date and time without an offset local time.
|
|
136
|
+
_ISO = re.compile(
|
|
137
|
+
r'([+-]\d{6}|\d{4})-(\d\d)-(\d\d)'
|
|
138
|
+
r'(?:[Tt ](\d\d):(\d\d)(?::(\d\d)(?:[.,](\d+))?)?\s*([Zz]|[+-]\d\d(?::?\d\d)?)?)?')
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def parse_date(text: Any) -> float:
|
|
142
|
+
"""
|
|
143
|
+
parse_date is Date.parse(text) for ISO 8601: milliseconds since the epoch, or
|
|
144
|
+
NaN. JavaScript reads more spellings than that (Date.prototype.toString() among
|
|
145
|
+
them); this package takes ISO 8601 only, which is what the format asks for.
|
|
146
|
+
"""
|
|
147
|
+
if not isinstance(text, str):
|
|
148
|
+
return math.nan
|
|
149
|
+
m = _ISO.fullmatch(trim(text))
|
|
150
|
+
if not m:
|
|
151
|
+
return math.nan
|
|
152
|
+
year, month, day = int(m.group(1)), int(m.group(2)), int(m.group(3))
|
|
153
|
+
hour = int(m.group(4) or 0)
|
|
154
|
+
minute = int(m.group(5) or 0)
|
|
155
|
+
second = int(m.group(6) or 0)
|
|
156
|
+
ms = int((m.group(7) or '0')[:3].ljust(3, '0'))
|
|
157
|
+
zone = m.group(8)
|
|
158
|
+
next_day = hour == 24 and minute == 0 and second == 0 and ms == 0
|
|
159
|
+
if (hour > 23 and not next_day) or minute > 59 or second > 59:
|
|
160
|
+
return math.nan
|
|
161
|
+
try:
|
|
162
|
+
base = _dt.datetime(year, month, day, 0 if next_day else hour, minute, second)
|
|
163
|
+
except ValueError:
|
|
164
|
+
return math.nan
|
|
165
|
+
if next_day:
|
|
166
|
+
base += _dt.timedelta(days=1)
|
|
167
|
+
if m.group(4) is None or zone is not None:
|
|
168
|
+
offset = 0
|
|
169
|
+
if zone and zone not in 'Zz':
|
|
170
|
+
sign = -1 if zone[0] == '-' else 1
|
|
171
|
+
digits = zone[1:].replace(':', '')
|
|
172
|
+
hours, minutes = int(digits[:2]), int(digits[2:] or 0)
|
|
173
|
+
if hours > 23 or minutes > 59:
|
|
174
|
+
return math.nan
|
|
175
|
+
offset = sign * (hours * 60 + minutes)
|
|
176
|
+
seconds = (base.replace(tzinfo=_dt.timezone.utc) - _EPOCH).total_seconds() - offset * 60
|
|
177
|
+
return float(int(seconds) * 1000 + ms)
|
|
178
|
+
try:
|
|
179
|
+
return float(int(base.timestamp()) * 1000 + ms)
|
|
180
|
+
except (OverflowError, OSError, ValueError):
|
|
181
|
+
return math.nan
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def iso(ms: float) -> str:
|
|
185
|
+
"""iso renders a timestamp as a view document demands: UTC, whole seconds, trailing Z."""
|
|
186
|
+
return _stamp(_EPOCH + _dt.timedelta(seconds=math.floor(ms / 1000))) + 'Z'
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _stamp(when: _dt.datetime) -> str:
|
|
190
|
+
# By hand: strftime's %Y does not pad years below 1000 everywhere.
|
|
191
|
+
return '%04d-%02d-%02dT%02d:%02d:%02d' % (when.year, when.month, when.day, when.hour, when.minute, when.second)
|
andon_client/_relay.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The one request this package makes: PUT /v1/views/{id}/envelope.
|
|
3
|
+
|
|
4
|
+
The write secret travels in the Authorization header and nowhere else; it is
|
|
5
|
+
never part of a URL, a result or an error text. Users talk to relay.andon.app;
|
|
6
|
+
ANDON_RELAY_URL exists for development against a local relay only.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import math
|
|
13
|
+
import os
|
|
14
|
+
import socket
|
|
15
|
+
import urllib.error
|
|
16
|
+
import urllib.parse
|
|
17
|
+
import urllib.request
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from typing import Callable, Dict, List, Mapping, NamedTuple, Optional
|
|
20
|
+
|
|
21
|
+
from ._js import trim
|
|
22
|
+
|
|
23
|
+
DEFAULT_RELAY = 'https://relay.andon.app'
|
|
24
|
+
TIMEOUT_SEC = 15
|
|
25
|
+
|
|
26
|
+
THROTTLE_SEC = 10
|
|
27
|
+
"""The relay accepts at most one upload per view per ten seconds (429 otherwise)."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def no_trailing_slash(url: str) -> str:
|
|
31
|
+
return url.rstrip('/')
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def relay_url(env: Optional[Mapping[str, str]] = None) -> str:
|
|
35
|
+
"""relay_url is ANDON_RELAY_URL without a trailing slash, or the public relay."""
|
|
36
|
+
value = no_trailing_slash(trim((env if env is not None else os.environ).get('ANDON_RELAY_URL') or ''))
|
|
37
|
+
return value or DEFAULT_RELAY
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def relay_aliases(env: Optional[Mapping[str, str]] = None) -> List[str]:
|
|
41
|
+
"""
|
|
42
|
+
relay_aliases are the other addresses under which a .env may name this same
|
|
43
|
+
relay. Development only: in a container a local relay is reachable as
|
|
44
|
+
host.docker.internal:8080, while the .env names it as 127.0.0.1:8080.
|
|
45
|
+
ANDON_RELAY_ALIASES is a comma-separated list, empty by default.
|
|
46
|
+
"""
|
|
47
|
+
text = (env if env is not None else os.environ).get('ANDON_RELAY_ALIASES') or ''
|
|
48
|
+
return [a for a in (no_trailing_slash(trim(s)) for s in text.split(',')) if a]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class PutResult:
|
|
53
|
+
"""
|
|
54
|
+
The answer to one upload:
|
|
55
|
+
|
|
56
|
+
ok 204, stored
|
|
57
|
+
throttled 429, try again after retry_after_sec
|
|
58
|
+
retry network error or 5xx, worth trying again later
|
|
59
|
+
fatal 401, 404, 400, 413: trying again changes nothing until a person does
|
|
60
|
+
"""
|
|
61
|
+
outcome: str
|
|
62
|
+
status: int
|
|
63
|
+
"""HTTP status, 0 when the relay was not reached"""
|
|
64
|
+
code: str = ''
|
|
65
|
+
"""the relay's error code, network when it was not reached"""
|
|
66
|
+
message: str = ''
|
|
67
|
+
retry_after_sec: Optional[float] = None
|
|
68
|
+
"""for throttled: when the relay takes the next upload"""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Response(NamedTuple):
|
|
72
|
+
"""What a transport hands back: the status, the body as text, the Retry-After header."""
|
|
73
|
+
status: int
|
|
74
|
+
text: str
|
|
75
|
+
retry_after: Optional[str]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
Transport = Callable[[str, Dict[str, str], bytes, float], Response]
|
|
79
|
+
"""transport(url, headers, body, timeout_sec) makes the PUT; raises OSError when there is no answer."""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def urllib_transport(url: str, headers: Dict[str, str], body: bytes, timeout: float) -> Response:
|
|
83
|
+
request = urllib.request.Request(url, data=body, headers=headers, method='PUT')
|
|
84
|
+
try:
|
|
85
|
+
with urllib.request.urlopen(request, timeout=timeout) as res: # noqa: S310 - the URL is the relay's
|
|
86
|
+
return Response(res.status, '' if res.status == 204 else _read(res), res.headers.get('retry-after'))
|
|
87
|
+
except urllib.error.HTTPError as err:
|
|
88
|
+
return Response(err.code, _read(err), err.headers.get('retry-after') if err.headers else None)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _read(res) -> str:
|
|
92
|
+
try:
|
|
93
|
+
return res.read(4096).decode('utf-8', 'replace')
|
|
94
|
+
except Exception: # noqa: BLE001 - a body that cannot be read carries no code, nothing more
|
|
95
|
+
return ''
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _error_code(text: str) -> str:
|
|
99
|
+
if not text.startswith('{'):
|
|
100
|
+
return ''
|
|
101
|
+
try:
|
|
102
|
+
parsed = json.loads(text)
|
|
103
|
+
except ValueError:
|
|
104
|
+
return '' # not the relay's JSON (a proxy's page, say): no code to report
|
|
105
|
+
code = parsed.get('error') if isinstance(parsed, dict) else None
|
|
106
|
+
return code if isinstance(code, str) else ''
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _unreachable(err: BaseException) -> str:
|
|
110
|
+
"""_unreachable says why a request got no answer at all."""
|
|
111
|
+
if isinstance(err, (socket.timeout, TimeoutError)) or \
|
|
112
|
+
(isinstance(err, urllib.error.URLError) and isinstance(err.reason, (socket.timeout, TimeoutError))):
|
|
113
|
+
return 'the relay did not answer within ' + str(TIMEOUT_SEC) + ' s'
|
|
114
|
+
reason = err.reason if isinstance(err, urllib.error.URLError) else err
|
|
115
|
+
return 'the relay could not be reached: ' + (str(reason) or type(reason).__name__)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _hint(status: int, code: str) -> str:
|
|
119
|
+
"""_hint says what to change, per the relay's answers."""
|
|
120
|
+
if status == 401:
|
|
121
|
+
return 'the relay rejected the write secret (' + code + '): use the current .env of the view'
|
|
122
|
+
if status == 404:
|
|
123
|
+
return 'the relay does not know this view (' + code + '): check the view ID, or use the current .env'
|
|
124
|
+
if status == 413:
|
|
125
|
+
return 'the envelope is larger than 256 KiB (' + code + '): fewer tiles or shorter series'
|
|
126
|
+
if status == 400:
|
|
127
|
+
return 'the relay refused the envelope (' + code + ')'
|
|
128
|
+
if status == 429:
|
|
129
|
+
return 'throttled (' + code + '): the next upload carries the latest state'
|
|
130
|
+
return 'upload failed with HTTP ' + str(status) + (' (' + code + ')' if code else '')
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _retry_after(value: Optional[str]) -> float:
|
|
134
|
+
try:
|
|
135
|
+
seconds = float(trim(value)) if value is not None and trim(value) else 0
|
|
136
|
+
except ValueError:
|
|
137
|
+
return THROTTLE_SEC # an HTTP date: the throttle is what the relay means
|
|
138
|
+
return seconds if math.isfinite(seconds) and seconds > 0 else THROTTLE_SEC
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def put(base: str, view: str, write_secret: str, envelope: str, transport: Optional[Transport] = None) -> PutResult:
|
|
142
|
+
"""put uploads one envelope and classifies the answer. It never raises for the relay's sake."""
|
|
143
|
+
url = base + '/v1/views/' + urllib.parse.quote(view, safe='') + '/envelope'
|
|
144
|
+
headers = {'content-type': 'application/json', 'authorization': 'Bearer ' + write_secret}
|
|
145
|
+
try:
|
|
146
|
+
res = (transport or urllib_transport)(url, headers, envelope.encode('utf-8'), TIMEOUT_SEC)
|
|
147
|
+
except (OSError, ValueError) as err: # URLError and timeouts are OSErrors; ValueError for a broken URL
|
|
148
|
+
return PutResult('retry', 0, 'network', _unreachable(err))
|
|
149
|
+
text = '' if res.status == 204 else (res.text or '')[:300]
|
|
150
|
+
code = _error_code(text)
|
|
151
|
+
if res.status == 204:
|
|
152
|
+
return PutResult('ok', 204)
|
|
153
|
+
if res.status == 429:
|
|
154
|
+
return PutResult('throttled', 429, code, _hint(429, code or 'throttled'), _retry_after(res.retry_after))
|
|
155
|
+
if res.status >= 500:
|
|
156
|
+
return PutResult('retry', res.status, code, _hint(res.status, code))
|
|
157
|
+
return PutResult('fatal', res.status, code, _hint(res.status, code))
|