chartremotely 0.2.7__tar.gz → 0.2.8__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.7 → chartremotely-0.2.8}/PKG-INFO +1 -1
- {chartremotely-0.2.7 → chartremotely-0.2.8}/chartremotely/answer.py +4 -2
- {chartremotely-0.2.7 → chartremotely-0.2.8}/chartremotely/push.py +33 -11
- {chartremotely-0.2.7 → chartremotely-0.2.8}/chartremotely/relay.py +1 -1
- {chartremotely-0.2.7 → chartremotely-0.2.8}/chartremotely/server.py +1 -1
- {chartremotely-0.2.7 → chartremotely-0.2.8}/chartremotely/vocab.py +2 -2
- {chartremotely-0.2.7 → chartremotely-0.2.8}/chartremotely.egg-info/PKG-INFO +1 -1
- {chartremotely-0.2.7 → chartremotely-0.2.8}/pyproject.toml +1 -1
- {chartremotely-0.2.7 → chartremotely-0.2.8}/tests/test_push.py +27 -0
- {chartremotely-0.2.7 → chartremotely-0.2.8}/tests/test_relay.py +3 -3
- {chartremotely-0.2.7 → chartremotely-0.2.8}/LICENSE +0 -0
- {chartremotely-0.2.7 → chartremotely-0.2.8}/README.md +0 -0
- {chartremotely-0.2.7 → chartremotely-0.2.8}/chartremotely/__init__.py +0 -0
- {chartremotely-0.2.7 → chartremotely-0.2.8}/chartremotely/assets/shortcut-template.plist +0 -0
- {chartremotely-0.2.7 → chartremotely-0.2.8}/chartremotely/ax.py +0 -0
- {chartremotely-0.2.7 → chartremotely-0.2.8}/chartremotely/cli.py +0 -0
- {chartremotely-0.2.7 → chartremotely-0.2.8}/chartremotely/config.py +0 -0
- {chartremotely-0.2.7 → chartremotely-0.2.8}/chartremotely/doctor.py +0 -0
- {chartremotely-0.2.7 → chartremotely-0.2.8}/chartremotely/forward.py +0 -0
- {chartremotely-0.2.7 → chartremotely-0.2.8}/chartremotely/guilock.py +0 -0
- {chartremotely-0.2.7 → chartremotely-0.2.8}/chartremotely/keystore.py +0 -0
- {chartremotely-0.2.7 → chartremotely-0.2.8}/chartremotely/layout.py +0 -0
- {chartremotely-0.2.7 → chartremotely-0.2.8}/chartremotely/mcpclient.py +0 -0
- {chartremotely-0.2.7 → chartremotely-0.2.8}/chartremotely/registry.py +0 -0
- {chartremotely-0.2.7 → chartremotely-0.2.8}/chartremotely/requestlog.py +0 -0
- {chartremotely-0.2.7 → chartremotely-0.2.8}/chartremotely/resolve.py +0 -0
- {chartremotely-0.2.7 → chartremotely-0.2.8}/chartremotely/scales.py +0 -0
- {chartremotely-0.2.7 → chartremotely-0.2.8}/chartremotely/services.py +0 -0
- {chartremotely-0.2.7 → chartremotely-0.2.8}/chartremotely/setup.py +0 -0
- {chartremotely-0.2.7 → chartremotely-0.2.8}/chartremotely/shortcut.py +0 -0
- {chartremotely-0.2.7 → chartremotely-0.2.8}/chartremotely/snapshot.py +0 -0
- {chartremotely-0.2.7 → chartremotely-0.2.8}/chartremotely/studies.py +0 -0
- {chartremotely-0.2.7 → chartremotely-0.2.8}/chartremotely/symbol.py +0 -0
- {chartremotely-0.2.7 → chartremotely-0.2.8}/chartremotely/tailnet.py +0 -0
- {chartremotely-0.2.7 → chartremotely-0.2.8}/chartremotely/timeframe.py +0 -0
- {chartremotely-0.2.7 → chartremotely-0.2.8}/chartremotely/window.py +0 -0
- {chartremotely-0.2.7 → chartremotely-0.2.8}/chartremotely.egg-info/SOURCES.txt +0 -0
- {chartremotely-0.2.7 → chartremotely-0.2.8}/chartremotely.egg-info/dependency_links.txt +0 -0
- {chartremotely-0.2.7 → chartremotely-0.2.8}/chartremotely.egg-info/entry_points.txt +0 -0
- {chartremotely-0.2.7 → chartremotely-0.2.8}/chartremotely.egg-info/requires.txt +0 -0
- {chartremotely-0.2.7 → chartremotely-0.2.8}/chartremotely.egg-info/top_level.txt +0 -0
- {chartremotely-0.2.7 → chartremotely-0.2.8}/setup.cfg +0 -0
- {chartremotely-0.2.7 → chartremotely-0.2.8}/tests/test_forward.py +0 -0
- {chartremotely-0.2.7 → chartremotely-0.2.8}/tests/test_requestlog.py +0 -0
- {chartremotely-0.2.7 → chartremotely-0.2.8}/tests/test_resolve.py +0 -0
- {chartremotely-0.2.7 → chartremotely-0.2.8}/tests/test_setup.py +0 -0
- {chartremotely-0.2.7 → chartremotely-0.2.8}/tests/test_snapshot.py +0 -0
- {chartremotely-0.2.7 → chartremotely-0.2.8}/tests/test_stamp_changelog.py +0 -0
- {chartremotely-0.2.7 → chartremotely-0.2.8}/tests/test_timeframe.py +0 -0
|
@@ -10,12 +10,14 @@ from typing import NamedTuple
|
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
class Answer(NamedTuple):
|
|
13
|
-
"""A command's reply, plus the symbol it put on the chart when it did.
|
|
13
|
+
"""A command's reply, plus the symbol and scale it put on the chart when it did.
|
|
14
14
|
|
|
15
15
|
``symbol`` is set only by a successful chart change that named one; the
|
|
16
16
|
picture of that change is labelled with it, so a label never depends on
|
|
17
|
-
how the reply happens to be worded.
|
|
17
|
+
how the reply happens to be worded. ``scale`` is the time frame the reply
|
|
18
|
+
states ("half", "30 minutes"), or None when the change could not tell.
|
|
18
19
|
"""
|
|
19
20
|
|
|
20
21
|
reply: str
|
|
21
22
|
symbol: str | None = None
|
|
23
|
+
scale: str | None = None
|
|
@@ -3,9 +3,9 @@
|
|
|
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, labelled with the symbol
|
|
7
|
-
of the burst put on the chart (the symbol field is read only as
|
|
8
|
-
it can be stale). The operator keeps the newest picture per symbol,
|
|
6
|
+
the patron's browser can offer it, labelled with the symbol and scale the
|
|
7
|
+
last command of the burst put on the chart (the symbol field is read only as
|
|
8
|
+
a fallback: it can be stale; the scale is never read back). The operator keeps the newest picture per symbol,
|
|
9
9
|
encrypted, for an hour each.
|
|
10
10
|
|
|
11
11
|
Three rules keep it out of the way:
|
|
@@ -44,10 +44,16 @@ _NOT_A_CHANGE = frozenset({"resolve", "scale", "read", "snapshot"})
|
|
|
44
44
|
#: the same shape; anything else is left off rather than sent.
|
|
45
45
|
SYMBOL = re.compile(r"^[A-Z0-9./^$-]{1,15}$")
|
|
46
46
|
|
|
47
|
+
#: What a scale label may look like on the wire: the words a reply states
|
|
48
|
+
#: ("half", "30 minutes", "1 day"). The operator checks the same shape.
|
|
49
|
+
SCALE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9 .:/-]{0,23}$")
|
|
50
|
+
|
|
47
51
|
_state = threading.Condition()
|
|
48
52
|
#: The symbol the last successful chart-changing command put on the chart.
|
|
49
53
|
#: A burst's picture carries the LAST one; a change that names none keeps it.
|
|
50
54
|
_named: str | None = None
|
|
55
|
+
#: The scale the last chart-changing command stated; kept the same way.
|
|
56
|
+
_scaled: str | None = None
|
|
51
57
|
_due: float | None = None
|
|
52
58
|
_worker: threading.Thread | None = None
|
|
53
59
|
|
|
@@ -66,12 +72,22 @@ def symbol_label(raw: object) -> str | None:
|
|
|
66
72
|
return label if SYMBOL.match(label) else None
|
|
67
73
|
|
|
68
74
|
|
|
69
|
-
def
|
|
75
|
+
def scale_label(raw: object) -> str | None:
|
|
76
|
+
"""The chart's scale as sent to the operator, or None when it is not scale-shaped."""
|
|
77
|
+
if not isinstance(raw, str):
|
|
78
|
+
return None
|
|
79
|
+
label = " ".join(raw.split())
|
|
80
|
+
return label if SCALE.match(label) else None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def payload(cfg: dict, image: str, symbol: str | None = None,
|
|
84
|
+
scale: str | None = None) -> tuple[str, dict] | None:
|
|
70
85
|
"""Where to send a picture and what to send, or None when not paired.
|
|
71
86
|
|
|
72
87
|
``symbol`` is what the chart showed when the picture was taken; it is left
|
|
73
88
|
out when unknown or not symbol-shaped, and the operator files the picture
|
|
74
|
-
under a plain "Chart".
|
|
89
|
+
under a plain "Chart". ``scale`` is the time frame the last change stated;
|
|
90
|
+
it is left out the same way.
|
|
75
91
|
"""
|
|
76
92
|
base = (cfg.get("operator_url") or "").rstrip("/")
|
|
77
93
|
agent_id, secret = cfg.get("agent_id"), cfg.get("agent_secret")
|
|
@@ -81,6 +97,9 @@ def payload(cfg: dict, image: str, symbol: str | None = None) -> tuple[str, dict
|
|
|
81
97
|
label = symbol_label(symbol)
|
|
82
98
|
if label:
|
|
83
99
|
body["symbol"] = label
|
|
100
|
+
scaled = scale_label(scale)
|
|
101
|
+
if scaled:
|
|
102
|
+
body["scale"] = scaled
|
|
84
103
|
return f"{base}/agent/snapshot", body
|
|
85
104
|
|
|
86
105
|
|
|
@@ -98,16 +117,19 @@ def _showing() -> str | None:
|
|
|
98
117
|
return None
|
|
99
118
|
|
|
100
119
|
|
|
101
|
-
def after_reply(request: str, reply: str, symbol: str | None = None
|
|
120
|
+
def after_reply(request: str, reply: str, symbol: str | None = None,
|
|
121
|
+
scale: str | None = None) -> None:
|
|
102
122
|
"""Call once a reply has been sent. Schedules a picture if the chart changed.
|
|
103
123
|
|
|
104
|
-
``symbol``
|
|
105
|
-
it named
|
|
124
|
+
``symbol`` and ``scale`` are what the command put on the chart
|
|
125
|
+
(vocab.Answer), when it named them; a change that names neither keeps the
|
|
126
|
+
previous command's.
|
|
106
127
|
"""
|
|
107
|
-
global _named
|
|
128
|
+
global _named, _scaled
|
|
108
129
|
if changes_chart(request, reply):
|
|
109
130
|
with _state:
|
|
110
131
|
_named = symbol_label(symbol) or _named
|
|
132
|
+
_scaled = scale_label(scale) or _scaled
|
|
111
133
|
schedule()
|
|
112
134
|
|
|
113
135
|
|
|
@@ -144,9 +166,9 @@ def push_now() -> None:
|
|
|
144
166
|
with guilock.driving():
|
|
145
167
|
image = snapshot.as_reply(snapshot.shrink(window.capture()))
|
|
146
168
|
with _state:
|
|
147
|
-
label = _named
|
|
169
|
+
label, scaled = _named, _scaled
|
|
148
170
|
label = label or _showing()
|
|
149
|
-
url, body = payload(cfg, image, label)
|
|
171
|
+
url, body = payload(cfg, image, label, scaled)
|
|
150
172
|
relay._post(url, body, timeout=30)
|
|
151
173
|
except Exception: # noqa: BLE001, S110 - deliberate: see the module docstring
|
|
152
174
|
pass
|
|
@@ -147,6 +147,6 @@ def run(operator_url: str | None = None, once: bool = False) -> None:
|
|
|
147
147
|
requestlog.request("relay", command, "", reply)
|
|
148
148
|
# After the reply, never before: the picture of a changed chart.
|
|
149
149
|
from . import push
|
|
150
|
-
push.after_reply(command, reply, result.symbol)
|
|
150
|
+
push.after_reply(command, reply, result.symbol, result.scale)
|
|
151
151
|
if once:
|
|
152
152
|
return
|
|
@@ -77,7 +77,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
77
77
|
result = answer(request)
|
|
78
78
|
self._reply(200, result.reply)
|
|
79
79
|
requestlog.request("serve", request, where, result.reply)
|
|
80
|
-
push.after_reply(request, result.reply, result.symbol)
|
|
80
|
+
push.after_reply(request, result.reply, result.symbol, result.scale)
|
|
81
81
|
|
|
82
82
|
def log_message(self, *args) -> None:
|
|
83
83
|
"""Silence the stock access log: its request line can carry ``?t=<token>``.
|
|
@@ -83,10 +83,10 @@ def set_chart(ticker: str, scale: str = "") -> Answer:
|
|
|
83
83
|
pass
|
|
84
84
|
|
|
85
85
|
if word:
|
|
86
|
-
return Answer(f"Showing {ticker} at {word}. Good luck.", shown)
|
|
86
|
+
return Answer(f"Showing {ticker} at {word}. Good luck.", shown, word)
|
|
87
87
|
try:
|
|
88
88
|
_, current = timeframe.current()
|
|
89
|
-
return Answer(f"Showing {ticker} at {current}, as is. Good luck.", shown)
|
|
89
|
+
return Answer(f"Showing {ticker} at {current}, as is. Good luck.", shown, current)
|
|
90
90
|
except Exception:
|
|
91
91
|
return Answer(f"Showing {ticker}. Good luck.", shown)
|
|
92
92
|
|
|
@@ -15,6 +15,7 @@ PAIRED = {"operator_url": "https://op.test/", "agent_id": "a1", "agent_secret":
|
|
|
15
15
|
def isolated_config(tmp_path, monkeypatch):
|
|
16
16
|
monkeypatch.setattr(guilock.config, "CONFIG_DIR", tmp_path)
|
|
17
17
|
monkeypatch.setattr(push, "_named", None)
|
|
18
|
+
monkeypatch.setattr(push, "_scaled", None)
|
|
18
19
|
|
|
19
20
|
|
|
20
21
|
def test_an_unpaired_mac_pushes_nothing():
|
|
@@ -44,6 +45,17 @@ def test_an_unreadable_symbol_is_left_off_rather_than_sent(raw):
|
|
|
44
45
|
assert "symbol" not in body
|
|
45
46
|
|
|
46
47
|
|
|
48
|
+
def test_the_push_names_the_scale_the_reply_stated():
|
|
49
|
+
_, body = push.payload(PAIRED, "img", "PLTR", " 30 minutes ")
|
|
50
|
+
assert body["scale"] == "30 minutes"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@pytest.mark.parametrize("raw", [None, "", " ", "x" * 25, "<b>half</b>", "half;", "half\x07", 30])
|
|
54
|
+
def test_an_unreadable_scale_is_left_off_rather_than_sent(raw):
|
|
55
|
+
_, body = push.payload(PAIRED, "img", "PLTR", raw)
|
|
56
|
+
assert "scale" not in body
|
|
57
|
+
|
|
58
|
+
|
|
47
59
|
@pytest.fixture
|
|
48
60
|
def labelled(monkeypatch):
|
|
49
61
|
"""Capture stubbed out; returns the bodies pushed. The symbol field reads OLD."""
|
|
@@ -92,6 +104,21 @@ def test_the_label_comes_from_the_command_not_the_wording(monkeypatch, labelled)
|
|
|
92
104
|
assert labelled[-1]["symbol"] == "TSLA"
|
|
93
105
|
|
|
94
106
|
|
|
107
|
+
def test_the_picture_carries_the_scale_the_last_change_stated(monkeypatch, labelled):
|
|
108
|
+
monkeypatch.setattr(push, "schedule", lambda: None)
|
|
109
|
+
push.after_reply("set PLTR | half", "Showing PLTR at half. Good luck.", "PLTR", "half")
|
|
110
|
+
push.after_reply("set MSFT", "Showing MSFT. Good luck.", "MSFT") # scale unknown: kept
|
|
111
|
+
push.push_now()
|
|
112
|
+
assert labelled[-1]["symbol"] == "MSFT" and labelled[-1]["scale"] == "half"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_no_scale_is_sent_when_none_was_ever_stated(monkeypatch, labelled):
|
|
116
|
+
monkeypatch.setattr(push, "schedule", lambda: None)
|
|
117
|
+
push.after_reply("set NVDA", "Showing NVDA. Good luck.", "NVDA")
|
|
118
|
+
push.push_now()
|
|
119
|
+
assert "scale" not in labelled[-1]
|
|
120
|
+
|
|
121
|
+
|
|
95
122
|
def test_the_symbol_field_is_only_the_fallback(labelled):
|
|
96
123
|
push.push_now()
|
|
97
124
|
assert labelled[-1]["symbol"] == "OLD"
|
|
@@ -165,7 +165,7 @@ def test_the_picture_is_scheduled_only_after_the_result_is_posted(operator, monk
|
|
|
165
165
|
StubOperator.claimed = True
|
|
166
166
|
relay.pair(operator, on_code=lambda c: None)
|
|
167
167
|
StubOperator.commands.append({"id": "r2", "command": "set PLTR"})
|
|
168
|
-
monkeypatch.setattr(relay, "execute", lambda cmd: Answer("Showing PLTR. Good luck.", "PLTR"))
|
|
168
|
+
monkeypatch.setattr(relay, "execute", lambda cmd: Answer("Showing PLTR at half. Good luck.", "PLTR", "half"))
|
|
169
169
|
real_post = relay._post
|
|
170
170
|
|
|
171
171
|
def post(url, payload, timeout):
|
|
@@ -173,6 +173,6 @@ def test_the_picture_is_scheduled_only_after_the_result_is_posted(operator, monk
|
|
|
173
173
|
events.append("result")
|
|
174
174
|
return real_post(url, payload, timeout)
|
|
175
175
|
monkeypatch.setattr(relay, "_post", post)
|
|
176
|
-
monkeypatch.setattr(push, "after_reply", lambda c, r, s: events.append(("picture", c, s)))
|
|
176
|
+
monkeypatch.setattr(push, "after_reply", lambda c, r, s, sc: events.append(("picture", c, s, sc)))
|
|
177
177
|
relay.run(once=True)
|
|
178
|
-
assert events == ["result", ("picture", "set PLTR", "PLTR")]
|
|
178
|
+
assert events == ["result", ("picture", "set PLTR", "PLTR", "half")]
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|