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.
- {chartremotely-0.2.1 → chartremotely-0.2.3}/PKG-INFO +14 -2
- {chartremotely-0.2.1 → chartremotely-0.2.3}/README.md +12 -0
- chartremotely-0.2.3/chartremotely/answer.py +21 -0
- {chartremotely-0.2.1 → chartremotely-0.2.3}/chartremotely/push.py +60 -8
- {chartremotely-0.2.1 → chartremotely-0.2.3}/chartremotely/relay.py +7 -5
- {chartremotely-0.2.1 → chartremotely-0.2.3}/chartremotely/server.py +4 -4
- {chartremotely-0.2.1 → chartremotely-0.2.3}/chartremotely/vocab.py +30 -15
- {chartremotely-0.2.1 → chartremotely-0.2.3}/chartremotely.egg-info/PKG-INFO +14 -2
- {chartremotely-0.2.1 → chartremotely-0.2.3}/chartremotely.egg-info/SOURCES.txt +1 -0
- {chartremotely-0.2.1 → chartremotely-0.2.3}/pyproject.toml +2 -2
- {chartremotely-0.2.1 → chartremotely-0.2.3}/tests/test_push.py +81 -0
- {chartremotely-0.2.1 → chartremotely-0.2.3}/tests/test_relay.py +6 -5
- {chartremotely-0.2.1 → chartremotely-0.2.3}/LICENSE +0 -0
- {chartremotely-0.2.1 → chartremotely-0.2.3}/chartremotely/__init__.py +0 -0
- {chartremotely-0.2.1 → chartremotely-0.2.3}/chartremotely/assets/shortcut-template.plist +0 -0
- {chartremotely-0.2.1 → chartremotely-0.2.3}/chartremotely/ax.py +0 -0
- {chartremotely-0.2.1 → chartremotely-0.2.3}/chartremotely/cli.py +0 -0
- {chartremotely-0.2.1 → chartremotely-0.2.3}/chartremotely/config.py +0 -0
- {chartremotely-0.2.1 → chartremotely-0.2.3}/chartremotely/doctor.py +0 -0
- {chartremotely-0.2.1 → chartremotely-0.2.3}/chartremotely/guilock.py +0 -0
- {chartremotely-0.2.1 → chartremotely-0.2.3}/chartremotely/keystore.py +0 -0
- {chartremotely-0.2.1 → chartremotely-0.2.3}/chartremotely/layout.py +0 -0
- {chartremotely-0.2.1 → chartremotely-0.2.3}/chartremotely/mcpclient.py +0 -0
- {chartremotely-0.2.1 → chartremotely-0.2.3}/chartremotely/registry.py +0 -0
- {chartremotely-0.2.1 → chartremotely-0.2.3}/chartremotely/resolve.py +0 -0
- {chartremotely-0.2.1 → chartremotely-0.2.3}/chartremotely/scales.py +0 -0
- {chartremotely-0.2.1 → chartremotely-0.2.3}/chartremotely/services.py +0 -0
- {chartremotely-0.2.1 → chartremotely-0.2.3}/chartremotely/setup.py +0 -0
- {chartremotely-0.2.1 → chartremotely-0.2.3}/chartremotely/shortcut.py +0 -0
- {chartremotely-0.2.1 → chartremotely-0.2.3}/chartremotely/snapshot.py +0 -0
- {chartremotely-0.2.1 → chartremotely-0.2.3}/chartremotely/studies.py +0 -0
- {chartremotely-0.2.1 → chartremotely-0.2.3}/chartremotely/symbol.py +0 -0
- {chartremotely-0.2.1 → chartremotely-0.2.3}/chartremotely/tailnet.py +0 -0
- {chartremotely-0.2.1 → chartremotely-0.2.3}/chartremotely/timeframe.py +0 -0
- {chartremotely-0.2.1 → chartremotely-0.2.3}/chartremotely/window.py +0 -0
- {chartremotely-0.2.1 → chartremotely-0.2.3}/chartremotely.egg-info/dependency_links.txt +0 -0
- {chartremotely-0.2.1 → chartremotely-0.2.3}/chartremotely.egg-info/entry_points.txt +0 -0
- {chartremotely-0.2.1 → chartremotely-0.2.3}/chartremotely.egg-info/requires.txt +0 -0
- {chartremotely-0.2.1 → chartremotely-0.2.3}/chartremotely.egg-info/top_level.txt +0 -0
- {chartremotely-0.2.1 → chartremotely-0.2.3}/setup.cfg +0 -0
- {chartremotely-0.2.1 → chartremotely-0.2.3}/tests/test_resolve.py +0 -0
- {chartremotely-0.2.1 → chartremotely-0.2.3}/tests/test_setup.py +0 -0
- {chartremotely-0.2.1 → chartremotely-0.2.3}/tests/test_snapshot.py +0 -0
- {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.
|
|
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.
|
|
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
|
|
7
|
-
|
|
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
|
|
51
|
-
"""
|
|
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
|
-
|
|
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
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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) ->
|
|
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:
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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:
|
|
154
|
+
return Answer(ticker)
|
|
155
|
+
return _driving(lambda: set_chart(ticker))
|
|
141
156
|
|
|
142
157
|
|
|
143
|
-
def _driving(command) ->
|
|
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.
|
|
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.
|
|
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
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "chartremotely"
|
|
3
|
-
version = "0.2.
|
|
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.
|
|
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
|
|
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
|