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.
Files changed (45) hide show
  1. {chartremotely-0.2.0 → chartremotely-0.2.1}/PKG-INFO +1 -1
  2. chartremotely-0.2.1/chartremotely/guilock.py +48 -0
  3. chartremotely-0.2.1/chartremotely/push.py +100 -0
  4. {chartremotely-0.2.0 → chartremotely-0.2.1}/chartremotely/relay.py +5 -1
  5. {chartremotely-0.2.0 → chartremotely-0.2.1}/chartremotely/server.py +9 -3
  6. {chartremotely-0.2.0 → chartremotely-0.2.1}/chartremotely/vocab.py +14 -5
  7. {chartremotely-0.2.0 → chartremotely-0.2.1}/chartremotely.egg-info/PKG-INFO +1 -1
  8. {chartremotely-0.2.0 → chartremotely-0.2.1}/chartremotely.egg-info/SOURCES.txt +1 -0
  9. {chartremotely-0.2.0 → chartremotely-0.2.1}/pyproject.toml +1 -1
  10. chartremotely-0.2.1/tests/test_push.py +107 -0
  11. {chartremotely-0.2.0 → chartremotely-0.2.1}/tests/test_relay.py +21 -1
  12. chartremotely-0.2.0/chartremotely/push.py +0 -58
  13. chartremotely-0.2.0/tests/test_push.py +0 -47
  14. {chartremotely-0.2.0 → chartremotely-0.2.1}/LICENSE +0 -0
  15. {chartremotely-0.2.0 → chartremotely-0.2.1}/README.md +0 -0
  16. {chartremotely-0.2.0 → chartremotely-0.2.1}/chartremotely/__init__.py +0 -0
  17. {chartremotely-0.2.0 → chartremotely-0.2.1}/chartremotely/assets/shortcut-template.plist +0 -0
  18. {chartremotely-0.2.0 → chartremotely-0.2.1}/chartremotely/ax.py +0 -0
  19. {chartremotely-0.2.0 → chartremotely-0.2.1}/chartremotely/cli.py +0 -0
  20. {chartremotely-0.2.0 → chartremotely-0.2.1}/chartremotely/config.py +0 -0
  21. {chartremotely-0.2.0 → chartremotely-0.2.1}/chartremotely/doctor.py +0 -0
  22. {chartremotely-0.2.0 → chartremotely-0.2.1}/chartremotely/keystore.py +0 -0
  23. {chartremotely-0.2.0 → chartremotely-0.2.1}/chartremotely/layout.py +0 -0
  24. {chartremotely-0.2.0 → chartremotely-0.2.1}/chartremotely/mcpclient.py +0 -0
  25. {chartremotely-0.2.0 → chartremotely-0.2.1}/chartremotely/registry.py +0 -0
  26. {chartremotely-0.2.0 → chartremotely-0.2.1}/chartremotely/resolve.py +0 -0
  27. {chartremotely-0.2.0 → chartremotely-0.2.1}/chartremotely/scales.py +0 -0
  28. {chartremotely-0.2.0 → chartremotely-0.2.1}/chartremotely/services.py +0 -0
  29. {chartremotely-0.2.0 → chartremotely-0.2.1}/chartremotely/setup.py +0 -0
  30. {chartremotely-0.2.0 → chartremotely-0.2.1}/chartremotely/shortcut.py +0 -0
  31. {chartremotely-0.2.0 → chartremotely-0.2.1}/chartremotely/snapshot.py +0 -0
  32. {chartremotely-0.2.0 → chartremotely-0.2.1}/chartremotely/studies.py +0 -0
  33. {chartremotely-0.2.0 → chartremotely-0.2.1}/chartremotely/symbol.py +0 -0
  34. {chartremotely-0.2.0 → chartremotely-0.2.1}/chartremotely/tailnet.py +0 -0
  35. {chartremotely-0.2.0 → chartremotely-0.2.1}/chartremotely/timeframe.py +0 -0
  36. {chartremotely-0.2.0 → chartremotely-0.2.1}/chartremotely/window.py +0 -0
  37. {chartremotely-0.2.0 → chartremotely-0.2.1}/chartremotely.egg-info/dependency_links.txt +0 -0
  38. {chartremotely-0.2.0 → chartremotely-0.2.1}/chartremotely.egg-info/entry_points.txt +0 -0
  39. {chartremotely-0.2.0 → chartremotely-0.2.1}/chartremotely.egg-info/requires.txt +0 -0
  40. {chartremotely-0.2.0 → chartremotely-0.2.1}/chartremotely.egg-info/top_level.txt +0 -0
  41. {chartremotely-0.2.0 → chartremotely-0.2.1}/setup.cfg +0 -0
  42. {chartremotely-0.2.0 → chartremotely-0.2.1}/tests/test_resolve.py +0 -0
  43. {chartremotely-0.2.0 → chartremotely-0.2.1}/tests/test_setup.py +0 -0
  44. {chartremotely-0.2.0 → chartremotely-0.2.1}/tests/test_snapshot.py +0 -0
  45. {chartremotely-0.2.0 → chartremotely-0.2.1}/tests/test_timeframe.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chartremotely
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Remote control of desktop charting applications on macOS
5
5
  Author: Lonnie VanZandt
6
6
  License:
@@ -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
- reply = execute(str(envelope["command"]))
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._reply(200, dispatch(raw))
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._reply(200, dispatch((query.get("cmd") or [""])[0].strip()))
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 push, registry, resolve, scales, snapshot, symbol, timeframe, window
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 push.after_change(cmd_set(ticker, scale if sep else ""))
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 push.after_change(cmd_set(ticker))
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)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chartremotely
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Remote control of desktop charting applications on macOS
5
5
  Author: Lonnie VanZandt
6
6
  License:
@@ -6,6 +6,7 @@ chartremotely/ax.py
6
6
  chartremotely/cli.py
7
7
  chartremotely/config.py
8
8
  chartremotely/doctor.py
9
+ chartremotely/guilock.py
9
10
  chartremotely/keystore.py
10
11
  chartremotely/layout.py
11
12
  chartremotely/mcpclient.py
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "chartremotely"
3
- version = "0.2.0"
3
+ version = "0.2.1"
4
4
  description = "Remote control of desktop charting applications on macOS"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -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