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.
Files changed (46) hide show
  1. {chartremotely-0.2.0 → chartremotely-0.2.2}/PKG-INFO +13 -1
  2. {chartremotely-0.2.0 → chartremotely-0.2.2}/README.md +12 -0
  3. chartremotely-0.2.2/chartremotely/answer.py +21 -0
  4. chartremotely-0.2.2/chartremotely/guilock.py +48 -0
  5. chartremotely-0.2.2/chartremotely/push.py +152 -0
  6. {chartremotely-0.2.0 → chartremotely-0.2.2}/chartremotely/relay.py +10 -4
  7. {chartremotely-0.2.0 → chartremotely-0.2.2}/chartremotely/server.py +10 -4
  8. {chartremotely-0.2.0 → chartremotely-0.2.2}/chartremotely/vocab.py +38 -14
  9. {chartremotely-0.2.0 → chartremotely-0.2.2}/chartremotely.egg-info/PKG-INFO +13 -1
  10. {chartremotely-0.2.0 → chartremotely-0.2.2}/chartremotely.egg-info/SOURCES.txt +2 -0
  11. {chartremotely-0.2.0 → chartremotely-0.2.2}/pyproject.toml +1 -1
  12. chartremotely-0.2.2/tests/test_push.py +188 -0
  13. {chartremotely-0.2.0 → chartremotely-0.2.2}/tests/test_relay.py +24 -3
  14. chartremotely-0.2.0/chartremotely/push.py +0 -58
  15. chartremotely-0.2.0/tests/test_push.py +0 -47
  16. {chartremotely-0.2.0 → chartremotely-0.2.2}/LICENSE +0 -0
  17. {chartremotely-0.2.0 → chartremotely-0.2.2}/chartremotely/__init__.py +0 -0
  18. {chartremotely-0.2.0 → chartremotely-0.2.2}/chartremotely/assets/shortcut-template.plist +0 -0
  19. {chartremotely-0.2.0 → chartremotely-0.2.2}/chartremotely/ax.py +0 -0
  20. {chartremotely-0.2.0 → chartremotely-0.2.2}/chartremotely/cli.py +0 -0
  21. {chartremotely-0.2.0 → chartremotely-0.2.2}/chartremotely/config.py +0 -0
  22. {chartremotely-0.2.0 → chartremotely-0.2.2}/chartremotely/doctor.py +0 -0
  23. {chartremotely-0.2.0 → chartremotely-0.2.2}/chartremotely/keystore.py +0 -0
  24. {chartremotely-0.2.0 → chartremotely-0.2.2}/chartremotely/layout.py +0 -0
  25. {chartremotely-0.2.0 → chartremotely-0.2.2}/chartremotely/mcpclient.py +0 -0
  26. {chartremotely-0.2.0 → chartremotely-0.2.2}/chartremotely/registry.py +0 -0
  27. {chartremotely-0.2.0 → chartremotely-0.2.2}/chartremotely/resolve.py +0 -0
  28. {chartremotely-0.2.0 → chartremotely-0.2.2}/chartremotely/scales.py +0 -0
  29. {chartremotely-0.2.0 → chartremotely-0.2.2}/chartremotely/services.py +0 -0
  30. {chartremotely-0.2.0 → chartremotely-0.2.2}/chartremotely/setup.py +0 -0
  31. {chartremotely-0.2.0 → chartremotely-0.2.2}/chartremotely/shortcut.py +0 -0
  32. {chartremotely-0.2.0 → chartremotely-0.2.2}/chartremotely/snapshot.py +0 -0
  33. {chartremotely-0.2.0 → chartremotely-0.2.2}/chartremotely/studies.py +0 -0
  34. {chartremotely-0.2.0 → chartremotely-0.2.2}/chartremotely/symbol.py +0 -0
  35. {chartremotely-0.2.0 → chartremotely-0.2.2}/chartremotely/tailnet.py +0 -0
  36. {chartremotely-0.2.0 → chartremotely-0.2.2}/chartremotely/timeframe.py +0 -0
  37. {chartremotely-0.2.0 → chartremotely-0.2.2}/chartremotely/window.py +0 -0
  38. {chartremotely-0.2.0 → chartremotely-0.2.2}/chartremotely.egg-info/dependency_links.txt +0 -0
  39. {chartremotely-0.2.0 → chartremotely-0.2.2}/chartremotely.egg-info/entry_points.txt +0 -0
  40. {chartremotely-0.2.0 → chartremotely-0.2.2}/chartremotely.egg-info/requires.txt +0 -0
  41. {chartremotely-0.2.0 → chartremotely-0.2.2}/chartremotely.egg-info/top_level.txt +0 -0
  42. {chartremotely-0.2.0 → chartremotely-0.2.2}/setup.cfg +0 -0
  43. {chartremotely-0.2.0 → chartremotely-0.2.2}/tests/test_resolve.py +0 -0
  44. {chartremotely-0.2.0 → chartremotely-0.2.2}/tests/test_setup.py +0 -0
  45. {chartremotely-0.2.0 → chartremotely-0.2.2}/tests/test_snapshot.py +0 -0
  46. {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.0
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) -> 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 dispatch
44
- return dispatch(command)
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
- reply = execute(str(envelope["command"]))
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 dispatch
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._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
+ 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 push, registry, resolve, scales, snapshot, symbol, timeframe, window
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 push.after_change(cmd_set(ticker, scale if sep else ""))
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 push.after_change(cmd_set(ticker))
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.0
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "chartremotely"
3
- version = "0.2.0"
3
+ version = "0.2.2"
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,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