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.
Files changed (49) hide show
  1. {chartremotely-0.2.7 → chartremotely-0.2.8}/PKG-INFO +1 -1
  2. {chartremotely-0.2.7 → chartremotely-0.2.8}/chartremotely/answer.py +4 -2
  3. {chartremotely-0.2.7 → chartremotely-0.2.8}/chartremotely/push.py +33 -11
  4. {chartremotely-0.2.7 → chartremotely-0.2.8}/chartremotely/relay.py +1 -1
  5. {chartremotely-0.2.7 → chartremotely-0.2.8}/chartremotely/server.py +1 -1
  6. {chartremotely-0.2.7 → chartremotely-0.2.8}/chartremotely/vocab.py +2 -2
  7. {chartremotely-0.2.7 → chartremotely-0.2.8}/chartremotely.egg-info/PKG-INFO +1 -1
  8. {chartremotely-0.2.7 → chartremotely-0.2.8}/pyproject.toml +1 -1
  9. {chartremotely-0.2.7 → chartremotely-0.2.8}/tests/test_push.py +27 -0
  10. {chartremotely-0.2.7 → chartremotely-0.2.8}/tests/test_relay.py +3 -3
  11. {chartremotely-0.2.7 → chartremotely-0.2.8}/LICENSE +0 -0
  12. {chartremotely-0.2.7 → chartremotely-0.2.8}/README.md +0 -0
  13. {chartremotely-0.2.7 → chartremotely-0.2.8}/chartremotely/__init__.py +0 -0
  14. {chartremotely-0.2.7 → chartremotely-0.2.8}/chartremotely/assets/shortcut-template.plist +0 -0
  15. {chartremotely-0.2.7 → chartremotely-0.2.8}/chartremotely/ax.py +0 -0
  16. {chartremotely-0.2.7 → chartremotely-0.2.8}/chartremotely/cli.py +0 -0
  17. {chartremotely-0.2.7 → chartremotely-0.2.8}/chartremotely/config.py +0 -0
  18. {chartremotely-0.2.7 → chartremotely-0.2.8}/chartremotely/doctor.py +0 -0
  19. {chartremotely-0.2.7 → chartremotely-0.2.8}/chartremotely/forward.py +0 -0
  20. {chartremotely-0.2.7 → chartremotely-0.2.8}/chartremotely/guilock.py +0 -0
  21. {chartremotely-0.2.7 → chartremotely-0.2.8}/chartremotely/keystore.py +0 -0
  22. {chartremotely-0.2.7 → chartremotely-0.2.8}/chartremotely/layout.py +0 -0
  23. {chartremotely-0.2.7 → chartremotely-0.2.8}/chartremotely/mcpclient.py +0 -0
  24. {chartremotely-0.2.7 → chartremotely-0.2.8}/chartremotely/registry.py +0 -0
  25. {chartremotely-0.2.7 → chartremotely-0.2.8}/chartremotely/requestlog.py +0 -0
  26. {chartremotely-0.2.7 → chartremotely-0.2.8}/chartremotely/resolve.py +0 -0
  27. {chartremotely-0.2.7 → chartremotely-0.2.8}/chartremotely/scales.py +0 -0
  28. {chartremotely-0.2.7 → chartremotely-0.2.8}/chartremotely/services.py +0 -0
  29. {chartremotely-0.2.7 → chartremotely-0.2.8}/chartremotely/setup.py +0 -0
  30. {chartremotely-0.2.7 → chartremotely-0.2.8}/chartremotely/shortcut.py +0 -0
  31. {chartremotely-0.2.7 → chartremotely-0.2.8}/chartremotely/snapshot.py +0 -0
  32. {chartremotely-0.2.7 → chartremotely-0.2.8}/chartremotely/studies.py +0 -0
  33. {chartremotely-0.2.7 → chartremotely-0.2.8}/chartremotely/symbol.py +0 -0
  34. {chartremotely-0.2.7 → chartremotely-0.2.8}/chartremotely/tailnet.py +0 -0
  35. {chartremotely-0.2.7 → chartremotely-0.2.8}/chartremotely/timeframe.py +0 -0
  36. {chartremotely-0.2.7 → chartremotely-0.2.8}/chartremotely/window.py +0 -0
  37. {chartremotely-0.2.7 → chartremotely-0.2.8}/chartremotely.egg-info/SOURCES.txt +0 -0
  38. {chartremotely-0.2.7 → chartremotely-0.2.8}/chartremotely.egg-info/dependency_links.txt +0 -0
  39. {chartremotely-0.2.7 → chartremotely-0.2.8}/chartremotely.egg-info/entry_points.txt +0 -0
  40. {chartremotely-0.2.7 → chartremotely-0.2.8}/chartremotely.egg-info/requires.txt +0 -0
  41. {chartremotely-0.2.7 → chartremotely-0.2.8}/chartremotely.egg-info/top_level.txt +0 -0
  42. {chartremotely-0.2.7 → chartremotely-0.2.8}/setup.cfg +0 -0
  43. {chartremotely-0.2.7 → chartremotely-0.2.8}/tests/test_forward.py +0 -0
  44. {chartremotely-0.2.7 → chartremotely-0.2.8}/tests/test_requestlog.py +0 -0
  45. {chartremotely-0.2.7 → chartremotely-0.2.8}/tests/test_resolve.py +0 -0
  46. {chartremotely-0.2.7 → chartremotely-0.2.8}/tests/test_setup.py +0 -0
  47. {chartremotely-0.2.7 → chartremotely-0.2.8}/tests/test_snapshot.py +0 -0
  48. {chartremotely-0.2.7 → chartremotely-0.2.8}/tests/test_stamp_changelog.py +0 -0
  49. {chartremotely-0.2.7 → chartremotely-0.2.8}/tests/test_timeframe.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chartremotely
3
- Version: 0.2.7
3
+ Version: 0.2.8
4
4
  Summary: Remote control of desktop charting applications on macOS
5
5
  Author: Lonnie VanZandt
6
6
  License:
@@ -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 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,
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 payload(cfg: dict, image: str, symbol: str | None = None) -> tuple[str, dict] | None:
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) -> 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`` 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.
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
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chartremotely
3
- Version: 0.2.7
3
+ Version: 0.2.8
4
4
  Summary: Remote control of desktop charting applications on macOS
5
5
  Author: Lonnie VanZandt
6
6
  License:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "chartremotely"
3
- version = "0.2.7"
3
+ version = "0.2.8"
4
4
  description = "Remote control of desktop charting applications on macOS"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -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