chartremotely 0.2.0__tar.gz → 0.2.2__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.2}/PKG-INFO +13 -1
- {chartremotely-0.2.0 → chartremotely-0.2.2}/README.md +12 -0
- chartremotely-0.2.2/chartremotely/answer.py +21 -0
- chartremotely-0.2.2/chartremotely/guilock.py +48 -0
- chartremotely-0.2.2/chartremotely/push.py +152 -0
- {chartremotely-0.2.0 → chartremotely-0.2.2}/chartremotely/relay.py +10 -4
- {chartremotely-0.2.0 → chartremotely-0.2.2}/chartremotely/server.py +10 -4
- {chartremotely-0.2.0 → chartremotely-0.2.2}/chartremotely/vocab.py +38 -14
- {chartremotely-0.2.0 → chartremotely-0.2.2}/chartremotely.egg-info/PKG-INFO +13 -1
- {chartremotely-0.2.0 → chartremotely-0.2.2}/chartremotely.egg-info/SOURCES.txt +2 -0
- {chartremotely-0.2.0 → chartremotely-0.2.2}/pyproject.toml +1 -1
- chartremotely-0.2.2/tests/test_push.py +188 -0
- {chartremotely-0.2.0 → chartremotely-0.2.2}/tests/test_relay.py +24 -3
- 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.2}/LICENSE +0 -0
- {chartremotely-0.2.0 → chartremotely-0.2.2}/chartremotely/__init__.py +0 -0
- {chartremotely-0.2.0 → chartremotely-0.2.2}/chartremotely/assets/shortcut-template.plist +0 -0
- {chartremotely-0.2.0 → chartremotely-0.2.2}/chartremotely/ax.py +0 -0
- {chartremotely-0.2.0 → chartremotely-0.2.2}/chartremotely/cli.py +0 -0
- {chartremotely-0.2.0 → chartremotely-0.2.2}/chartremotely/config.py +0 -0
- {chartremotely-0.2.0 → chartremotely-0.2.2}/chartremotely/doctor.py +0 -0
- {chartremotely-0.2.0 → chartremotely-0.2.2}/chartremotely/keystore.py +0 -0
- {chartremotely-0.2.0 → chartremotely-0.2.2}/chartremotely/layout.py +0 -0
- {chartremotely-0.2.0 → chartremotely-0.2.2}/chartremotely/mcpclient.py +0 -0
- {chartremotely-0.2.0 → chartremotely-0.2.2}/chartremotely/registry.py +0 -0
- {chartremotely-0.2.0 → chartremotely-0.2.2}/chartremotely/resolve.py +0 -0
- {chartremotely-0.2.0 → chartremotely-0.2.2}/chartremotely/scales.py +0 -0
- {chartremotely-0.2.0 → chartremotely-0.2.2}/chartremotely/services.py +0 -0
- {chartremotely-0.2.0 → chartremotely-0.2.2}/chartremotely/setup.py +0 -0
- {chartremotely-0.2.0 → chartremotely-0.2.2}/chartremotely/shortcut.py +0 -0
- {chartremotely-0.2.0 → chartremotely-0.2.2}/chartremotely/snapshot.py +0 -0
- {chartremotely-0.2.0 → chartremotely-0.2.2}/chartremotely/studies.py +0 -0
- {chartremotely-0.2.0 → chartremotely-0.2.2}/chartremotely/symbol.py +0 -0
- {chartremotely-0.2.0 → chartremotely-0.2.2}/chartremotely/tailnet.py +0 -0
- {chartremotely-0.2.0 → chartremotely-0.2.2}/chartremotely/timeframe.py +0 -0
- {chartremotely-0.2.0 → chartremotely-0.2.2}/chartremotely/window.py +0 -0
- {chartremotely-0.2.0 → chartremotely-0.2.2}/chartremotely.egg-info/dependency_links.txt +0 -0
- {chartremotely-0.2.0 → chartremotely-0.2.2}/chartremotely.egg-info/entry_points.txt +0 -0
- {chartremotely-0.2.0 → chartremotely-0.2.2}/chartremotely.egg-info/requires.txt +0 -0
- {chartremotely-0.2.0 → chartremotely-0.2.2}/chartremotely.egg-info/top_level.txt +0 -0
- {chartremotely-0.2.0 → chartremotely-0.2.2}/setup.cfg +0 -0
- {chartremotely-0.2.0 → chartremotely-0.2.2}/tests/test_resolve.py +0 -0
- {chartremotely-0.2.0 → chartremotely-0.2.2}/tests/test_setup.py +0 -0
- {chartremotely-0.2.0 → chartremotely-0.2.2}/tests/test_snapshot.py +0 -0
- {chartremotely-0.2.0 → chartremotely-0.2.2}/tests/test_timeframe.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: chartremotely
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Remote control of desktop charting applications on macOS
|
|
5
5
|
Author: Lonnie VanZandt
|
|
6
6
|
License:
|
|
@@ -354,6 +354,18 @@ reports anything still missing.
|
|
|
354
354
|
To pair by hand instead, run `chartremotely pair` and give the code it prints
|
|
355
355
|
to your MCP client (`chart_pair_agent`), or type it on the site's Profile page.
|
|
356
356
|
|
|
357
|
+
## Releases
|
|
358
|
+
|
|
359
|
+
Every merge to `main` that changes `chartremotely/` or `pyproject.toml` is
|
|
360
|
+
published to PyPI on its own, once ruff and the tests pass: the next patch
|
|
361
|
+
after the newest `vX.Y.Z` tag, tagged and given a GitHub Release. To release a
|
|
362
|
+
minor or major version instead, raise `version` in `pyproject.toml` in the PR;
|
|
363
|
+
the merge publishes that number. Pushing a `v*` tag by hand still publishes
|
|
364
|
+
exactly that version.
|
|
365
|
+
|
|
366
|
+
Nothing on a Mac updates itself. Upgrade an installed agent with
|
|
367
|
+
`uv tool upgrade chartremotely`.
|
|
368
|
+
|
|
357
369
|
## How it finds anything
|
|
358
370
|
|
|
359
371
|
Nothing is anchored to screen coordinates. thinkorswim layouts differ per
|
|
@@ -130,6 +130,18 @@ reports anything still missing.
|
|
|
130
130
|
To pair by hand instead, run `chartremotely pair` and give the code it prints
|
|
131
131
|
to your MCP client (`chart_pair_agent`), or type it on the site's Profile page.
|
|
132
132
|
|
|
133
|
+
## Releases
|
|
134
|
+
|
|
135
|
+
Every merge to `main` that changes `chartremotely/` or `pyproject.toml` is
|
|
136
|
+
published to PyPI on its own, once ruff and the tests pass: the next patch
|
|
137
|
+
after the newest `vX.Y.Z` tag, tagged and given a GitHub Release. To release a
|
|
138
|
+
minor or major version instead, raise `version` in `pyproject.toml` in the PR;
|
|
139
|
+
the merge publishes that number. Pushing a `v*` tag by hand still publishes
|
|
140
|
+
exactly that version.
|
|
141
|
+
|
|
142
|
+
Nothing on a Mac updates itself. Upgrade an installed agent with
|
|
143
|
+
`uv tool upgrade chartremotely`.
|
|
144
|
+
|
|
133
145
|
## How it finds anything
|
|
134
146
|
|
|
135
147
|
Nothing is anchored to screen coordinates. thinkorswim layouts differ per
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""What a command answers: the words for the caller, and what it put on the chart.
|
|
2
|
+
|
|
3
|
+
Stdlib only, so the relay and the push can carry it without importing the
|
|
4
|
+
macOS drivers.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import NamedTuple
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Answer(NamedTuple):
|
|
13
|
+
"""A command's reply, plus the symbol it put on the chart when it did.
|
|
14
|
+
|
|
15
|
+
``symbol`` is set only by a successful chart change that named one; the
|
|
16
|
+
picture of that change is labelled with it, so a label never depends on
|
|
17
|
+
how the reply happens to be worded.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
reply: str
|
|
21
|
+
symbol: str | None = None
|
|
@@ -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,152 @@
|
|
|
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, labelled with the symbol the last command
|
|
7
|
+
of the burst put on the chart (the symbol field is read only as a fallback:
|
|
8
|
+
it can be stale). The operator keeps the newest picture per symbol,
|
|
9
|
+
encrypted, for an hour each.
|
|
10
|
+
|
|
11
|
+
Three rules keep it out of the way:
|
|
12
|
+
|
|
13
|
+
* **After the reply.** The listener and the relay schedule the picture only
|
|
14
|
+
once their reply has gone out, so it never delays or races the answer.
|
|
15
|
+
* **Coalesced.** Changes in quick succession produce one picture, taken when
|
|
16
|
+
the chart has been quiet for QUIET_SECONDS — never a trail of in-between
|
|
17
|
+
captures.
|
|
18
|
+
* **Serialised.** The capture holds the same cross-process lock as every
|
|
19
|
+
command that drives the chart (guilock), so it never overlaps one.
|
|
20
|
+
|
|
21
|
+
Any failure is swallowed: the chart already changed, and a missing picture
|
|
22
|
+
must never become a spoken error. Stdlib only at module scope, like the relay;
|
|
23
|
+
the capture is imported lazily.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import re
|
|
29
|
+
import threading
|
|
30
|
+
import time
|
|
31
|
+
|
|
32
|
+
from . import config, guilock, relay
|
|
33
|
+
|
|
34
|
+
#: How long the chart must go unchanged before its picture is taken.
|
|
35
|
+
QUIET_SECONDS = 1.5
|
|
36
|
+
|
|
37
|
+
#: Verbs that read or picture the chart, or only resolve words: they change
|
|
38
|
+
#: nothing, so no picture follows them. Every other request — `set`, or a bare
|
|
39
|
+
#: company name — changes the chart (see vocab.dispatch).
|
|
40
|
+
_NOT_A_CHANGE = frozenset({"resolve", "scale", "read", "snapshot"})
|
|
41
|
+
|
|
42
|
+
#: What a symbol label may look like on the wire: tickers, futures (/ES),
|
|
43
|
+
#: indices (.SPX, $SPX.X, ^VIX) and share classes (BRK/B). The operator checks
|
|
44
|
+
#: the same shape; anything else is left off rather than sent.
|
|
45
|
+
SYMBOL = re.compile(r"^[A-Z0-9./^$-]{1,15}$")
|
|
46
|
+
|
|
47
|
+
_state = threading.Condition()
|
|
48
|
+
#: The symbol the last successful chart-changing command put on the chart.
|
|
49
|
+
#: A burst's picture carries the LAST one; a change that names none keeps it.
|
|
50
|
+
_named: str | None = None
|
|
51
|
+
_due: float | None = None
|
|
52
|
+
_worker: threading.Thread | None = None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def changes_chart(request: str, reply: str) -> bool:
|
|
56
|
+
"""Whether this answered request changed what the chart shows."""
|
|
57
|
+
verb = (request or "").strip().partition(" ")[0].lower()
|
|
58
|
+
return bool(verb) and verb not in _NOT_A_CHANGE and not reply.startswith("ERR")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def symbol_label(raw: object) -> str | None:
|
|
62
|
+
"""The chart's symbol as sent to the operator, or None when it is not symbol-shaped."""
|
|
63
|
+
if not isinstance(raw, str):
|
|
64
|
+
return None
|
|
65
|
+
label = raw.strip().upper()
|
|
66
|
+
return label if SYMBOL.match(label) else None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def payload(cfg: dict, image: str, symbol: str | None = None) -> tuple[str, dict] | None:
|
|
70
|
+
"""Where to send a picture and what to send, or None when not paired.
|
|
71
|
+
|
|
72
|
+
``symbol`` is what the chart showed when the picture was taken; it is left
|
|
73
|
+
out when unknown or not symbol-shaped, and the operator files the picture
|
|
74
|
+
under a plain "Chart".
|
|
75
|
+
"""
|
|
76
|
+
base = (cfg.get("operator_url") or "").rstrip("/")
|
|
77
|
+
agent_id, secret = cfg.get("agent_id"), cfg.get("agent_secret")
|
|
78
|
+
if not (base and agent_id and secret):
|
|
79
|
+
return None
|
|
80
|
+
body = {"agent_id": agent_id, "secret": secret, "image": image}
|
|
81
|
+
label = symbol_label(symbol)
|
|
82
|
+
if label:
|
|
83
|
+
body["symbol"] = label
|
|
84
|
+
return f"{base}/agent/snapshot", body
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _showing() -> str | None:
|
|
88
|
+
"""The symbol field's value, or None. Never raises.
|
|
89
|
+
|
|
90
|
+
Only a fallback: thinkorswim does not push accessibility updates, so right
|
|
91
|
+
after a change this can still name the previous symbol. The symbol a
|
|
92
|
+
command named is preferred whenever one is known.
|
|
93
|
+
"""
|
|
94
|
+
try:
|
|
95
|
+
from . import symbol
|
|
96
|
+
return symbol_label(symbol.current())
|
|
97
|
+
except Exception: # noqa: BLE001 - a label is optional; the picture is not
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def after_reply(request: str, reply: str, symbol: str | None = None) -> None:
|
|
102
|
+
"""Call once a reply has been sent. Schedules a picture if the chart changed.
|
|
103
|
+
|
|
104
|
+
``symbol`` is what the command put on the chart (vocab.Answer.symbol), when
|
|
105
|
+
it named one; a change that names none keeps the previous command's.
|
|
106
|
+
"""
|
|
107
|
+
global _named
|
|
108
|
+
if changes_chart(request, reply):
|
|
109
|
+
with _state:
|
|
110
|
+
_named = symbol_label(symbol) or _named
|
|
111
|
+
schedule()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def schedule() -> None:
|
|
115
|
+
"""Ask for one picture once the chart has been quiet; later asks push it back."""
|
|
116
|
+
global _due, _worker
|
|
117
|
+
with _state:
|
|
118
|
+
_due = time.monotonic() + QUIET_SECONDS
|
|
119
|
+
if _worker is None or not _worker.is_alive():
|
|
120
|
+
_worker = threading.Thread(target=_wait_then_push, name="push-latest", daemon=True)
|
|
121
|
+
_worker.start()
|
|
122
|
+
_state.notify_all()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _wait_then_push() -> None:
|
|
126
|
+
global _due
|
|
127
|
+
with _state:
|
|
128
|
+
while True:
|
|
129
|
+
remaining = (_due or 0) - time.monotonic()
|
|
130
|
+
if remaining <= 0:
|
|
131
|
+
_due = None
|
|
132
|
+
break
|
|
133
|
+
_state.wait(remaining)
|
|
134
|
+
push_now()
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def push_now() -> None:
|
|
138
|
+
"""Take the chart's picture and send it. Never raises."""
|
|
139
|
+
try:
|
|
140
|
+
cfg = config.load()
|
|
141
|
+
if payload(cfg, "") is None:
|
|
142
|
+
return
|
|
143
|
+
from . import snapshot, window
|
|
144
|
+
with guilock.driving():
|
|
145
|
+
image = snapshot.as_reply(snapshot.shrink(window.capture()))
|
|
146
|
+
with _state:
|
|
147
|
+
label = _named
|
|
148
|
+
label = label or _showing()
|
|
149
|
+
url, body = payload(cfg, image, label)
|
|
150
|
+
relay._post(url, body, timeout=30)
|
|
151
|
+
except Exception: # noqa: BLE001, S110 - deliberate: see the module docstring
|
|
152
|
+
pass
|
|
@@ -19,6 +19,7 @@ import urllib.error
|
|
|
19
19
|
import urllib.request
|
|
20
20
|
|
|
21
21
|
from . import config
|
|
22
|
+
from .answer import Answer
|
|
22
23
|
|
|
23
24
|
#: Poll window. The operator holds a request open for slightly less than
|
|
24
25
|
#: this, so a timeout here means the network dropped it, not that the
|
|
@@ -32,7 +33,7 @@ class PairingError(RuntimeError):
|
|
|
32
33
|
"""Pairing could not be completed."""
|
|
33
34
|
|
|
34
35
|
|
|
35
|
-
def execute(command: str) ->
|
|
36
|
+
def execute(command: str) -> Answer:
|
|
36
37
|
"""Hand a relayed command to the agent's own vocabulary.
|
|
37
38
|
|
|
38
39
|
Imported lazily and wrapped here for one reason: the wire logic in this
|
|
@@ -40,8 +41,8 @@ def execute(command: str) -> str:
|
|
|
40
41
|
drivers in with it - making the whole file unimportable anywhere the
|
|
41
42
|
drivers are not installed, CI included.
|
|
42
43
|
"""
|
|
43
|
-
from .vocab import
|
|
44
|
-
return
|
|
44
|
+
from .vocab import answer
|
|
45
|
+
return answer(command)
|
|
45
46
|
|
|
46
47
|
|
|
47
48
|
def _post(url: str, payload: dict, timeout: float) -> dict:
|
|
@@ -134,12 +135,17 @@ def run(operator_url: str | None = None, once: bool = False) -> None:
|
|
|
134
135
|
continue
|
|
135
136
|
|
|
136
137
|
if envelope.get("command"):
|
|
137
|
-
|
|
138
|
+
command = str(envelope["command"])
|
|
139
|
+
result = execute(command)
|
|
140
|
+
reply = result.reply
|
|
138
141
|
try:
|
|
139
142
|
_post(f"{base}/agent/result",
|
|
140
143
|
{**credentials, "id": envelope.get("id"), "reply": reply},
|
|
141
144
|
timeout=20)
|
|
142
145
|
except (urllib.error.URLError, TimeoutError, OSError):
|
|
143
146
|
pass # the caller has already timed out and refunded
|
|
147
|
+
# After the reply, never before: the picture of a changed chart.
|
|
148
|
+
from . import push
|
|
149
|
+
push.after_reply(command, reply, result.symbol)
|
|
144
150
|
if once:
|
|
145
151
|
return
|
|
@@ -17,8 +17,8 @@ import secrets
|
|
|
17
17
|
import urllib.parse
|
|
18
18
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
19
19
|
|
|
20
|
-
from . import config
|
|
21
|
-
from .vocab import
|
|
20
|
+
from . import config, push
|
|
21
|
+
from .vocab import answer
|
|
22
22
|
|
|
23
23
|
|
|
24
24
|
class Handler(BaseHTTPRequestHandler):
|
|
@@ -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
|
+
result = answer(request)
|
|
65
|
+
self._reply(200, result.reply)
|
|
66
|
+
push.after_reply(request, result.reply, result.symbol)
|
|
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,8 @@ 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
|
+
from .answer import Answer
|
|
20
21
|
|
|
21
22
|
ERR = "ERR "
|
|
22
23
|
|
|
@@ -48,6 +49,11 @@ def cmd_scale(spoken: str) -> str:
|
|
|
48
49
|
|
|
49
50
|
|
|
50
51
|
def cmd_set(ticker: str, scale: str = "") -> str:
|
|
52
|
+
"""Put a ticker on the chart; see :func:`set_chart`. Returns only the words."""
|
|
53
|
+
return set_chart(ticker, scale).reply
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def set_chart(ticker: str, scale: str = "") -> Answer:
|
|
51
57
|
"""Put a ticker on the chart, optionally at a given scale.
|
|
52
58
|
|
|
53
59
|
Degrades deliberately: an unparseable scale leaves the chart alone and
|
|
@@ -56,11 +62,12 @@ def cmd_set(ticker: str, scale: str = "") -> str:
|
|
|
56
62
|
"""
|
|
57
63
|
ticker = ticker.strip().replace(" ", "")
|
|
58
64
|
if not ticker:
|
|
59
|
-
return ERR + "No symbol to show."
|
|
65
|
+
return Answer(ERR + "No symbol to show.")
|
|
60
66
|
try:
|
|
61
67
|
symbol.show(ticker)
|
|
62
68
|
except Exception as exc:
|
|
63
|
-
return ERR + _short(exc)
|
|
69
|
+
return Answer(ERR + _short(exc))
|
|
70
|
+
shown = symbol.to_ticker(ticker)
|
|
64
71
|
|
|
65
72
|
word = None
|
|
66
73
|
if scale.strip() and scale.strip().lower() not in scales.AS_IS:
|
|
@@ -76,12 +83,12 @@ def cmd_set(ticker: str, scale: str = "") -> str:
|
|
|
76
83
|
pass
|
|
77
84
|
|
|
78
85
|
if word:
|
|
79
|
-
return f"Showing {ticker} at {word}. Good luck."
|
|
86
|
+
return Answer(f"Showing {ticker} at {word}. Good luck.", shown)
|
|
80
87
|
try:
|
|
81
88
|
_, current = timeframe.current()
|
|
82
|
-
return f"Showing {ticker} at {current}, as is. Good luck."
|
|
89
|
+
return Answer(f"Showing {ticker} at {current}, as is. Good luck.", shown)
|
|
83
90
|
except Exception:
|
|
84
|
-
return f"Showing {ticker}. Good luck."
|
|
91
|
+
return Answer(f"Showing {ticker}. Good luck.", shown)
|
|
85
92
|
|
|
86
93
|
|
|
87
94
|
def cmd_read() -> str:
|
|
@@ -103,8 +110,16 @@ def cmd_snapshot() -> str:
|
|
|
103
110
|
|
|
104
111
|
|
|
105
112
|
def dispatch(request: str) -> str:
|
|
113
|
+
"""Route one request and return only its words; see :func:`answer`."""
|
|
114
|
+
return answer(request).reply
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def answer(request: str) -> Answer:
|
|
106
118
|
"""Route one request. Anything unrecognised is treated as a company name.
|
|
107
119
|
|
|
120
|
+
The Answer carries the symbol a successful change put on the chart, so
|
|
121
|
+
the picture that follows is labelled from data, not from the reply text.
|
|
122
|
+
|
|
108
123
|
The entire surface:
|
|
109
124
|
|
|
110
125
|
resolve <spoken name> -> TICKER | ERR
|
|
@@ -116,25 +131,34 @@ def dispatch(request: str) -> str:
|
|
|
116
131
|
"""
|
|
117
132
|
request = (request or "").strip()
|
|
118
133
|
if not request:
|
|
119
|
-
return ERR + "I didn't catch that."
|
|
134
|
+
return Answer(ERR + "I didn't catch that.")
|
|
120
135
|
|
|
121
136
|
verb, _, rest = request.partition(" ")
|
|
122
137
|
verb = verb.lower()
|
|
123
138
|
|
|
124
139
|
if verb == "resolve":
|
|
125
|
-
return cmd_resolve(rest)
|
|
140
|
+
return Answer(cmd_resolve(rest))
|
|
126
141
|
if verb == "scale":
|
|
127
|
-
return cmd_scale(rest)
|
|
142
|
+
return Answer(cmd_scale(rest))
|
|
128
143
|
if verb == "read":
|
|
129
|
-
return cmd_read()
|
|
144
|
+
return _driving(lambda: Answer(cmd_read()))
|
|
130
145
|
if verb == "snapshot":
|
|
131
|
-
return cmd_snapshot()
|
|
146
|
+
return _driving(lambda: Answer(cmd_snapshot()))
|
|
132
147
|
if verb == "set":
|
|
133
148
|
ticker, sep, scale = rest.partition("|")
|
|
134
|
-
return
|
|
149
|
+
return _driving(lambda: set_chart(ticker, scale if sep else ""))
|
|
135
150
|
|
|
136
151
|
# Bare name: resolve and show it.
|
|
137
152
|
ticker = cmd_resolve(request)
|
|
138
153
|
if ticker.startswith(ERR):
|
|
139
|
-
return ticker
|
|
140
|
-
return
|
|
154
|
+
return Answer(ticker)
|
|
155
|
+
return _driving(lambda: set_chart(ticker))
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _driving(command) -> Answer:
|
|
159
|
+
"""Run one command that drives the chart, never alongside another."""
|
|
160
|
+
try:
|
|
161
|
+
with guilock.driving():
|
|
162
|
+
return command()
|
|
163
|
+
except guilock.Busy as exc:
|
|
164
|
+
return Answer(ERR + str(exc))
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: chartremotely
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Remote control of desktop charting applications on macOS
|
|
5
5
|
Author: Lonnie VanZandt
|
|
6
6
|
License:
|
|
@@ -354,6 +354,18 @@ reports anything still missing.
|
|
|
354
354
|
To pair by hand instead, run `chartremotely pair` and give the code it prints
|
|
355
355
|
to your MCP client (`chart_pair_agent`), or type it on the site's Profile page.
|
|
356
356
|
|
|
357
|
+
## Releases
|
|
358
|
+
|
|
359
|
+
Every merge to `main` that changes `chartremotely/` or `pyproject.toml` is
|
|
360
|
+
published to PyPI on its own, once ruff and the tests pass: the next patch
|
|
361
|
+
after the newest `vX.Y.Z` tag, tagged and given a GitHub Release. To release a
|
|
362
|
+
minor or major version instead, raise `version` in `pyproject.toml` in the PR;
|
|
363
|
+
the merge publishes that number. Pushing a `v*` tag by hand still publishes
|
|
364
|
+
exactly that version.
|
|
365
|
+
|
|
366
|
+
Nothing on a Mac updates itself. Upgrade an installed agent with
|
|
367
|
+
`uv tool upgrade chartremotely`.
|
|
368
|
+
|
|
357
369
|
## How it finds anything
|
|
358
370
|
|
|
359
371
|
Nothing is anchored to screen coordinates. thinkorswim layouts differ per
|
|
@@ -2,10 +2,12 @@ LICENSE
|
|
|
2
2
|
README.md
|
|
3
3
|
pyproject.toml
|
|
4
4
|
chartremotely/__init__.py
|
|
5
|
+
chartremotely/answer.py
|
|
5
6
|
chartremotely/ax.py
|
|
6
7
|
chartremotely/cli.py
|
|
7
8
|
chartremotely/config.py
|
|
8
9
|
chartremotely/doctor.py
|
|
10
|
+
chartremotely/guilock.py
|
|
9
11
|
chartremotely/keystore.py
|
|
10
12
|
chartremotely/layout.py
|
|
11
13
|
chartremotely/mcpclient.py
|
|
@@ -0,0 +1,188 @@
|
|
|
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
|
+
import types
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from chartremotely import guilock, push
|
|
10
|
+
|
|
11
|
+
PAIRED = {"operator_url": "https://op.test/", "agent_id": "a1", "agent_secret": "s1"}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@pytest.fixture(autouse=True)
|
|
15
|
+
def isolated_config(tmp_path, monkeypatch):
|
|
16
|
+
monkeypatch.setattr(guilock.config, "CONFIG_DIR", tmp_path)
|
|
17
|
+
monkeypatch.setattr(push, "_named", None)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_an_unpaired_mac_pushes_nothing():
|
|
21
|
+
assert push.payload({"operator_url": None, "agent_id": None, "agent_secret": None}, "x") is None
|
|
22
|
+
assert push.payload({**PAIRED, "agent_secret": ""}, "x") is None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_the_push_carries_the_agents_identity_and_the_picture():
|
|
26
|
+
url, body = push.payload(PAIRED, "data:image/jpeg;base64,AAAA")
|
|
27
|
+
assert url == "https://op.test/agent/snapshot"
|
|
28
|
+
assert body == {"agent_id": "a1", "secret": "s1", "image": "data:image/jpeg;base64,AAAA"}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_the_push_names_the_symbol_the_chart_shows():
|
|
32
|
+
_, body = push.payload(PAIRED, "img", " pltr ")
|
|
33
|
+
assert body["symbol"] == "PLTR"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@pytest.mark.parametrize("raw", ["/ES", ".SPX", "$SPX.X", "^VIX", "BRK/B", "BRK-B", "A"])
|
|
37
|
+
def test_futures_indices_and_share_classes_are_symbol_shaped(raw):
|
|
38
|
+
assert push.symbol_label(raw) == raw
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@pytest.mark.parametrize("raw", [None, "", " ", "TOO-LONG-A-SYMBOL", "PL TR", "<b>", "PLTR;", 7])
|
|
42
|
+
def test_an_unreadable_symbol_is_left_off_rather_than_sent(raw):
|
|
43
|
+
_, body = push.payload(PAIRED, "img", raw)
|
|
44
|
+
assert "symbol" not in body
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@pytest.fixture
|
|
48
|
+
def labelled(monkeypatch):
|
|
49
|
+
"""Capture stubbed out; returns the bodies pushed. The symbol field reads OLD."""
|
|
50
|
+
import chartremotely
|
|
51
|
+
|
|
52
|
+
sent = []
|
|
53
|
+
monkeypatch.setattr(push.config, "load", lambda: PAIRED)
|
|
54
|
+
monkeypatch.setattr(push.relay, "_post", lambda url, body, timeout: sent.append(body))
|
|
55
|
+
monkeypatch.setattr(push, "_showing", lambda: "OLD")
|
|
56
|
+
monkeypatch.setattr(chartremotely, "window",
|
|
57
|
+
types.SimpleNamespace(capture=lambda: b"png"), raising=False)
|
|
58
|
+
monkeypatch.setattr(chartremotely, "snapshot",
|
|
59
|
+
types.SimpleNamespace(shrink=lambda b: b, as_reply=lambda b: "data:img"),
|
|
60
|
+
raising=False)
|
|
61
|
+
return sent
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_a_burst_is_labelled_with_its_last_set(monkeypatch, labelled):
|
|
65
|
+
monkeypatch.setattr(push, "QUIET_SECONDS", 0.1)
|
|
66
|
+
push.after_reply("set AAPL", "Showing AAPL at swing, as is. Good luck.", "AAPL")
|
|
67
|
+
push.after_reply("set MSFT | daily", "Showing MSFT at daily. Good luck.", "MSFT")
|
|
68
|
+
time.sleep(0.4)
|
|
69
|
+
assert [b["symbol"] for b in labelled] == ["MSFT"]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_a_change_naming_no_symbol_keeps_the_last_sets(monkeypatch, labelled):
|
|
73
|
+
monkeypatch.setattr(push, "schedule", lambda: None)
|
|
74
|
+
push.after_reply("set NVDA", "Showing NVDA. Good luck.", "NVDA")
|
|
75
|
+
push.after_reply("set | swing", "Scale is swing.") # a change that names no symbol
|
|
76
|
+
push.push_now()
|
|
77
|
+
assert labelled[-1]["symbol"] == "NVDA"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_a_failed_set_does_not_relabel(monkeypatch, labelled):
|
|
81
|
+
monkeypatch.setattr(push, "schedule", lambda: None)
|
|
82
|
+
push.after_reply("set NVDA", "Showing NVDA. Good luck.", "NVDA")
|
|
83
|
+
push.after_reply("set ZZZZ", "ERR no symbol field found")
|
|
84
|
+
push.push_now()
|
|
85
|
+
assert labelled[-1]["symbol"] == "NVDA"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_the_label_comes_from_the_command_not_the_wording(monkeypatch, labelled):
|
|
89
|
+
monkeypatch.setattr(push, "schedule", lambda: None)
|
|
90
|
+
push.after_reply("set TSLA", "Now on the wall: Tesla. Enjoy.", "TSLA")
|
|
91
|
+
push.push_now()
|
|
92
|
+
assert labelled[-1]["symbol"] == "TSLA"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_the_symbol_field_is_only_the_fallback(labelled):
|
|
96
|
+
push.push_now()
|
|
97
|
+
assert labelled[-1]["symbol"] == "OLD"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_a_failed_symbol_read_still_sends_the_picture(monkeypatch):
|
|
101
|
+
import chartremotely
|
|
102
|
+
|
|
103
|
+
def unreadable():
|
|
104
|
+
raise RuntimeError("no symbol field")
|
|
105
|
+
monkeypatch.setattr(chartremotely, "symbol",
|
|
106
|
+
types.SimpleNamespace(current=unreadable), raising=False)
|
|
107
|
+
assert push._showing() is None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@pytest.mark.parametrize("request_, reply, changed", [
|
|
111
|
+
("set PLTR | swing", "Showing PLTR at swing. Good luck.", True),
|
|
112
|
+
("palantir", "Showing PLTR at swing, as is. Good luck.", True),
|
|
113
|
+
("set PLTR", "ERR no symbol field found", False),
|
|
114
|
+
("resolve palantir", "PLTR", False),
|
|
115
|
+
("scale swing", "swing", False),
|
|
116
|
+
("read", "PLTR at swing", False),
|
|
117
|
+
("snapshot", "data:image/jpeg;base64,AAAA", False),
|
|
118
|
+
("", "ERR I didn't catch that.", False),
|
|
119
|
+
])
|
|
120
|
+
def test_only_an_answered_change_earns_a_picture(request_, reply, changed):
|
|
121
|
+
assert push.changes_chart(request_, reply) is changed
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_a_burst_of_changes_gives_one_picture_after_the_last(monkeypatch):
|
|
125
|
+
shots = []
|
|
126
|
+
monkeypatch.setattr(push, "QUIET_SECONDS", 0.15)
|
|
127
|
+
monkeypatch.setattr(push, "push_now", lambda: shots.append(time.monotonic()))
|
|
128
|
+
started = time.monotonic()
|
|
129
|
+
for i in range(4):
|
|
130
|
+
if i:
|
|
131
|
+
time.sleep(0.05)
|
|
132
|
+
push.after_reply("set PLTR", "Showing PLTR. Good luck.")
|
|
133
|
+
last = time.monotonic()
|
|
134
|
+
time.sleep(0.5)
|
|
135
|
+
assert len(shots) == 1, "one picture for the whole burst"
|
|
136
|
+
assert shots[0] - last >= 0.14, "taken only once the chart went quiet"
|
|
137
|
+
assert shots[0] > started
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_nothing_is_scheduled_for_a_request_that_changed_nothing(monkeypatch):
|
|
141
|
+
shots = []
|
|
142
|
+
monkeypatch.setattr(push, "QUIET_SECONDS", 0.05)
|
|
143
|
+
monkeypatch.setattr(push, "push_now", lambda: shots.append(1))
|
|
144
|
+
push.after_reply("resolve palantir", "PLTR")
|
|
145
|
+
time.sleep(0.2)
|
|
146
|
+
assert shots == []
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_a_push_never_raises_whatever_goes_wrong(monkeypatch):
|
|
150
|
+
monkeypatch.setattr(push.config, "load", lambda: PAIRED)
|
|
151
|
+
|
|
152
|
+
def boom(*a, **k):
|
|
153
|
+
raise OSError("operator unreachable")
|
|
154
|
+
monkeypatch.setattr(push.relay, "_post", boom)
|
|
155
|
+
push.push_now() # the capture or the post fails; the chart already changed
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_the_chart_is_driven_by_one_holder_at_a_time():
|
|
159
|
+
order = []
|
|
160
|
+
|
|
161
|
+
def hold(name, pause):
|
|
162
|
+
with guilock.driving():
|
|
163
|
+
order.append(f"{name} in")
|
|
164
|
+
time.sleep(pause)
|
|
165
|
+
order.append(f"{name} out")
|
|
166
|
+
|
|
167
|
+
a = threading.Thread(target=hold, args=("a", 0.2))
|
|
168
|
+
a.start()
|
|
169
|
+
time.sleep(0.05)
|
|
170
|
+
hold("b", 0)
|
|
171
|
+
a.join()
|
|
172
|
+
assert order == ["a in", "a out", "b in", "b out"]
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test_a_holder_that_never_lets_go_is_reported_busy():
|
|
176
|
+
release = threading.Event()
|
|
177
|
+
|
|
178
|
+
def hog():
|
|
179
|
+
with guilock.driving():
|
|
180
|
+
release.wait(2)
|
|
181
|
+
|
|
182
|
+
t = threading.Thread(target=hog)
|
|
183
|
+
t.start()
|
|
184
|
+
time.sleep(0.05)
|
|
185
|
+
with pytest.raises(guilock.Busy), guilock.driving(wait=0.2):
|
|
186
|
+
pass
|
|
187
|
+
release.set()
|
|
188
|
+
t.join()
|
|
@@ -12,6 +12,7 @@ from typing import ClassVar
|
|
|
12
12
|
import pytest
|
|
13
13
|
|
|
14
14
|
from chartremotely import relay
|
|
15
|
+
from chartremotely.answer import Answer
|
|
15
16
|
|
|
16
17
|
|
|
17
18
|
class StubOperator(BaseHTTPRequestHandler):
|
|
@@ -100,7 +101,7 @@ def test_a_relayed_command_is_dispatched_and_answered(operator, monkeypatch):
|
|
|
100
101
|
relay.pair(operator, on_code=lambda c: None)
|
|
101
102
|
StubOperator.commands.append({"id": "r1", "command": "read"})
|
|
102
103
|
# The vocabulary is the agent's own; stub it so no GUI is required.
|
|
103
|
-
monkeypatch.setattr(relay, "execute", lambda cmd: f"dispatched:{cmd}")
|
|
104
|
+
monkeypatch.setattr(relay, "execute", lambda cmd: Answer(f"dispatched:{cmd}"))
|
|
104
105
|
relay.run(once=True)
|
|
105
106
|
assert StubOperator.results == [
|
|
106
107
|
{"agent_id": "a1", "secret": "s1", "id": "r1", "reply": "dispatched:read"}]
|
|
@@ -109,7 +110,7 @@ def test_a_relayed_command_is_dispatched_and_answered(operator, monkeypatch):
|
|
|
109
110
|
def test_an_idle_poll_reports_nothing(operator, monkeypatch):
|
|
110
111
|
StubOperator.claimed = True
|
|
111
112
|
relay.pair(operator, on_code=lambda c: None)
|
|
112
|
-
monkeypatch.setattr(relay, "execute", lambda cmd: "should not be called")
|
|
113
|
+
monkeypatch.setattr(relay, "execute", lambda cmd: Answer("should not be called"))
|
|
113
114
|
relay.run(once=True)
|
|
114
115
|
assert StubOperator.results == []
|
|
115
116
|
|
|
@@ -141,7 +142,7 @@ def test_the_wire_logic_imports_without_the_macos_drivers():
|
|
|
141
142
|
from pathlib import Path
|
|
142
143
|
|
|
143
144
|
forbidden = {"Quartz", "AppKit", "ApplicationServices", "CoreFoundation"}
|
|
144
|
-
for name in ("relay", "resolve", "scales", "config", "registry", "snapshot", "push",
|
|
145
|
+
for name in ("relay", "resolve", "scales", "config", "registry", "snapshot", "push", "guilock",
|
|
145
146
|
"mcpclient", "keystore", "services", "tailnet", "shortcut", "setup"):
|
|
146
147
|
tree = ast.parse(Path(f"chartremotely/{name}.py").read_text())
|
|
147
148
|
for node in ast.walk(tree):
|
|
@@ -155,3 +156,23 @@ def test_the_wire_logic_imports_without_the_macos_drivers():
|
|
|
155
156
|
# are the sanctioned escape hatch.
|
|
156
157
|
if node.col_offset == 0:
|
|
157
158
|
assert not (names & forbidden), f"{name}.py imports {names & forbidden} at module scope"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def test_the_picture_is_scheduled_only_after_the_result_is_posted(operator, monkeypatch):
|
|
162
|
+
"""The reply goes out first; only then may a picture of the changed chart follow."""
|
|
163
|
+
from chartremotely import push
|
|
164
|
+
events = []
|
|
165
|
+
StubOperator.claimed = True
|
|
166
|
+
relay.pair(operator, on_code=lambda c: None)
|
|
167
|
+
StubOperator.commands.append({"id": "r2", "command": "set PLTR"})
|
|
168
|
+
monkeypatch.setattr(relay, "execute", lambda cmd: Answer("Showing PLTR. Good luck.", "PLTR"))
|
|
169
|
+
real_post = relay._post
|
|
170
|
+
|
|
171
|
+
def post(url, payload, timeout):
|
|
172
|
+
if url.endswith("/agent/result"):
|
|
173
|
+
events.append("result")
|
|
174
|
+
return real_post(url, payload, timeout)
|
|
175
|
+
monkeypatch.setattr(relay, "_post", post)
|
|
176
|
+
monkeypatch.setattr(push, "after_reply", lambda c, r, s: events.append(("picture", c, s)))
|
|
177
|
+
relay.run(once=True)
|
|
178
|
+
assert events == ["result", ("picture", "set PLTR", "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
|