chartremotely 0.2.10__tar.gz → 0.2.12__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.10 → chartremotely-0.2.12}/PKG-INFO +15 -2
- {chartremotely-0.2.10 → chartremotely-0.2.12}/README.md +14 -1
- {chartremotely-0.2.10 → chartremotely-0.2.12}/chartremotely/patron.py +4 -1
- chartremotely-0.2.12/chartremotely/popups.py +258 -0
- chartremotely-0.2.12/chartremotely/recent.py +50 -0
- {chartremotely-0.2.10 → chartremotely-0.2.12}/chartremotely/requestlog.py +13 -3
- {chartremotely-0.2.10 → chartremotely-0.2.12}/chartremotely/resolve.py +99 -7
- chartremotely-0.2.12/chartremotely/scales.py +188 -0
- {chartremotely-0.2.10 → chartremotely-0.2.12}/chartremotely/symbol.py +11 -1
- {chartremotely-0.2.10 → chartremotely-0.2.12}/chartremotely/timeframe.py +8 -7
- chartremotely-0.2.12/chartremotely/understand.py +226 -0
- {chartremotely-0.2.10 → chartremotely-0.2.12}/chartremotely/vocab.py +31 -5
- {chartremotely-0.2.10 → chartremotely-0.2.12}/chartremotely/window.py +5 -1
- {chartremotely-0.2.10 → chartremotely-0.2.12}/chartremotely.egg-info/PKG-INFO +15 -2
- {chartremotely-0.2.10 → chartremotely-0.2.12}/chartremotely.egg-info/SOURCES.txt +8 -1
- {chartremotely-0.2.10 → chartremotely-0.2.12}/pyproject.toml +1 -1
- chartremotely-0.2.12/tests/test_popups.py +132 -0
- chartremotely-0.2.12/tests/test_recent.py +30 -0
- {chartremotely-0.2.10 → chartremotely-0.2.12}/tests/test_relay.py +1 -1
- {chartremotely-0.2.10 → chartremotely-0.2.12}/tests/test_requestlog.py +9 -0
- {chartremotely-0.2.10 → chartremotely-0.2.12}/tests/test_resolve.py +54 -1
- chartremotely-0.2.12/tests/test_scales.py +70 -0
- chartremotely-0.2.12/tests/test_understand.py +87 -0
- chartremotely-0.2.10/chartremotely/scales.py +0 -77
- {chartremotely-0.2.10 → chartremotely-0.2.12}/LICENSE +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.12}/chartremotely/__init__.py +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.12}/chartremotely/answer.py +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.12}/chartremotely/assets/shortcut-template.plist +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.12}/chartremotely/ax.py +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.12}/chartremotely/cli.py +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.12}/chartremotely/config.py +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.12}/chartremotely/doctor.py +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.12}/chartremotely/guilock.py +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.12}/chartremotely/keystore.py +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.12}/chartremotely/layout.py +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.12}/chartremotely/mcpclient.py +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.12}/chartremotely/push.py +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.12}/chartremotely/registry.py +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.12}/chartremotely/relay.py +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.12}/chartremotely/screen.py +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.12}/chartremotely/server.py +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.12}/chartremotely/services.py +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.12}/chartremotely/setup.py +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.12}/chartremotely/shortcut.py +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.12}/chartremotely/snapshot.py +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.12}/chartremotely/studies.py +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.12}/chartremotely/tailnet.py +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.12}/chartremotely.egg-info/dependency_links.txt +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.12}/chartremotely.egg-info/entry_points.txt +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.12}/chartremotely.egg-info/requires.txt +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.12}/chartremotely.egg-info/top_level.txt +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.12}/setup.cfg +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.12}/tests/test_patron.py +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.12}/tests/test_push.py +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.12}/tests/test_screen.py +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.12}/tests/test_setup.py +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.12}/tests/test_snapshot.py +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.12}/tests/test_stamp_changelog.py +0 -0
- {chartremotely-0.2.10 → chartremotely-0.2.12}/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.12
|
|
4
4
|
Summary: Remote control of desktop charting applications on macOS
|
|
5
5
|
Author: Lonnie VanZandt
|
|
6
6
|
License:
|
|
@@ -345,8 +345,21 @@ expired. Answer the DM on your phone to renew." — a fresh DM is already on its
|
|
|
345
345
|
way, and once you answer it (within about half an hour) the next command
|
|
346
346
|
works again.
|
|
347
347
|
|
|
348
|
+
**What Siri heard.** Dictation matches against all of English, so the Mac
|
|
349
|
+
repairs what it hears against the three small vocabularies it actually needs:
|
|
350
|
+
companies in the SEC list, the ten time frames, and your display names. A
|
|
351
|
+
scale may be a mnemonic, a bar size ("thirty minutes", "an hour", "four
|
|
352
|
+
hours", "a day") or one word that sounds like exactly one mnemonic ("have",
|
|
353
|
+
"alf" are half). A company may be mis-heard ("volunteer", "pal and tear" are
|
|
354
|
+
Palantir; "shop if y" is Shopify); when two companies are too close to call
|
|
355
|
+
you are asked - "Did you mean PS (Pershing Square) or MSGS (Madison Square
|
|
356
|
+
Garden)?" - rather than shown the wrong chart, and the symbols you chart
|
|
357
|
+
lately settle a near-tie. `understand()` parses a whole sentence the same way
|
|
358
|
+
("Palantir half on the mini").
|
|
359
|
+
|
|
348
360
|
Each command handled is logged as one line — time, the command's first word,
|
|
349
|
-
the "Where?" as said,
|
|
361
|
+
the "Where?" as said, what was heard for a company or scale lookup, and the
|
|
362
|
+
reply if it was an ERR — plus one line when its
|
|
350
363
|
tool call ends (the tool, "Where?" and any refusal) — to
|
|
351
364
|
`~/Library/Logs/chartremotely-serve.log` (voice) or `chartremotely-relay.log`
|
|
352
365
|
(from the operator). Never a token, a secret or a picture.
|
|
@@ -121,8 +121,21 @@ expired. Answer the DM on your phone to renew." — a fresh DM is already on its
|
|
|
121
121
|
way, and once you answer it (within about half an hour) the next command
|
|
122
122
|
works again.
|
|
123
123
|
|
|
124
|
+
**What Siri heard.** Dictation matches against all of English, so the Mac
|
|
125
|
+
repairs what it hears against the three small vocabularies it actually needs:
|
|
126
|
+
companies in the SEC list, the ten time frames, and your display names. A
|
|
127
|
+
scale may be a mnemonic, a bar size ("thirty minutes", "an hour", "four
|
|
128
|
+
hours", "a day") or one word that sounds like exactly one mnemonic ("have",
|
|
129
|
+
"alf" are half). A company may be mis-heard ("volunteer", "pal and tear" are
|
|
130
|
+
Palantir; "shop if y" is Shopify); when two companies are too close to call
|
|
131
|
+
you are asked - "Did you mean PS (Pershing Square) or MSGS (Madison Square
|
|
132
|
+
Garden)?" - rather than shown the wrong chart, and the symbols you chart
|
|
133
|
+
lately settle a near-tie. `understand()` parses a whole sentence the same way
|
|
134
|
+
("Palantir half on the mini").
|
|
135
|
+
|
|
124
136
|
Each command handled is logged as one line — time, the command's first word,
|
|
125
|
-
the "Where?" as said,
|
|
137
|
+
the "Where?" as said, what was heard for a company or scale lookup, and the
|
|
138
|
+
reply if it was an ERR — plus one line when its
|
|
126
139
|
tool call ends (the tool, "Where?" and any refusal) — to
|
|
127
140
|
`~/Library/Logs/chartremotely-serve.log` (voice) or `chartremotely-relay.log`
|
|
128
141
|
(from the operator). Never a token, a secret or a picture.
|
|
@@ -28,7 +28,7 @@ import threading
|
|
|
28
28
|
import time
|
|
29
29
|
from collections.abc import Callable
|
|
30
30
|
|
|
31
|
-
from . import config, keystore, mcpclient, requestlog
|
|
31
|
+
from . import config, keystore, mcpclient, recent, requestlog
|
|
32
32
|
|
|
33
33
|
ERR = "ERR "
|
|
34
34
|
#: How long a spoken reply waits for an early refusal before handing off.
|
|
@@ -134,6 +134,9 @@ def send(command: str, where: str, cfg: dict | None = None) -> str:
|
|
|
134
134
|
payload = _call(base, tool, envelope)
|
|
135
135
|
box["payload"] = payload
|
|
136
136
|
settle(base, npub, tool, wanted, payload)
|
|
137
|
+
if tool == "chart_show_chart" and refusal(payload) is None \
|
|
138
|
+
and payload.get("error_code") not in PROOF_CODES:
|
|
139
|
+
recent.remember(args["security"])
|
|
137
140
|
done.set()
|
|
138
141
|
|
|
139
142
|
threading.Thread(target=work, name=f"call-{tool}", daemon=True).start()
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""Close thinkorswim's popups before anyone looks at the chart.
|
|
2
|
+
|
|
3
|
+
Two popups get left open over the chart, and a picture taken then shows them:
|
|
4
|
+
|
|
5
|
+
* the symbol field's autocomplete list (PLTR:XTSE, PLTRX, ...), which hangs
|
|
6
|
+
under the field after a symbol is typed and often outlives the Return that
|
|
7
|
+
committed it;
|
|
8
|
+
* the aggregation (time frame) menu, which stays open after a preset is
|
|
9
|
+
picked or the current one is read.
|
|
10
|
+
|
|
11
|
+
Neither answers the obvious remedies. An Escape posted to thinkorswim lands on
|
|
12
|
+
whatever holds keyboard focus, which after a commit is nothing at all - so it
|
|
13
|
+
is silently dropped. What works, measured on thinkorswim build 1993:
|
|
14
|
+
|
|
15
|
+
* the aggregation menu closes when its toggle is pressed again (``AXPress``);
|
|
16
|
+
the toggle's ``AXValue`` is 1 while the menu shows;
|
|
17
|
+
* the autocomplete list closes on Escape once the symbol field has focus.
|
|
18
|
+
Escape only hides the list: text typed but not yet committed stays in the
|
|
19
|
+
field, so it never cancels an entry;
|
|
20
|
+
* focus leaves the field when the chart's own title strip (the line under
|
|
21
|
+
the field reading "PLTR 10 D 30m") is focused. Setting the field's
|
|
22
|
+
``AXFocused`` to false is accepted and ignored.
|
|
23
|
+
|
|
24
|
+
A Swing field commits its text when it loses focus, so focus is moved off the
|
|
25
|
+
field only when that text is what the chart already shows. Otherwise the field
|
|
26
|
+
keeps focus and its pending text, and nothing changes.
|
|
27
|
+
|
|
28
|
+
The decisions are pure and live at module scope, over a :class:`Seen`
|
|
29
|
+
snapshot, so they are tested where PyObjC is absent. :func:`dismiss` gathers
|
|
30
|
+
the snapshots and acts; its macOS imports are deferred.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import re
|
|
36
|
+
import time
|
|
37
|
+
from collections.abc import Callable
|
|
38
|
+
from dataclasses import dataclass
|
|
39
|
+
|
|
40
|
+
Rect = tuple[float, float, float, float]
|
|
41
|
+
|
|
42
|
+
#: Longest dismiss() spends closing popups.
|
|
43
|
+
TIMEOUT = 1.5
|
|
44
|
+
#: How often dismiss() looks again after acting.
|
|
45
|
+
POLL = 0.1
|
|
46
|
+
#: Longest dismiss() waits for one action to show before trying it again.
|
|
47
|
+
ACT_WAIT = 0.5
|
|
48
|
+
#: Points of slack when deciding a list hangs from the symbol field.
|
|
49
|
+
SLACK = 8
|
|
50
|
+
#: How far under the field's bottom edge to hit-test for a list or the title.
|
|
51
|
+
PROBE_DY, PROBE_DX = 8, 8
|
|
52
|
+
|
|
53
|
+
#: The first word of the chart's title strip: the symbol it is drawn for.
|
|
54
|
+
CHART_SYMBOL = re.compile(r"^[./$^]?[A-Z0-9]{1,12}([/.][A-Z0-9]{1,3})?(:[A-Z]+)?$")
|
|
55
|
+
|
|
56
|
+
CLOSE_SCALE, CLOSE_LIST, LEAVE_FIELD = "close scale menu", "close symbol list", "leave field"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class Seen:
|
|
61
|
+
"""What the chart's popups and symbol field looked like at one moment."""
|
|
62
|
+
|
|
63
|
+
scale_open: bool = False
|
|
64
|
+
list_open: bool = False
|
|
65
|
+
field_focused: bool = False
|
|
66
|
+
field_value: str | None = None
|
|
67
|
+
#: The chart's title strip, e.g. "PLTR 10 D 30m"; None while a list covers it.
|
|
68
|
+
title: str | None = None
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def clean(self) -> bool:
|
|
72
|
+
"""Nothing is drawn over the chart."""
|
|
73
|
+
return not (self.scale_open or self.list_open)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def hangs_under(field: Rect, table: Rect | None) -> bool:
|
|
77
|
+
"""Whether ``table`` is the autocomplete list dropped from the symbol field.
|
|
78
|
+
|
|
79
|
+
thinkorswim paints that list inside the window, so the window server never
|
|
80
|
+
sees it; the accessibility tree reports it as a table whose top-left corner
|
|
81
|
+
sits on the field's bottom-left corner. ``field`` is the field's combo box.
|
|
82
|
+
"""
|
|
83
|
+
if table is None:
|
|
84
|
+
return False
|
|
85
|
+
fx, fy, _, fh = field
|
|
86
|
+
tx, ty, tw, th = table
|
|
87
|
+
return (tw > 0 and th > 0 and abs(tx - fx) <= SLACK
|
|
88
|
+
and abs(ty - (fy + fh)) <= SLACK)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def chart_symbol(title: str | None) -> str | None:
|
|
92
|
+
"""The symbol a chart title strip names ("PLTR 10 D 30m" -> "PLTR"), or None."""
|
|
93
|
+
if not isinstance(title, str) or not title.strip():
|
|
94
|
+
return None
|
|
95
|
+
word = title.split()[0]
|
|
96
|
+
return word if CHART_SYMBOL.match(word) else None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def shows(title: str | None, entry: str | None) -> bool:
|
|
100
|
+
"""Whether the chart already shows what ``entry`` names.
|
|
101
|
+
|
|
102
|
+
A futures root typed as "/ES" is drawn as its front contract ("/ESZ26"),
|
|
103
|
+
so a root counts as shown by any of its contracts.
|
|
104
|
+
"""
|
|
105
|
+
drawn = chart_symbol(title)
|
|
106
|
+
want = (entry or "").strip().upper()
|
|
107
|
+
if not drawn or not want:
|
|
108
|
+
return False
|
|
109
|
+
return drawn == want or (want.startswith("/") and drawn.startswith(want))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def next_step(seen: Seen) -> str | None:
|
|
113
|
+
"""The one thing to do next, or None when there is nothing safe left to do.
|
|
114
|
+
|
|
115
|
+
The menu is closed before the list: its toggle is pressed through the
|
|
116
|
+
tree, which needs no focus, whereas closing the list moves focus into the
|
|
117
|
+
field. Focus leaves the field only when the chart already shows its text,
|
|
118
|
+
because losing focus commits whatever the field holds.
|
|
119
|
+
"""
|
|
120
|
+
if seen.scale_open:
|
|
121
|
+
return CLOSE_SCALE
|
|
122
|
+
if seen.list_open:
|
|
123
|
+
return CLOSE_LIST
|
|
124
|
+
if seen.field_focused and shows(seen.title, seen.field_value):
|
|
125
|
+
return LEAVE_FIELD
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass(frozen=True)
|
|
130
|
+
class Dismissed:
|
|
131
|
+
"""What dismiss() did, and whether the chart was left uncovered."""
|
|
132
|
+
|
|
133
|
+
did: tuple[str, ...] = ()
|
|
134
|
+
clean: bool = True
|
|
135
|
+
|
|
136
|
+
def __bool__(self) -> bool:
|
|
137
|
+
return bool(self.did)
|
|
138
|
+
|
|
139
|
+
def __str__(self) -> str:
|
|
140
|
+
done = "; ".join(self.did) or "nothing open"
|
|
141
|
+
return done if self.clean else done + "; a popup is still open"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def run(look: Callable[[], Seen], act: Callable[[str], None], *,
|
|
145
|
+
until: Callable[[Seen], bool] | None = None, timeout: float = TIMEOUT,
|
|
146
|
+
clock: Callable[[], float] = time.monotonic,
|
|
147
|
+
sleep: Callable[[float], None] = time.sleep) -> Dismissed:
|
|
148
|
+
"""Look, act on the next step, and look again until nothing is left.
|
|
149
|
+
|
|
150
|
+
``until``, when given, must also hold before it stops early: a symbol just
|
|
151
|
+
committed is waited for until the chart's title names it, so focus can
|
|
152
|
+
leave the field once it is safe to. One action is retried only after it
|
|
153
|
+
has had ACT_WAIT to show, so a slow repaint is not answered with a second
|
|
154
|
+
press of a toggle.
|
|
155
|
+
"""
|
|
156
|
+
deadline = clock() + timeout
|
|
157
|
+
did: list[str] = []
|
|
158
|
+
last: tuple[str | None, float] = (None, -ACT_WAIT)
|
|
159
|
+
while True:
|
|
160
|
+
seen = look()
|
|
161
|
+
step = next_step(seen)
|
|
162
|
+
now = clock()
|
|
163
|
+
if step is None and (until is None or until(seen)) or now >= deadline:
|
|
164
|
+
return Dismissed(tuple(did), seen.clean)
|
|
165
|
+
if step is not None and (step != last[0] or now - last[1] >= ACT_WAIT):
|
|
166
|
+
act(step)
|
|
167
|
+
if step not in did:
|
|
168
|
+
did.append(step)
|
|
169
|
+
last = (step, now)
|
|
170
|
+
sleep(POLL)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# --------------------------------------------------------------------------
|
|
174
|
+
# macOS. Imported lazily so the decisions above stay importable anywhere.
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _frame(ax, element) -> Rect | None:
|
|
178
|
+
p, s = ax.position(element), ax.size(element)
|
|
179
|
+
return (p.x, p.y, s.width, s.height) if p and s else None
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _under_field(ax, ax_app, field):
|
|
183
|
+
"""The combo's frame, the element just under it, and the list that is, if any."""
|
|
184
|
+
combo = ax.attr(field, "AXParent")
|
|
185
|
+
frame = _frame(ax, combo) if combo is not None else None
|
|
186
|
+
if frame is None:
|
|
187
|
+
return None, None, None
|
|
188
|
+
x, y, _, h = frame
|
|
189
|
+
hit = ax.element_at(ax_app, x + PROBE_DX, y + h + PROBE_DY)
|
|
190
|
+
table = None
|
|
191
|
+
for element in (hit, ax.attr(hit, "AXParent") if hit is not None else None):
|
|
192
|
+
if element is not None and ax.attr(element, "AXRole") == "AXTable":
|
|
193
|
+
table = element
|
|
194
|
+
break
|
|
195
|
+
return frame, hit, table
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def dismiss(app=None, expect: str | None = None, timeout: float = TIMEOUT) -> Dismissed:
|
|
199
|
+
"""Close the symbol list and the scale menu, and take focus off the field.
|
|
200
|
+
|
|
201
|
+
``expect`` is a symbol just committed: dismiss() then also waits, within
|
|
202
|
+
``timeout``, for the chart's title to name it before it lets focus go.
|
|
203
|
+
|
|
204
|
+
Idempotent, and cheap when nothing is open: a few accessibility reads.
|
|
205
|
+
Call it with thinkorswim in front (hit-testing answers from the menu bar
|
|
206
|
+
otherwise), inside the chart lock, after the change it tidies up after.
|
|
207
|
+
A field or toggle that cannot be found is left alone rather than raised
|
|
208
|
+
about: tidying up must never turn a change that landed into an error.
|
|
209
|
+
"""
|
|
210
|
+
from . import ax, config, symbol, timeframe
|
|
211
|
+
|
|
212
|
+
app = app or ax.running_app()
|
|
213
|
+
pid = app.processIdentifier()
|
|
214
|
+
ax_app = ax.handle(app)
|
|
215
|
+
cfg = config.load()
|
|
216
|
+
try:
|
|
217
|
+
field, _ = symbol.locate(ax_app, cfg)
|
|
218
|
+
except symbol.NotFound:
|
|
219
|
+
field = None
|
|
220
|
+
try:
|
|
221
|
+
toggle, _ = timeframe._control(ax_app, cfg)
|
|
222
|
+
except timeframe.MenuError:
|
|
223
|
+
toggle = None
|
|
224
|
+
title = [None]
|
|
225
|
+
|
|
226
|
+
def look() -> Seen:
|
|
227
|
+
scale_open = toggle is not None and ax.attr(toggle, "AXValue") not in (0, "0", False, None)
|
|
228
|
+
if field is None:
|
|
229
|
+
return Seen(scale_open=scale_open)
|
|
230
|
+
frame, hit, table = _under_field(ax, ax_app, field)
|
|
231
|
+
list_open = frame is not None and hangs_under(
|
|
232
|
+
frame, _frame(ax, table) if table is not None else None)
|
|
233
|
+
title[0] = None if list_open else hit
|
|
234
|
+
return Seen(
|
|
235
|
+
scale_open=scale_open,
|
|
236
|
+
list_open=list_open,
|
|
237
|
+
field_focused=bool(ax.attr(field, "AXFocused")),
|
|
238
|
+
field_value=ax.attr(field, "AXValue"),
|
|
239
|
+
title=ax.attr(title[0], "AXValue") if title[0] is not None else None,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
def act(step: str) -> None:
|
|
243
|
+
if step == CLOSE_SCALE:
|
|
244
|
+
ax.perform(toggle, "AXPress")
|
|
245
|
+
elif step == CLOSE_LIST:
|
|
246
|
+
# Escape reaches only the focused component; after a commit that
|
|
247
|
+
# is nothing, so the field is focused first.
|
|
248
|
+
if not ax.attr(field, "AXFocused"):
|
|
249
|
+
ax.set_attr(field, "AXFocused", True)
|
|
250
|
+
time.sleep(0.1)
|
|
251
|
+
ax.escape(pid)
|
|
252
|
+
elif step == LEAVE_FIELD:
|
|
253
|
+
# LEAVE_FIELD is only chosen when the element under the field
|
|
254
|
+
# read as the chart's title, so title[0] is that strip.
|
|
255
|
+
ax.set_attr(title[0], "AXFocused", True)
|
|
256
|
+
|
|
257
|
+
until = (lambda seen: shows(seen.title, expect)) if expect else None
|
|
258
|
+
return run(look, act, until=until, timeout=timeout)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""The symbols this Mac charted lately: a personal prior for what was said.
|
|
2
|
+
|
|
3
|
+
When a spoken name lands between two companies, the one you chart is the one
|
|
4
|
+
you meant (see :func:`resolve.decide`). The list is kept most recent first,
|
|
5
|
+
at most :data:`KEEP` long, in ``~/.config/chartremotely/recent.json``, and is
|
|
6
|
+
written only when a chart change succeeded. It holds ticker symbols and
|
|
7
|
+
nothing else - never a token, a name as said or a secret.
|
|
8
|
+
|
|
9
|
+
Stdlib only; every function swallows its own errors, because a lost prior
|
|
10
|
+
must never cost a reply.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import re
|
|
17
|
+
|
|
18
|
+
from .config import CONFIG_DIR
|
|
19
|
+
|
|
20
|
+
PATH = CONFIG_DIR / "recent.json"
|
|
21
|
+
KEEP = 50
|
|
22
|
+
_TICKER = re.compile(r"^[A-Z][A-Z0-9.\-]{0,9}$")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def load(path=None) -> list[str]:
|
|
26
|
+
"""The remembered symbols, most recent first; [] when there are none."""
|
|
27
|
+
try:
|
|
28
|
+
data = json.loads((path or PATH).read_text())
|
|
29
|
+
except (OSError, ValueError):
|
|
30
|
+
return []
|
|
31
|
+
if not isinstance(data, list):
|
|
32
|
+
return []
|
|
33
|
+
return [t for t in data if isinstance(t, str) and _TICKER.match(t)][:KEEP]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def remember(ticker: str, path=None) -> None:
|
|
37
|
+
"""Put a symbol that was just charted at the front. Ignores anything not
|
|
38
|
+
shaped like a ticker, so a company name as said is never written."""
|
|
39
|
+
ticker = (ticker or "").strip().upper()
|
|
40
|
+
if not _TICKER.match(ticker):
|
|
41
|
+
return
|
|
42
|
+
path = path or PATH
|
|
43
|
+
kept = [ticker] + [t for t in load(path) if t != ticker]
|
|
44
|
+
try:
|
|
45
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
tmp = path.with_suffix(".tmp")
|
|
47
|
+
tmp.write_text(json.dumps(kept[:KEEP]))
|
|
48
|
+
tmp.replace(path)
|
|
49
|
+
except OSError:
|
|
50
|
+
pass
|
|
@@ -2,8 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
launchd sends stderr to ``~/Library/Logs/chartremotely-<service>.log``. The
|
|
4
4
|
line carries only what is needed to tell requests apart: the time, the verb
|
|
5
|
-
(the command's first word
|
|
6
|
-
|
|
5
|
+
(the command's first word), the "Where?" as dictated, and the reply when it
|
|
6
|
+
is an ERR. A command's arguments are never logged - except for the free
|
|
7
|
+
``resolve`` and ``scale`` lookups, whose argument is what Siri heard for a
|
|
8
|
+
company or a time frame (``heard=``, at most :data:`HEARD_MAX` characters):
|
|
9
|
+
not sensitive, and the only way to see why a word was not understood.
|
|
10
|
+
Never a token, a secret or a picture.
|
|
7
11
|
|
|
8
12
|
Stdlib only, like the relay, so it imports where the macOS drivers do not.
|
|
9
13
|
"""
|
|
@@ -15,6 +19,9 @@ import time
|
|
|
15
19
|
|
|
16
20
|
WHERE_MAX = 64
|
|
17
21
|
ERR_MAX = 200
|
|
22
|
+
HEARD_MAX = 60
|
|
23
|
+
#: The verbs whose argument is logged as ``heard=``; see the module doc.
|
|
24
|
+
HEARD_VERBS = frozenset({"resolve", "scale"})
|
|
18
25
|
|
|
19
26
|
|
|
20
27
|
def _clean(text: str, limit: int) -> str:
|
|
@@ -25,9 +32,12 @@ def _clean(text: str, limit: int) -> str:
|
|
|
25
32
|
|
|
26
33
|
def line(source: str, command: str, where: str, reply: str) -> str:
|
|
27
34
|
"""The log line for one request (see the module doc)."""
|
|
28
|
-
|
|
35
|
+
first, _, rest = (command or "").strip().partition(" ")
|
|
36
|
+
verb = _clean(first or "-", 32)
|
|
29
37
|
parts = [time.strftime("%Y-%m-%dT%H:%M:%S%z"), source, f"verb={verb}",
|
|
30
38
|
f"where={_clean(where or '', WHERE_MAX)!r}"]
|
|
39
|
+
if first.lower() in HEARD_VERBS:
|
|
40
|
+
parts.append(f"heard={_clean(rest.strip(), HEARD_MAX)!r}")
|
|
31
41
|
if (reply or "").startswith("ERR"):
|
|
32
42
|
parts.append(f"reply={_clean(reply, ERR_MAX)!r}")
|
|
33
43
|
return " ".join(parts)
|
|
@@ -15,14 +15,22 @@ Matching runs in tiers, highest score wins:
|
|
|
15
15
|
650 the query COVERS the name, plus extra words ("john deere")
|
|
16
16
|
600 consonant skeletons match ("volunteer")
|
|
17
17
|
400 fuzzy, as a last resort
|
|
18
|
+
|
|
19
|
+
:func:`decide` sits on top: it also scores the words run together ("shop if
|
|
20
|
+
y" is "shopify"), treats share classes of one company as one answer, lets
|
|
21
|
+
the symbols this Mac charted recently settle a near-tie, and - when two
|
|
22
|
+
different companies are still too close to call - returns both, so the
|
|
23
|
+
reply can ask "Did you mean ...?" instead of guessing. A confident wrong
|
|
24
|
+
ticker is worse than a question.
|
|
18
25
|
"""
|
|
19
26
|
|
|
20
27
|
from __future__ import annotations
|
|
21
28
|
|
|
22
29
|
import difflib
|
|
23
30
|
import re
|
|
31
|
+
from dataclasses import dataclass, field
|
|
24
32
|
|
|
25
|
-
__all__ = ["candidates", "normalize", "phonetic", "resolve", "spelled"]
|
|
33
|
+
__all__ = ["Decision", "candidates", "decide", "normalize", "phonetic", "resolve", "spelled"]
|
|
26
34
|
|
|
27
35
|
# Corporate furniture, stripped from both sides before comparison. "and"
|
|
28
36
|
# is here because "&" normalises to it, so DEERE & CO reduces to "deere".
|
|
@@ -58,6 +66,8 @@ ALIASES = {
|
|
|
58
66
|
"callalon": "QCOM", # nasal "Qualcomm"
|
|
59
67
|
"ge aerospace": "GE", # still filed as GENERAL ELECTRIC CO
|
|
60
68
|
"ge aviation": "GE",
|
|
69
|
+
"google": "GOOGL", # filed as Alphabet Inc.
|
|
70
|
+
"facebook": "META", # filed as Meta Platforms
|
|
61
71
|
}
|
|
62
72
|
|
|
63
73
|
# Soundex-style consonant coding: vowels carry almost no information in a
|
|
@@ -85,6 +95,16 @@ MIN_PREFIX = 4
|
|
|
85
95
|
MIN_FUZZY = 5
|
|
86
96
|
FUZZY_FLOOR = 0.82
|
|
87
97
|
NAME_COVERAGE = 0.6 # how much of the company name the query must cover
|
|
98
|
+
#: Two different companies scoring within this of each other is a question.
|
|
99
|
+
MARGIN = 10.0
|
|
100
|
+
#: A symbol charted recently wins when it scores within this of the best -
|
|
101
|
+
#: unless the best is the ticker itself or the exact company name.
|
|
102
|
+
PRIOR_MARGIN = 25.0
|
|
103
|
+
CERTAIN = 900.0
|
|
104
|
+
#: A close-scoring name far less prominent than the best one (by its place
|
|
105
|
+
#: in the SEC file) is no real contender: "robinhood" is HOOD, not a fund.
|
|
106
|
+
def _contends(rank: int, top_rank: int) -> bool:
|
|
107
|
+
return rank <= 4 * top_rank + 400
|
|
88
108
|
|
|
89
109
|
|
|
90
110
|
def normalize(text: str) -> str:
|
|
@@ -195,13 +215,85 @@ def candidates(query: str, rows: list[dict]) -> list[tuple[float, str, str]]:
|
|
|
195
215
|
return out
|
|
196
216
|
|
|
197
217
|
|
|
198
|
-
|
|
199
|
-
|
|
218
|
+
@dataclass(frozen=True)
|
|
219
|
+
class Decision:
|
|
220
|
+
"""What a spoken company name came to.
|
|
221
|
+
|
|
222
|
+
``ticker`` is set only when the answer is clear. ``options`` holds the
|
|
223
|
+
(ticker, spoken name) pairs too close to call - never set together with
|
|
224
|
+
``ticker``. Neither set means nothing matched.
|
|
225
|
+
"""
|
|
226
|
+
|
|
227
|
+
ticker: str | None = None
|
|
228
|
+
options: list[tuple[str, str]] = field(default_factory=list)
|
|
229
|
+
|
|
230
|
+
def question(self) -> str:
|
|
231
|
+
"""'Did you mean PLTR (Palantir Technologies) or FLUT (Flutter Entertainment)?'"""
|
|
232
|
+
said = [f"{t} ({n})" for t, n in self.options]
|
|
233
|
+
return "Did you mean " + ", ".join(said[:-1]) + " or " + said[-1] + "?"
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def spoken_name(name: str) -> str:
|
|
237
|
+
"""'PALANTIR TECHNOLOGIES INC.' -> 'Palantir Technologies' (three words at most)."""
|
|
238
|
+
words = strip_suffixes(normalize(name)).split()[:3]
|
|
239
|
+
return " ".join(words).title()
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _ranked(query: str, rows: list[dict]) -> list[tuple[float, str, str]]:
|
|
243
|
+
"""Candidates for the query and for its words run together, best per ticker."""
|
|
244
|
+
best: dict[str, tuple[float, str, str]] = {}
|
|
245
|
+
forms = [query]
|
|
246
|
+
# "and" stays in: "pal and tear" run together sounds like Palantir. Words
|
|
247
|
+
# that are all filler are still refused, run together or not.
|
|
248
|
+
words = normalize(query).split()
|
|
249
|
+
if len(words) > 1 and set(words) - STOPWORDS:
|
|
250
|
+
forms.append("".join(words))
|
|
251
|
+
for form in forms:
|
|
252
|
+
for cand in candidates(form, rows):
|
|
253
|
+
if cand[1] not in best or cand[0] > best[cand[1]][0]:
|
|
254
|
+
best[cand[1]] = cand
|
|
255
|
+
return sorted(best.values(), key=lambda c: -c[0])
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def decide(query: str, rows: list[dict], recent: list[str] | tuple = ()) -> Decision:
|
|
259
|
+
"""The ticker for a spoken name, the options to ask about, or nothing.
|
|
260
|
+
|
|
261
|
+
``recent`` is the symbols this Mac charted lately, most recent first;
|
|
262
|
+
it only ever settles a near-tie (see :data:`PRIOR_MARGIN`).
|
|
263
|
+
"""
|
|
200
264
|
letters = spelled(query)
|
|
201
265
|
if letters and any(r["t"] == letters for r in rows):
|
|
202
|
-
return letters
|
|
266
|
+
return Decision(letters)
|
|
203
267
|
direct = ALIASES.get(normalize(query))
|
|
204
268
|
if direct:
|
|
205
|
-
return direct
|
|
206
|
-
|
|
207
|
-
|
|
269
|
+
return Decision(direct)
|
|
270
|
+
ranks = {r["t"]: r.get("r", 0) for r in rows}
|
|
271
|
+
ranked = _ranked(query, rows)
|
|
272
|
+
if not ranked:
|
|
273
|
+
return Decision()
|
|
274
|
+
# One company, many share classes (F, F-PB, F-PC...): one answer, the best.
|
|
275
|
+
companies: list[tuple[float, str, str]] = []
|
|
276
|
+
seen: set[str] = set()
|
|
277
|
+
for cand in ranked:
|
|
278
|
+
key = strip_suffixes(normalize(cand[2]))
|
|
279
|
+
if key not in seen:
|
|
280
|
+
seen.add(key)
|
|
281
|
+
companies.append(cand)
|
|
282
|
+
top = companies[0][0]
|
|
283
|
+
order = {t.upper(): i for i, t in reversed(list(enumerate(recent)))}
|
|
284
|
+
if top < CERTAIN:
|
|
285
|
+
known = [c for c in companies if c[1] in order and top - c[0] <= PRIOR_MARGIN]
|
|
286
|
+
if known:
|
|
287
|
+
return Decision(min(known, key=lambda c: order[c[1]])[1])
|
|
288
|
+
top_rank = ranks.get(companies[0][1], 0)
|
|
289
|
+
close = [c for c in companies if top - c[0] <= MARGIN
|
|
290
|
+
and (c is companies[0] or _contends(ranks.get(c[1], 0), top_rank))]
|
|
291
|
+
if len(close) == 1:
|
|
292
|
+
return Decision(close[0][1])
|
|
293
|
+
return Decision(options=[(t, spoken_name(n)) for _, t, n in close[:3]])
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def resolve(query: str, rows: list[dict], recent: list[str] | tuple = ()) -> str | None:
|
|
297
|
+
"""Best ticker for a spoken query, or None when nothing is convincing or
|
|
298
|
+
the answer is too close to call (see :func:`decide`)."""
|
|
299
|
+
return decide(query, rows, recent).ticker
|