chartremotely 0.2.0__tar.gz → 0.2.1__tar.gz
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.
- {chartremotely-0.2.0 → chartremotely-0.2.1}/PKG-INFO +1 -1
- chartremotely-0.2.1/chartremotely/guilock.py +48 -0
- chartremotely-0.2.1/chartremotely/push.py +100 -0
- {chartremotely-0.2.0 → chartremotely-0.2.1}/chartremotely/relay.py +5 -1
- {chartremotely-0.2.0 → chartremotely-0.2.1}/chartremotely/server.py +9 -3
- {chartremotely-0.2.0 → chartremotely-0.2.1}/chartremotely/vocab.py +14 -5
- {chartremotely-0.2.0 → chartremotely-0.2.1}/chartremotely.egg-info/PKG-INFO +1 -1
- {chartremotely-0.2.0 → chartremotely-0.2.1}/chartremotely.egg-info/SOURCES.txt +1 -0
- {chartremotely-0.2.0 → chartremotely-0.2.1}/pyproject.toml +1 -1
- chartremotely-0.2.1/tests/test_push.py +107 -0
- {chartremotely-0.2.0 → chartremotely-0.2.1}/tests/test_relay.py +21 -1
- chartremotely-0.2.0/chartremotely/push.py +0 -58
- chartremotely-0.2.0/tests/test_push.py +0 -47
- {chartremotely-0.2.0 → chartremotely-0.2.1}/LICENSE +0 -0
- {chartremotely-0.2.0 → chartremotely-0.2.1}/README.md +0 -0
- {chartremotely-0.2.0 → chartremotely-0.2.1}/chartremotely/__init__.py +0 -0
- {chartremotely-0.2.0 → chartremotely-0.2.1}/chartremotely/assets/shortcut-template.plist +0 -0
- {chartremotely-0.2.0 → chartremotely-0.2.1}/chartremotely/ax.py +0 -0
- {chartremotely-0.2.0 → chartremotely-0.2.1}/chartremotely/cli.py +0 -0
- {chartremotely-0.2.0 → chartremotely-0.2.1}/chartremotely/config.py +0 -0
- {chartremotely-0.2.0 → chartremotely-0.2.1}/chartremotely/doctor.py +0 -0
- {chartremotely-0.2.0 → chartremotely-0.2.1}/chartremotely/keystore.py +0 -0
- {chartremotely-0.2.0 → chartremotely-0.2.1}/chartremotely/layout.py +0 -0
- {chartremotely-0.2.0 → chartremotely-0.2.1}/chartremotely/mcpclient.py +0 -0
- {chartremotely-0.2.0 → chartremotely-0.2.1}/chartremotely/registry.py +0 -0
- {chartremotely-0.2.0 → chartremotely-0.2.1}/chartremotely/resolve.py +0 -0
- {chartremotely-0.2.0 → chartremotely-0.2.1}/chartremotely/scales.py +0 -0
- {chartremotely-0.2.0 → chartremotely-0.2.1}/chartremotely/services.py +0 -0
- {chartremotely-0.2.0 → chartremotely-0.2.1}/chartremotely/setup.py +0 -0
- {chartremotely-0.2.0 → chartremotely-0.2.1}/chartremotely/shortcut.py +0 -0
- {chartremotely-0.2.0 → chartremotely-0.2.1}/chartremotely/snapshot.py +0 -0
- {chartremotely-0.2.0 → chartremotely-0.2.1}/chartremotely/studies.py +0 -0
- {chartremotely-0.2.0 → chartremotely-0.2.1}/chartremotely/symbol.py +0 -0
- {chartremotely-0.2.0 → chartremotely-0.2.1}/chartremotely/tailnet.py +0 -0
- {chartremotely-0.2.0 → chartremotely-0.2.1}/chartremotely/timeframe.py +0 -0
- {chartremotely-0.2.0 → chartremotely-0.2.1}/chartremotely/window.py +0 -0
- {chartremotely-0.2.0 → chartremotely-0.2.1}/chartremotely.egg-info/dependency_links.txt +0 -0
- {chartremotely-0.2.0 → chartremotely-0.2.1}/chartremotely.egg-info/entry_points.txt +0 -0
- {chartremotely-0.2.0 → chartremotely-0.2.1}/chartremotely.egg-info/requires.txt +0 -0
- {chartremotely-0.2.0 → chartremotely-0.2.1}/chartremotely.egg-info/top_level.txt +0 -0
- {chartremotely-0.2.0 → chartremotely-0.2.1}/setup.cfg +0 -0
- {chartremotely-0.2.0 → chartremotely-0.2.1}/tests/test_resolve.py +0 -0
- {chartremotely-0.2.0 → chartremotely-0.2.1}/tests/test_setup.py +0 -0
- {chartremotely-0.2.0 → chartremotely-0.2.1}/tests/test_snapshot.py +0 -0
- {chartremotely-0.2.0 → chartremotely-0.2.1}/tests/test_timeframe.py +0 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""One driver of thinkorswim at a time, across every process on this Mac.
|
|
2
|
+
|
|
3
|
+
The Siri listener (`serve`) and the website's relay (`relay`) are separate
|
|
4
|
+
processes, and each can drive the chart; so can the background picture taken
|
|
5
|
+
after a change. Two of them hit-testing and typing into the same symbol field
|
|
6
|
+
at once is how a good request comes back as an error. A file lock is the one
|
|
7
|
+
primitive both processes and their threads share.
|
|
8
|
+
|
|
9
|
+
Stdlib only, so the wire logic that imports it stays importable on Linux.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import fcntl
|
|
15
|
+
import time
|
|
16
|
+
from collections.abc import Iterator
|
|
17
|
+
from contextlib import contextmanager
|
|
18
|
+
|
|
19
|
+
from . import config
|
|
20
|
+
|
|
21
|
+
LOCK_NAME = "gui.lock"
|
|
22
|
+
#: Longest a request waits for another to finish driving the chart.
|
|
23
|
+
WAIT_SECONDS = 30.0
|
|
24
|
+
_POLL_SECONDS = 0.1
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Busy(TimeoutError):
|
|
28
|
+
"""Another request held the chart for longer than WAIT_SECONDS."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@contextmanager
|
|
32
|
+
def driving(wait: float = WAIT_SECONDS) -> Iterator[None]:
|
|
33
|
+
"""Hold the chart for the duration of the block."""
|
|
34
|
+
config.CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
with open(config.CONFIG_DIR / LOCK_NAME, "a") as handle:
|
|
36
|
+
deadline = time.monotonic() + wait
|
|
37
|
+
while True:
|
|
38
|
+
try:
|
|
39
|
+
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
40
|
+
break
|
|
41
|
+
except BlockingIOError:
|
|
42
|
+
if time.monotonic() >= deadline:
|
|
43
|
+
raise Busy("the chart is busy with another request") from None
|
|
44
|
+
time.sleep(_POLL_SECONDS)
|
|
45
|
+
try:
|
|
46
|
+
yield
|
|
47
|
+
finally:
|
|
48
|
+
fcntl.flock(handle, fcntl.LOCK_UN)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Tell the operator what the chart looks like after it changes.
|
|
2
|
+
|
|
3
|
+
A chart changed by voice goes straight to this machine over the tailnet, so
|
|
4
|
+
the operator never hears of it. Once a change is answered — after the reply
|
|
5
|
+
("… Good luck.") has been sent — one picture of the chart pane is pushed up so
|
|
6
|
+
the patron's browser can offer it. The operator keeps only the newest,
|
|
7
|
+
encrypted, for an hour.
|
|
8
|
+
|
|
9
|
+
Three rules keep it out of the way:
|
|
10
|
+
|
|
11
|
+
* **After the reply.** The listener and the relay schedule the picture only
|
|
12
|
+
once their reply has gone out, so it never delays or races the answer.
|
|
13
|
+
* **Coalesced.** Changes in quick succession produce one picture, taken when
|
|
14
|
+
the chart has been quiet for QUIET_SECONDS — never a trail of in-between
|
|
15
|
+
captures.
|
|
16
|
+
* **Serialised.** The capture holds the same cross-process lock as every
|
|
17
|
+
command that drives the chart (guilock), so it never overlaps one.
|
|
18
|
+
|
|
19
|
+
Any failure is swallowed: the chart already changed, and a missing picture
|
|
20
|
+
must never become a spoken error. Stdlib only at module scope, like the relay;
|
|
21
|
+
the capture is imported lazily.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import threading
|
|
27
|
+
import time
|
|
28
|
+
|
|
29
|
+
from . import config, guilock, relay
|
|
30
|
+
|
|
31
|
+
#: How long the chart must go unchanged before its picture is taken.
|
|
32
|
+
QUIET_SECONDS = 1.5
|
|
33
|
+
|
|
34
|
+
#: Verbs that read or picture the chart, or only resolve words: they change
|
|
35
|
+
#: nothing, so no picture follows them. Every other request — `set`, or a bare
|
|
36
|
+
#: company name — changes the chart (see vocab.dispatch).
|
|
37
|
+
_NOT_A_CHANGE = frozenset({"resolve", "scale", "read", "snapshot"})
|
|
38
|
+
|
|
39
|
+
_state = threading.Condition()
|
|
40
|
+
_due: float | None = None
|
|
41
|
+
_worker: threading.Thread | None = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def changes_chart(request: str, reply: str) -> bool:
|
|
45
|
+
"""Whether this answered request changed what the chart shows."""
|
|
46
|
+
verb = (request or "").strip().partition(" ")[0].lower()
|
|
47
|
+
return bool(verb) and verb not in _NOT_A_CHANGE and not reply.startswith("ERR")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def payload(cfg: dict, image: str) -> tuple[str, dict] | None:
|
|
51
|
+
"""Where to send a picture and what to send, or None when not paired."""
|
|
52
|
+
base = (cfg.get("operator_url") or "").rstrip("/")
|
|
53
|
+
agent_id, secret = cfg.get("agent_id"), cfg.get("agent_secret")
|
|
54
|
+
if not (base and agent_id and secret):
|
|
55
|
+
return None
|
|
56
|
+
return f"{base}/agent/snapshot", {"agent_id": agent_id, "secret": secret, "image": image}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def after_reply(request: str, reply: str) -> None:
|
|
60
|
+
"""Call once a reply has been sent. Schedules a picture if the chart changed."""
|
|
61
|
+
if changes_chart(request, reply):
|
|
62
|
+
schedule()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def schedule() -> None:
|
|
66
|
+
"""Ask for one picture once the chart has been quiet; later asks push it back."""
|
|
67
|
+
global _due, _worker
|
|
68
|
+
with _state:
|
|
69
|
+
_due = time.monotonic() + QUIET_SECONDS
|
|
70
|
+
if _worker is None or not _worker.is_alive():
|
|
71
|
+
_worker = threading.Thread(target=_wait_then_push, name="push-latest", daemon=True)
|
|
72
|
+
_worker.start()
|
|
73
|
+
_state.notify_all()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _wait_then_push() -> None:
|
|
77
|
+
global _due
|
|
78
|
+
with _state:
|
|
79
|
+
while True:
|
|
80
|
+
remaining = (_due or 0) - time.monotonic()
|
|
81
|
+
if remaining <= 0:
|
|
82
|
+
_due = None
|
|
83
|
+
break
|
|
84
|
+
_state.wait(remaining)
|
|
85
|
+
push_now()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def push_now() -> None:
|
|
89
|
+
"""Take the chart's picture and send it. Never raises."""
|
|
90
|
+
try:
|
|
91
|
+
cfg = config.load()
|
|
92
|
+
if payload(cfg, "") is None:
|
|
93
|
+
return
|
|
94
|
+
from . import snapshot, window
|
|
95
|
+
with guilock.driving():
|
|
96
|
+
image = snapshot.as_reply(snapshot.shrink(window.capture()))
|
|
97
|
+
url, body = payload(cfg, image)
|
|
98
|
+
relay._post(url, body, timeout=30)
|
|
99
|
+
except Exception: # noqa: BLE001, S110 - deliberate: see the module docstring
|
|
100
|
+
pass
|
|
@@ -134,12 +134,16 @@ def run(operator_url: str | None = None, once: bool = False) -> None:
|
|
|
134
134
|
continue
|
|
135
135
|
|
|
136
136
|
if envelope.get("command"):
|
|
137
|
-
|
|
137
|
+
command = str(envelope["command"])
|
|
138
|
+
reply = execute(command)
|
|
138
139
|
try:
|
|
139
140
|
_post(f"{base}/agent/result",
|
|
140
141
|
{**credentials, "id": envelope.get("id"), "reply": reply},
|
|
141
142
|
timeout=20)
|
|
142
143
|
except (urllib.error.URLError, TimeoutError, OSError):
|
|
143
144
|
pass # the caller has already timed out and refunded
|
|
145
|
+
# After the reply, never before: the picture of a changed chart.
|
|
146
|
+
from . import push
|
|
147
|
+
push.after_reply(command, reply)
|
|
144
148
|
if once:
|
|
145
149
|
return
|
|
@@ -17,7 +17,7 @@ import secrets
|
|
|
17
17
|
import urllib.parse
|
|
18
18
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
19
19
|
|
|
20
|
-
from . import config
|
|
20
|
+
from . import config, push
|
|
21
21
|
from .vocab import dispatch
|
|
22
22
|
|
|
23
23
|
|
|
@@ -48,7 +48,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
48
48
|
raw = str(json.loads(raw).get("cmd") or "").strip()
|
|
49
49
|
except ValueError:
|
|
50
50
|
return self._reply(400, "ERR bad JSON")
|
|
51
|
-
self.
|
|
51
|
+
self._answer(raw)
|
|
52
52
|
|
|
53
53
|
def do_GET(self) -> None:
|
|
54
54
|
parsed = urllib.parse.urlparse(self.path)
|
|
@@ -57,7 +57,13 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
57
57
|
query = urllib.parse.parse_qs(parsed.query)
|
|
58
58
|
if not self._authorised((query.get("t") or [None])[0]):
|
|
59
59
|
return self._reply(403, "ERR forbidden")
|
|
60
|
-
self.
|
|
60
|
+
self._answer((query.get("cmd") or [""])[0].strip())
|
|
61
|
+
|
|
62
|
+
def _answer(self, request: str) -> None:
|
|
63
|
+
"""Reply first; only then schedule the picture of a changed chart."""
|
|
64
|
+
reply = dispatch(request)
|
|
65
|
+
self._reply(200, reply)
|
|
66
|
+
push.after_reply(request, reply)
|
|
61
67
|
|
|
62
68
|
def log_message(self, *args) -> None:
|
|
63
69
|
"""Silence. Requests carry spoken input; do not write it to a log."""
|
|
@@ -16,7 +16,7 @@ Two rules the callers depend on:
|
|
|
16
16
|
|
|
17
17
|
from __future__ import annotations
|
|
18
18
|
|
|
19
|
-
from . import
|
|
19
|
+
from . import guilock, registry, resolve, scales, snapshot, symbol, timeframe, window
|
|
20
20
|
|
|
21
21
|
ERR = "ERR "
|
|
22
22
|
|
|
@@ -126,15 +126,24 @@ def dispatch(request: str) -> str:
|
|
|
126
126
|
if verb == "scale":
|
|
127
127
|
return cmd_scale(rest)
|
|
128
128
|
if verb == "read":
|
|
129
|
-
return cmd_read
|
|
129
|
+
return _driving(cmd_read)
|
|
130
130
|
if verb == "snapshot":
|
|
131
|
-
return cmd_snapshot
|
|
131
|
+
return _driving(cmd_snapshot)
|
|
132
132
|
if verb == "set":
|
|
133
133
|
ticker, sep, scale = rest.partition("|")
|
|
134
|
-
return
|
|
134
|
+
return _driving(lambda: cmd_set(ticker, scale if sep else ""))
|
|
135
135
|
|
|
136
136
|
# Bare name: resolve and show it.
|
|
137
137
|
ticker = cmd_resolve(request)
|
|
138
138
|
if ticker.startswith(ERR):
|
|
139
139
|
return ticker
|
|
140
|
-
return
|
|
140
|
+
return _driving(lambda: cmd_set(ticker))
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _driving(command) -> str:
|
|
144
|
+
"""Run one command that drives the chart, never alongside another."""
|
|
145
|
+
try:
|
|
146
|
+
with guilock.driving():
|
|
147
|
+
return command()
|
|
148
|
+
except guilock.Busy as exc:
|
|
149
|
+
return ERR + str(exc)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""The picture pushed after a chart change: only after the reply, one per burst, never in the way."""
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from chartremotely import guilock, push
|
|
9
|
+
|
|
10
|
+
PAIRED = {"operator_url": "https://op.test/", "agent_id": "a1", "agent_secret": "s1"}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.fixture(autouse=True)
|
|
14
|
+
def isolated_config(tmp_path, monkeypatch):
|
|
15
|
+
monkeypatch.setattr(guilock.config, "CONFIG_DIR", tmp_path)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_an_unpaired_mac_pushes_nothing():
|
|
19
|
+
assert push.payload({"operator_url": None, "agent_id": None, "agent_secret": None}, "x") is None
|
|
20
|
+
assert push.payload({**PAIRED, "agent_secret": ""}, "x") is None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_the_push_carries_the_agents_identity_and_the_picture():
|
|
24
|
+
url, body = push.payload(PAIRED, "data:image/jpeg;base64,AAAA")
|
|
25
|
+
assert url == "https://op.test/agent/snapshot"
|
|
26
|
+
assert body == {"agent_id": "a1", "secret": "s1", "image": "data:image/jpeg;base64,AAAA"}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytest.mark.parametrize("request_, reply, changed", [
|
|
30
|
+
("set PLTR | swing", "Showing PLTR at swing. Good luck.", True),
|
|
31
|
+
("palantir", "Showing PLTR at swing, as is. Good luck.", True),
|
|
32
|
+
("set PLTR", "ERR no symbol field found", False),
|
|
33
|
+
("resolve palantir", "PLTR", False),
|
|
34
|
+
("scale swing", "swing", False),
|
|
35
|
+
("read", "PLTR at swing", False),
|
|
36
|
+
("snapshot", "data:image/jpeg;base64,AAAA", False),
|
|
37
|
+
("", "ERR I didn't catch that.", False),
|
|
38
|
+
])
|
|
39
|
+
def test_only_an_answered_change_earns_a_picture(request_, reply, changed):
|
|
40
|
+
assert push.changes_chart(request_, reply) is changed
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_a_burst_of_changes_gives_one_picture_after_the_last(monkeypatch):
|
|
44
|
+
shots = []
|
|
45
|
+
monkeypatch.setattr(push, "QUIET_SECONDS", 0.15)
|
|
46
|
+
monkeypatch.setattr(push, "push_now", lambda: shots.append(time.monotonic()))
|
|
47
|
+
started = time.monotonic()
|
|
48
|
+
for i in range(4):
|
|
49
|
+
if i:
|
|
50
|
+
time.sleep(0.05)
|
|
51
|
+
push.after_reply("set PLTR", "Showing PLTR. Good luck.")
|
|
52
|
+
last = time.monotonic()
|
|
53
|
+
time.sleep(0.5)
|
|
54
|
+
assert len(shots) == 1, "one picture for the whole burst"
|
|
55
|
+
assert shots[0] - last >= 0.14, "taken only once the chart went quiet"
|
|
56
|
+
assert shots[0] > started
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_nothing_is_scheduled_for_a_request_that_changed_nothing(monkeypatch):
|
|
60
|
+
shots = []
|
|
61
|
+
monkeypatch.setattr(push, "QUIET_SECONDS", 0.05)
|
|
62
|
+
monkeypatch.setattr(push, "push_now", lambda: shots.append(1))
|
|
63
|
+
push.after_reply("resolve palantir", "PLTR")
|
|
64
|
+
time.sleep(0.2)
|
|
65
|
+
assert shots == []
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_a_push_never_raises_whatever_goes_wrong(monkeypatch):
|
|
69
|
+
monkeypatch.setattr(push.config, "load", lambda: PAIRED)
|
|
70
|
+
|
|
71
|
+
def boom(*a, **k):
|
|
72
|
+
raise OSError("operator unreachable")
|
|
73
|
+
monkeypatch.setattr(push.relay, "_post", boom)
|
|
74
|
+
push.push_now() # the capture or the post fails; the chart already changed
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_the_chart_is_driven_by_one_holder_at_a_time():
|
|
78
|
+
order = []
|
|
79
|
+
|
|
80
|
+
def hold(name, pause):
|
|
81
|
+
with guilock.driving():
|
|
82
|
+
order.append(f"{name} in")
|
|
83
|
+
time.sleep(pause)
|
|
84
|
+
order.append(f"{name} out")
|
|
85
|
+
|
|
86
|
+
a = threading.Thread(target=hold, args=("a", 0.2))
|
|
87
|
+
a.start()
|
|
88
|
+
time.sleep(0.05)
|
|
89
|
+
hold("b", 0)
|
|
90
|
+
a.join()
|
|
91
|
+
assert order == ["a in", "a out", "b in", "b out"]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_a_holder_that_never_lets_go_is_reported_busy():
|
|
95
|
+
release = threading.Event()
|
|
96
|
+
|
|
97
|
+
def hog():
|
|
98
|
+
with guilock.driving():
|
|
99
|
+
release.wait(2)
|
|
100
|
+
|
|
101
|
+
t = threading.Thread(target=hog)
|
|
102
|
+
t.start()
|
|
103
|
+
time.sleep(0.05)
|
|
104
|
+
with pytest.raises(guilock.Busy), guilock.driving(wait=0.2):
|
|
105
|
+
pass
|
|
106
|
+
release.set()
|
|
107
|
+
t.join()
|
|
@@ -141,7 +141,7 @@ def test_the_wire_logic_imports_without_the_macos_drivers():
|
|
|
141
141
|
from pathlib import Path
|
|
142
142
|
|
|
143
143
|
forbidden = {"Quartz", "AppKit", "ApplicationServices", "CoreFoundation"}
|
|
144
|
-
for name in ("relay", "resolve", "scales", "config", "registry", "snapshot", "push",
|
|
144
|
+
for name in ("relay", "resolve", "scales", "config", "registry", "snapshot", "push", "guilock",
|
|
145
145
|
"mcpclient", "keystore", "services", "tailnet", "shortcut", "setup"):
|
|
146
146
|
tree = ast.parse(Path(f"chartremotely/{name}.py").read_text())
|
|
147
147
|
for node in ast.walk(tree):
|
|
@@ -155,3 +155,23 @@ def test_the_wire_logic_imports_without_the_macos_drivers():
|
|
|
155
155
|
# are the sanctioned escape hatch.
|
|
156
156
|
if node.col_offset == 0:
|
|
157
157
|
assert not (names & forbidden), f"{name}.py imports {names & forbidden} at module scope"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_the_picture_is_scheduled_only_after_the_result_is_posted(operator, monkeypatch):
|
|
161
|
+
"""The reply goes out first; only then may a picture of the changed chart follow."""
|
|
162
|
+
from chartremotely import push
|
|
163
|
+
events = []
|
|
164
|
+
StubOperator.claimed = True
|
|
165
|
+
relay.pair(operator, on_code=lambda c: None)
|
|
166
|
+
StubOperator.commands.append({"id": "r2", "command": "set PLTR"})
|
|
167
|
+
monkeypatch.setattr(relay, "execute", lambda cmd: "Showing PLTR. Good luck.")
|
|
168
|
+
real_post = relay._post
|
|
169
|
+
|
|
170
|
+
def post(url, payload, timeout):
|
|
171
|
+
if url.endswith("/agent/result"):
|
|
172
|
+
events.append("result")
|
|
173
|
+
return real_post(url, payload, timeout)
|
|
174
|
+
monkeypatch.setattr(relay, "_post", post)
|
|
175
|
+
monkeypatch.setattr(push, "after_reply", lambda c, r: events.append(("picture", c)))
|
|
176
|
+
relay.run(once=True)
|
|
177
|
+
assert events == ["result", ("picture", "set PLTR")]
|
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
"""Tell the operator what the chart looks like after it changes.
|
|
2
|
-
|
|
3
|
-
A chart changed by voice goes straight to this machine over the tailnet, so
|
|
4
|
-
the operator never hears of it. Right after a change succeeds, one picture of
|
|
5
|
-
the chart pane is pushed up so the patron's browser can offer it. The operator
|
|
6
|
-
keeps only the newest, encrypted, for an hour.
|
|
7
|
-
|
|
8
|
-
Off the voice path on purpose: the push runs in the background after the
|
|
9
|
-
answer is already on its way, and any failure is swallowed. The chart has
|
|
10
|
-
already changed; a missing picture must never turn into a spoken error.
|
|
11
|
-
|
|
12
|
-
Stdlib only at module scope, like the relay: the capture is imported lazily.
|
|
13
|
-
"""
|
|
14
|
-
|
|
15
|
-
from __future__ import annotations
|
|
16
|
-
|
|
17
|
-
import threading
|
|
18
|
-
import time
|
|
19
|
-
|
|
20
|
-
from . import config, relay
|
|
21
|
-
|
|
22
|
-
#: Time for the chart to redraw before its picture is taken.
|
|
23
|
-
SETTLE_SECONDS = 1.0
|
|
24
|
-
|
|
25
|
-
_lock = threading.Lock()
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
def payload(cfg: dict, image: str) -> tuple[str, dict] | None:
|
|
29
|
-
"""Where to send a picture and what to send, or None when not paired."""
|
|
30
|
-
base = (cfg.get("operator_url") or "").rstrip("/")
|
|
31
|
-
agent_id, secret = cfg.get("agent_id"), cfg.get("agent_secret")
|
|
32
|
-
if not (base and agent_id and secret):
|
|
33
|
-
return None
|
|
34
|
-
return f"{base}/agent/snapshot", {"agent_id": agent_id, "secret": secret, "image": image}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
def after_change(reply: str) -> str:
|
|
38
|
-
"""Start a picture push if the change worked. Returns the reply unchanged."""
|
|
39
|
-
if not reply.startswith("ERR"):
|
|
40
|
-
threading.Thread(target=push_now, name="push-latest", daemon=True).start()
|
|
41
|
-
return reply
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
def push_now() -> None:
|
|
45
|
-
"""Take the chart's picture and send it. Never raises."""
|
|
46
|
-
try:
|
|
47
|
-
cfg = config.load()
|
|
48
|
-
if payload(cfg, "") is None:
|
|
49
|
-
return
|
|
50
|
-
# One at a time: two quick changes should not capture over each other.
|
|
51
|
-
with _lock:
|
|
52
|
-
time.sleep(SETTLE_SECONDS)
|
|
53
|
-
from . import snapshot, window
|
|
54
|
-
image = snapshot.as_reply(snapshot.shrink(window.capture()))
|
|
55
|
-
url, body = payload(cfg, image)
|
|
56
|
-
relay._post(url, body, timeout=30)
|
|
57
|
-
except Exception: # noqa: BLE001, S110 - deliberate: see the module docstring
|
|
58
|
-
pass
|
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
"""The picture pushed after a chart change: when it goes, and that it never hurts."""
|
|
2
|
-
|
|
3
|
-
import threading
|
|
4
|
-
|
|
5
|
-
from chartremotely import push
|
|
6
|
-
|
|
7
|
-
PAIRED = {"operator_url": "https://op.test/", "agent_id": "a1", "agent_secret": "s1"}
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
def test_an_unpaired_mac_pushes_nothing():
|
|
11
|
-
assert push.payload({"operator_url": None, "agent_id": None, "agent_secret": None}, "x") is None
|
|
12
|
-
assert push.payload({**PAIRED, "agent_secret": ""}, "x") is None
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
def test_the_push_carries_the_agents_identity_and_the_picture():
|
|
16
|
-
url, body = push.payload(PAIRED, "data:image/jpeg;base64,AAAA")
|
|
17
|
-
assert url == "https://op.test/agent/snapshot"
|
|
18
|
-
assert body == {"agent_id": "a1", "secret": "s1", "image": "data:image/jpeg;base64,AAAA"}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
def test_a_failed_change_starts_no_push(monkeypatch):
|
|
22
|
-
started = []
|
|
23
|
-
monkeypatch.setattr(threading, "Thread", lambda **kw: started.append(kw) or _Never())
|
|
24
|
-
assert push.after_change("ERR no match for 'volunteer'") == "ERR no match for 'volunteer'"
|
|
25
|
-
assert started == []
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
def test_a_good_change_answers_at_once_and_pushes_behind_it(monkeypatch):
|
|
29
|
-
started = []
|
|
30
|
-
monkeypatch.setattr(threading, "Thread", lambda **kw: started.append(kw) or _Never())
|
|
31
|
-
assert push.after_change("Showing PLTR at swing. Good luck.").startswith("Showing PLTR")
|
|
32
|
-
assert [kw["target"] for kw in started] == [push.push_now]
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
def test_a_push_never_raises_whatever_goes_wrong(monkeypatch):
|
|
36
|
-
monkeypatch.setattr(push.config, "load", lambda: PAIRED)
|
|
37
|
-
monkeypatch.setattr(push, "SETTLE_SECONDS", 0)
|
|
38
|
-
|
|
39
|
-
def boom(*a, **k):
|
|
40
|
-
raise OSError("operator unreachable")
|
|
41
|
-
monkeypatch.setattr(push.relay, "_post", boom)
|
|
42
|
-
push.push_now() # the capture or the post fails; the chart already changed
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
class _Never:
|
|
46
|
-
def start(self):
|
|
47
|
-
pass
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|