chartremotely 0.2.1__tar.gz → 0.2.3__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 (44) hide show
  1. {chartremotely-0.2.1 → chartremotely-0.2.3}/PKG-INFO +14 -2
  2. {chartremotely-0.2.1 → chartremotely-0.2.3}/README.md +12 -0
  3. chartremotely-0.2.3/chartremotely/answer.py +21 -0
  4. {chartremotely-0.2.1 → chartremotely-0.2.3}/chartremotely/push.py +60 -8
  5. {chartremotely-0.2.1 → chartremotely-0.2.3}/chartremotely/relay.py +7 -5
  6. {chartremotely-0.2.1 → chartremotely-0.2.3}/chartremotely/server.py +4 -4
  7. {chartremotely-0.2.1 → chartremotely-0.2.3}/chartremotely/vocab.py +30 -15
  8. {chartremotely-0.2.1 → chartremotely-0.2.3}/chartremotely.egg-info/PKG-INFO +14 -2
  9. {chartremotely-0.2.1 → chartremotely-0.2.3}/chartremotely.egg-info/SOURCES.txt +1 -0
  10. {chartremotely-0.2.1 → chartremotely-0.2.3}/pyproject.toml +2 -2
  11. {chartremotely-0.2.1 → chartremotely-0.2.3}/tests/test_push.py +81 -0
  12. {chartremotely-0.2.1 → chartremotely-0.2.3}/tests/test_relay.py +6 -5
  13. {chartremotely-0.2.1 → chartremotely-0.2.3}/LICENSE +0 -0
  14. {chartremotely-0.2.1 → chartremotely-0.2.3}/chartremotely/__init__.py +0 -0
  15. {chartremotely-0.2.1 → chartremotely-0.2.3}/chartremotely/assets/shortcut-template.plist +0 -0
  16. {chartremotely-0.2.1 → chartremotely-0.2.3}/chartremotely/ax.py +0 -0
  17. {chartremotely-0.2.1 → chartremotely-0.2.3}/chartremotely/cli.py +0 -0
  18. {chartremotely-0.2.1 → chartremotely-0.2.3}/chartremotely/config.py +0 -0
  19. {chartremotely-0.2.1 → chartremotely-0.2.3}/chartremotely/doctor.py +0 -0
  20. {chartremotely-0.2.1 → chartremotely-0.2.3}/chartremotely/guilock.py +0 -0
  21. {chartremotely-0.2.1 → chartremotely-0.2.3}/chartremotely/keystore.py +0 -0
  22. {chartremotely-0.2.1 → chartremotely-0.2.3}/chartremotely/layout.py +0 -0
  23. {chartremotely-0.2.1 → chartremotely-0.2.3}/chartremotely/mcpclient.py +0 -0
  24. {chartremotely-0.2.1 → chartremotely-0.2.3}/chartremotely/registry.py +0 -0
  25. {chartremotely-0.2.1 → chartremotely-0.2.3}/chartremotely/resolve.py +0 -0
  26. {chartremotely-0.2.1 → chartremotely-0.2.3}/chartremotely/scales.py +0 -0
  27. {chartremotely-0.2.1 → chartremotely-0.2.3}/chartremotely/services.py +0 -0
  28. {chartremotely-0.2.1 → chartremotely-0.2.3}/chartremotely/setup.py +0 -0
  29. {chartremotely-0.2.1 → chartremotely-0.2.3}/chartremotely/shortcut.py +0 -0
  30. {chartremotely-0.2.1 → chartremotely-0.2.3}/chartremotely/snapshot.py +0 -0
  31. {chartremotely-0.2.1 → chartremotely-0.2.3}/chartremotely/studies.py +0 -0
  32. {chartremotely-0.2.1 → chartremotely-0.2.3}/chartremotely/symbol.py +0 -0
  33. {chartremotely-0.2.1 → chartremotely-0.2.3}/chartremotely/tailnet.py +0 -0
  34. {chartremotely-0.2.1 → chartremotely-0.2.3}/chartremotely/timeframe.py +0 -0
  35. {chartremotely-0.2.1 → chartremotely-0.2.3}/chartremotely/window.py +0 -0
  36. {chartremotely-0.2.1 → chartremotely-0.2.3}/chartremotely.egg-info/dependency_links.txt +0 -0
  37. {chartremotely-0.2.1 → chartremotely-0.2.3}/chartremotely.egg-info/entry_points.txt +0 -0
  38. {chartremotely-0.2.1 → chartremotely-0.2.3}/chartremotely.egg-info/requires.txt +0 -0
  39. {chartremotely-0.2.1 → chartremotely-0.2.3}/chartremotely.egg-info/top_level.txt +0 -0
  40. {chartremotely-0.2.1 → chartremotely-0.2.3}/setup.cfg +0 -0
  41. {chartremotely-0.2.1 → chartremotely-0.2.3}/tests/test_resolve.py +0 -0
  42. {chartremotely-0.2.1 → chartremotely-0.2.3}/tests/test_setup.py +0 -0
  43. {chartremotely-0.2.1 → chartremotely-0.2.3}/tests/test_snapshot.py +0 -0
  44. {chartremotely-0.2.1 → chartremotely-0.2.3}/tests/test_timeframe.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chartremotely
3
- Version: 0.2.1
3
+ Version: 0.2.3
4
4
  Summary: Remote control of desktop charting applications on macOS
5
5
  Author: Lonnie VanZandt
6
6
  License:
@@ -207,7 +207,7 @@ License:
207
207
  limitations under the License.
208
208
 
209
209
  Keywords: thinkorswim,accessibility,automation,charting
210
- Requires-Python: >=3.11
210
+ Requires-Python: >=3.12
211
211
  Description-Content-Type: text/markdown
212
212
  License-File: LICENSE
213
213
  Provides-Extra: macos
@@ -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
@@ -3,8 +3,10 @@
3
3
  A chart changed by voice goes straight to this machine over the tailnet, so
4
4
  the operator never hears of it. Once a change is answered — after the reply
5
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.
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.
8
10
 
9
11
  Three rules keep it out of the way:
10
12
 
@@ -23,6 +25,7 @@ the capture is imported lazily.
23
25
 
24
26
  from __future__ import annotations
25
27
 
28
+ import re
26
29
  import threading
27
30
  import time
28
31
 
@@ -36,7 +39,15 @@ QUIET_SECONDS = 1.5
36
39
  #: company name — changes the chart (see vocab.dispatch).
37
40
  _NOT_A_CHANGE = frozenset({"resolve", "scale", "read", "snapshot"})
38
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
+
39
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
40
51
  _due: float | None = None
41
52
  _worker: threading.Thread | None = None
42
53
 
@@ -47,18 +58,56 @@ def changes_chart(request: str, reply: str) -> bool:
47
58
  return bool(verb) and verb not in _NOT_A_CHANGE and not reply.startswith("ERR")
48
59
 
49
60
 
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."""
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
+ """
52
76
  base = (cfg.get("operator_url") or "").rstrip("/")
53
77
  agent_id, secret = cfg.get("agent_id"), cfg.get("agent_secret")
54
78
  if not (base and agent_id and secret):
55
79
  return None
56
- return f"{base}/agent/snapshot", {"agent_id": agent_id, "secret": secret, "image": image}
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
+
57
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.
58
103
 
59
- def after_reply(request: str, reply: str) -> None:
60
- """Call once a reply has been sent. Schedules a picture if the chart changed."""
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
61
108
  if changes_chart(request, reply):
109
+ with _state:
110
+ _named = symbol_label(symbol) or _named
62
111
  schedule()
63
112
 
64
113
 
@@ -94,7 +143,10 @@ def push_now() -> None:
94
143
  from . import snapshot, window
95
144
  with guilock.driving():
96
145
  image = snapshot.as_reply(snapshot.shrink(window.capture()))
97
- url, body = payload(cfg, image)
146
+ with _state:
147
+ label = _named
148
+ label = label or _showing()
149
+ url, body = payload(cfg, image, label)
98
150
  relay._post(url, body, timeout=30)
99
151
  except Exception: # noqa: BLE001, S110 - deliberate: see the module docstring
100
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:
@@ -135,7 +136,8 @@ def run(operator_url: str | None = None, once: bool = False) -> None:
135
136
 
136
137
  if envelope.get("command"):
137
138
  command = str(envelope["command"])
138
- reply = execute(command)
139
+ result = execute(command)
140
+ reply = result.reply
139
141
  try:
140
142
  _post(f"{base}/agent/result",
141
143
  {**credentials, "id": envelope.get("id"), "reply": reply},
@@ -144,6 +146,6 @@ def run(operator_url: str | None = None, once: bool = False) -> None:
144
146
  pass # the caller has already timed out and refunded
145
147
  # After the reply, never before: the picture of a changed chart.
146
148
  from . import push
147
- push.after_reply(command, reply)
149
+ push.after_reply(command, reply, result.symbol)
148
150
  if once:
149
151
  return
@@ -18,7 +18,7 @@ import urllib.parse
18
18
  from http.server import BaseHTTPRequestHandler, HTTPServer
19
19
 
20
20
  from . import config, push
21
- from .vocab import dispatch
21
+ from .vocab import answer
22
22
 
23
23
 
24
24
  class Handler(BaseHTTPRequestHandler):
@@ -61,9 +61,9 @@ class Handler(BaseHTTPRequestHandler):
61
61
 
62
62
  def _answer(self, request: str) -> None:
63
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)
64
+ result = answer(request)
65
+ self._reply(200, result.reply)
66
+ push.after_reply(request, result.reply, result.symbol)
67
67
 
68
68
  def log_message(self, *args) -> None:
69
69
  """Silence. Requests carry spoken input; do not write it to a log."""
@@ -17,6 +17,7 @@ Two rules the callers depend on:
17
17
  from __future__ import annotations
18
18
 
19
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,34 +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 _driving(cmd_read)
144
+ return _driving(lambda: Answer(cmd_read()))
130
145
  if verb == "snapshot":
131
- return _driving(cmd_snapshot)
146
+ return _driving(lambda: Answer(cmd_snapshot()))
132
147
  if verb == "set":
133
148
  ticker, sep, scale = rest.partition("|")
134
- return _driving(lambda: 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 _driving(lambda: cmd_set(ticker))
154
+ return Answer(ticker)
155
+ return _driving(lambda: set_chart(ticker))
141
156
 
142
157
 
143
- def _driving(command) -> str:
158
+ def _driving(command) -> Answer:
144
159
  """Run one command that drives the chart, never alongside another."""
145
160
  try:
146
161
  with guilock.driving():
147
162
  return command()
148
163
  except guilock.Busy as exc:
149
- return ERR + str(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.1
3
+ Version: 0.2.3
4
4
  Summary: Remote control of desktop charting applications on macOS
5
5
  Author: Lonnie VanZandt
6
6
  License:
@@ -207,7 +207,7 @@ License:
207
207
  limitations under the License.
208
208
 
209
209
  Keywords: thinkorswim,accessibility,automation,charting
210
- Requires-Python: >=3.11
210
+ Requires-Python: >=3.12
211
211
  Description-Content-Type: text/markdown
212
212
  License-File: LICENSE
213
213
  Provides-Extra: macos
@@ -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,6 +2,7 @@ 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
@@ -1,9 +1,9 @@
1
1
  [project]
2
2
  name = "chartremotely"
3
- version = "0.2.1"
3
+ version = "0.2.3"
4
4
  description = "Remote control of desktop charting applications on macOS"
5
5
  readme = "README.md"
6
- requires-python = ">=3.11"
6
+ requires-python = ">=3.12"
7
7
  license = { file = "LICENSE" }
8
8
  authors = [{ name = "Lonnie VanZandt" }]
9
9
  keywords = ["thinkorswim", "accessibility", "automation", "charting"]
@@ -2,6 +2,7 @@
2
2
 
3
3
  import threading
4
4
  import time
5
+ import types
5
6
 
6
7
  import pytest
7
8
 
@@ -13,6 +14,7 @@ PAIRED = {"operator_url": "https://op.test/", "agent_id": "a1", "agent_secret":
13
14
  @pytest.fixture(autouse=True)
14
15
  def isolated_config(tmp_path, monkeypatch):
15
16
  monkeypatch.setattr(guilock.config, "CONFIG_DIR", tmp_path)
17
+ monkeypatch.setattr(push, "_named", None)
16
18
 
17
19
 
18
20
  def test_an_unpaired_mac_pushes_nothing():
@@ -26,6 +28,85 @@ def test_the_push_carries_the_agents_identity_and_the_picture():
26
28
  assert body == {"agent_id": "a1", "secret": "s1", "image": "data:image/jpeg;base64,AAAA"}
27
29
 
28
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
+
29
110
  @pytest.mark.parametrize("request_, reply, changed", [
30
111
  ("set PLTR | swing", "Showing PLTR at swing. Good luck.", True),
31
112
  ("palantir", "Showing PLTR at swing, as is. Good luck.", True),
@@ -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
 
@@ -164,7 +165,7 @@ def test_the_picture_is_scheduled_only_after_the_result_is_posted(operator, monk
164
165
  StubOperator.claimed = True
165
166
  relay.pair(operator, on_code=lambda c: None)
166
167
  StubOperator.commands.append({"id": "r2", "command": "set PLTR"})
167
- monkeypatch.setattr(relay, "execute", lambda cmd: "Showing PLTR. Good luck.")
168
+ monkeypatch.setattr(relay, "execute", lambda cmd: Answer("Showing PLTR. Good luck.", "PLTR"))
168
169
  real_post = relay._post
169
170
 
170
171
  def post(url, payload, timeout):
@@ -172,6 +173,6 @@ def test_the_picture_is_scheduled_only_after_the_result_is_posted(operator, monk
172
173
  events.append("result")
173
174
  return real_post(url, payload, timeout)
174
175
  monkeypatch.setattr(relay, "_post", post)
175
- monkeypatch.setattr(push, "after_reply", lambda c, r: events.append(("picture", c)))
176
+ monkeypatch.setattr(push, "after_reply", lambda c, r, s: events.append(("picture", c, s)))
176
177
  relay.run(once=True)
177
- assert events == ["result", ("picture", "set PLTR")]
178
+ assert events == ["result", ("picture", "set PLTR", "PLTR")]
File without changes
File without changes