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.
@@ -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
+ ]
@@ -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))