chartremotely 0.3.6__tar.gz → 0.3.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.3.6 → chartremotely-0.3.8}/PKG-INFO +1 -1
- {chartremotely-0.3.6 → chartremotely-0.3.8}/chartremotely/cli.py +1 -1
- {chartremotely-0.3.6 → chartremotely-0.3.8}/chartremotely/doctor.py +42 -13
- chartremotely-0.3.8/chartremotely/registry.py +122 -0
- {chartremotely-0.3.6 → chartremotely-0.3.8}/chartremotely/resolve.py +7 -1
- {chartremotely-0.3.6 → chartremotely-0.3.8}/chartremotely/services.py +25 -0
- {chartremotely-0.3.6 → chartremotely-0.3.8}/chartremotely/setup.py +9 -1
- {chartremotely-0.3.6 → chartremotely-0.3.8}/chartremotely/snapshot.py +23 -0
- {chartremotely-0.3.6 → chartremotely-0.3.8}/chartremotely/symbol.py +1 -3
- {chartremotely-0.3.6 → chartremotely-0.3.8}/chartremotely/understand.py +3 -3
- {chartremotely-0.3.6 → chartremotely-0.3.8}/chartremotely/window.py +4 -2
- {chartremotely-0.3.6 → chartremotely-0.3.8}/chartremotely.egg-info/PKG-INFO +1 -1
- {chartremotely-0.3.6 → chartremotely-0.3.8}/chartremotely.egg-info/SOURCES.txt +2 -0
- {chartremotely-0.3.6 → chartremotely-0.3.8}/pyproject.toml +1 -1
- chartremotely-0.3.8/tests/test_doctor.py +42 -0
- chartremotely-0.3.8/tests/test_registry.py +119 -0
- {chartremotely-0.3.6 → chartremotely-0.3.8}/tests/test_snapshot.py +20 -0
- chartremotely-0.3.6/chartremotely/registry.py +0 -53
- {chartremotely-0.3.6 → chartremotely-0.3.8}/LICENSE +0 -0
- {chartremotely-0.3.6 → chartremotely-0.3.8}/README.md +0 -0
- {chartremotely-0.3.6 → chartremotely-0.3.8}/chartremotely/__init__.py +0 -0
- {chartremotely-0.3.6 → chartremotely-0.3.8}/chartremotely/answer.py +0 -0
- {chartremotely-0.3.6 → chartremotely-0.3.8}/chartremotely/assets/shortcut.plist +0 -0
- {chartremotely-0.3.6 → chartremotely-0.3.8}/chartremotely/ax.py +0 -0
- {chartremotely-0.3.6 → chartremotely-0.3.8}/chartremotely/config.py +0 -0
- {chartremotely-0.3.6 → chartremotely-0.3.8}/chartremotely/displays.py +0 -0
- {chartremotely-0.3.6 → chartremotely-0.3.8}/chartremotely/guilock.py +0 -0
- {chartremotely-0.3.6 → chartremotely-0.3.8}/chartremotely/hearing.py +0 -0
- {chartremotely-0.3.6 → chartremotely-0.3.8}/chartremotely/holder.py +0 -0
- {chartremotely-0.3.6 → chartremotely-0.3.8}/chartremotely/keystore.py +0 -0
- {chartremotely-0.3.6 → chartremotely-0.3.8}/chartremotely/layout.py +0 -0
- {chartremotely-0.3.6 → chartremotely-0.3.8}/chartremotely/mcpclient.py +0 -0
- {chartremotely-0.3.6 → chartremotely-0.3.8}/chartremotely/patron.py +0 -0
- {chartremotely-0.3.6 → chartremotely-0.3.8}/chartremotely/popups.py +0 -0
- {chartremotely-0.3.6 → chartremotely-0.3.8}/chartremotely/push.py +0 -0
- {chartremotely-0.3.6 → chartremotely-0.3.8}/chartremotely/recent.py +0 -0
- {chartremotely-0.3.6 → chartremotely-0.3.8}/chartremotely/relay.py +0 -0
- {chartremotely-0.3.6 → chartremotely-0.3.8}/chartremotely/requestlog.py +0 -0
- {chartremotely-0.3.6 → chartremotely-0.3.8}/chartremotely/scales.py +0 -0
- {chartremotely-0.3.6 → chartremotely-0.3.8}/chartremotely/screen.py +0 -0
- {chartremotely-0.3.6 → chartremotely-0.3.8}/chartremotely/server.py +0 -0
- {chartremotely-0.3.6 → chartremotely-0.3.8}/chartremotely/shortcut.py +0 -0
- {chartremotely-0.3.6 → chartremotely-0.3.8}/chartremotely/studies.py +0 -0
- {chartremotely-0.3.6 → chartremotely-0.3.8}/chartremotely/tailnet.py +0 -0
- {chartremotely-0.3.6 → chartremotely-0.3.8}/chartremotely/terminal.py +0 -0
- {chartremotely-0.3.6 → chartremotely-0.3.8}/chartremotely/timeframe.py +0 -0
- {chartremotely-0.3.6 → chartremotely-0.3.8}/chartremotely/vocab.py +0 -0
- {chartremotely-0.3.6 → chartremotely-0.3.8}/chartremotely/voice.py +0 -0
- {chartremotely-0.3.6 → chartremotely-0.3.8}/chartremotely.egg-info/dependency_links.txt +0 -0
- {chartremotely-0.3.6 → chartremotely-0.3.8}/chartremotely.egg-info/entry_points.txt +0 -0
- {chartremotely-0.3.6 → chartremotely-0.3.8}/chartremotely.egg-info/requires.txt +0 -0
- {chartremotely-0.3.6 → chartremotely-0.3.8}/chartremotely.egg-info/top_level.txt +0 -0
- {chartremotely-0.3.6 → chartremotely-0.3.8}/setup.cfg +0 -0
- {chartremotely-0.3.6 → chartremotely-0.3.8}/tests/test_changelog.py +0 -0
- {chartremotely-0.3.6 → chartremotely-0.3.8}/tests/test_hearing.py +0 -0
- {chartremotely-0.3.6 → chartremotely-0.3.8}/tests/test_patron.py +0 -0
- {chartremotely-0.3.6 → chartremotely-0.3.8}/tests/test_popups.py +0 -0
- {chartremotely-0.3.6 → chartremotely-0.3.8}/tests/test_push.py +0 -0
- {chartremotely-0.3.6 → chartremotely-0.3.8}/tests/test_recent.py +0 -0
- {chartremotely-0.3.6 → chartremotely-0.3.8}/tests/test_relay.py +0 -0
- {chartremotely-0.3.6 → chartremotely-0.3.8}/tests/test_requestlog.py +0 -0
- {chartremotely-0.3.6 → chartremotely-0.3.8}/tests/test_resolve.py +0 -0
- {chartremotely-0.3.6 → chartremotely-0.3.8}/tests/test_scales.py +0 -0
- {chartremotely-0.3.6 → chartremotely-0.3.8}/tests/test_screen.py +0 -0
- {chartremotely-0.3.6 → chartremotely-0.3.8}/tests/test_setup.py +0 -0
- {chartremotely-0.3.6 → chartremotely-0.3.8}/tests/test_stamp_changelog.py +0 -0
- {chartremotely-0.3.6 → chartremotely-0.3.8}/tests/test_timeframe.py +0 -0
- {chartremotely-0.3.6 → chartremotely-0.3.8}/tests/test_understand.py +0 -0
- {chartremotely-0.3.6 → chartremotely-0.3.8}/tests/test_voice.py +0 -0
|
@@ -7,6 +7,7 @@ check names the fix, not just the symptom.
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
+
import os
|
|
10
11
|
import urllib.error
|
|
11
12
|
import urllib.request
|
|
12
13
|
|
|
@@ -33,18 +34,45 @@ def _check_app() -> tuple[str, str]:
|
|
|
33
34
|
return OK, f"thinkorswim running (pid {app.processIdentifier()})"
|
|
34
35
|
|
|
35
36
|
|
|
36
|
-
def
|
|
37
|
-
"""
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
37
|
+
def permission_verdict(granted: dict) -> tuple[str, str]:
|
|
38
|
+
"""What the services' own Python holds; see :func:`services.probe_permissions`."""
|
|
39
|
+
if not granted:
|
|
40
|
+
return BAD, "the permission check did not answer - run: chartremotely setup"
|
|
41
|
+
missing = [name for name, key in (("Accessibility", "accessibility"),
|
|
42
|
+
("Screen Recording", "screen_recording")) if not granted.get(key)]
|
|
43
|
+
if missing:
|
|
44
|
+
python = granted.get("python", "the services' Python")
|
|
45
|
+
return BAD, (f"{' and '.join(missing)} off for {python} - "
|
|
46
|
+
"run: chartremotely setup (a Python upgrade revokes both)")
|
|
47
|
+
return OK, "Accessibility and Screen Recording allowed for the services' Python"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _check_permissions() -> tuple[str, str]:
|
|
51
|
+
"""Asked as a launchd job, as the services run: the Terminal's own grants say nothing about theirs."""
|
|
52
|
+
import tempfile
|
|
53
|
+
from pathlib import Path
|
|
54
|
+
|
|
55
|
+
from . import services
|
|
56
|
+
return permission_verdict(services.probe_permissions(
|
|
57
|
+
request=False, out=Path(tempfile.gettempdir()) / "chartremotely-permissions.json"))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def service_verdict(running: dict[str, str | None], exists=os.path.exists) -> tuple[str, str]:
|
|
61
|
+
"""Each service, and whether the Python it runs is still on disk."""
|
|
62
|
+
stopped = [name for name, python in running.items() if python is None]
|
|
63
|
+
orphaned = [name for name, python in running.items() if python and not exists(python)]
|
|
64
|
+
if orphaned:
|
|
65
|
+
kick = "; ".join(f"launchctl kickstart -k gui/$UID/com.chartremotely.{name}" for name in orphaned)
|
|
66
|
+
return BAD, (f"{', '.join(orphaned)} still on a Python that was upgraded away - "
|
|
67
|
+
f"restart: {kick}, then run: chartremotely doctor")
|
|
68
|
+
if stopped:
|
|
69
|
+
return BAD, f"{', '.join(stopped)} not running - run: chartremotely setup"
|
|
70
|
+
return OK, "serve and relay running"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _check_services() -> tuple[str, str]:
|
|
74
|
+
from . import services
|
|
75
|
+
return service_verdict({name: services.running_python(name) for name in services.LABELS})
|
|
48
76
|
|
|
49
77
|
|
|
50
78
|
def _check_controls() -> tuple[str, str]:
|
|
@@ -97,7 +125,8 @@ def _check_listener() -> tuple[str, str]:
|
|
|
97
125
|
CHECKS = (
|
|
98
126
|
("PyObjC", _check_pyobjc),
|
|
99
127
|
("thinkorswim", _check_app),
|
|
100
|
-
("
|
|
128
|
+
("Services", _check_services),
|
|
129
|
+
("Permissions", _check_permissions),
|
|
101
130
|
("Chart controls", _check_controls),
|
|
102
131
|
("Registry", _check_registry),
|
|
103
132
|
("Listener", _check_listener),
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""The security registry: SEC issuers plus listed ETFs, cached locally.
|
|
2
|
+
|
|
3
|
+
The SEC file holds roughly 10,500 US issuers, ordered by prominence, which
|
|
4
|
+
makes the index a usable tie-breaker: NVDA, AAPL and GOOGL are the first
|
|
5
|
+
three entries. It lists a fund only when the fund files as its own company
|
|
6
|
+
(SPY, GLD), so the sector and thematic ETFs that file as a series of a
|
|
7
|
+
parent trust - XBI, XLE, QQQ, SMH - are absent from it.
|
|
8
|
+
|
|
9
|
+
Nasdaq Trader's symbol directory fills that gap: every US-listed security,
|
|
10
|
+
each flagged as an ETF or not, with its full name. Only its ETF rows are
|
|
11
|
+
taken, ranked after every SEC issuer, so a spoken company name still finds
|
|
12
|
+
the company before a fund that shares a word with it.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import time
|
|
19
|
+
import urllib.request
|
|
20
|
+
|
|
21
|
+
from .config import DATA_DIR
|
|
22
|
+
|
|
23
|
+
SEC_URL = "https://www.sec.gov/files/company_tickers.json"
|
|
24
|
+
NASDAQ_URL = "https://www.nasdaqtrader.com/dynamic/SymDir/nasdaqtraded.txt"
|
|
25
|
+
CACHE = DATA_DIR / "tickers.json"
|
|
26
|
+
ETF_CACHE = DATA_DIR / "etfs.json"
|
|
27
|
+
MAX_AGE = 7 * 24 * 3600
|
|
28
|
+
# The SEC requires a User-Agent carrying a contact address and answers 403
|
|
29
|
+
# without one. A repository URL alone is not enough.
|
|
30
|
+
DEFAULT_CONTACT = "chartremotely@example.com"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _user_agent() -> str:
|
|
34
|
+
from .config import load
|
|
35
|
+
contact = load().get("contact") or DEFAULT_CONTACT
|
|
36
|
+
# SEC's documented format is plain "Name email". Anything fancier is
|
|
37
|
+
# rejected: a User-Agent carrying a URL answers 403, as does one with no
|
|
38
|
+
# contact address at all.
|
|
39
|
+
return f"ChartRemotely {contact}"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _fresh(path=CACHE) -> bool:
|
|
43
|
+
return path.exists() and time.time() - path.stat().st_mtime < MAX_AGE
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _store(path, rows: list) -> None:
|
|
47
|
+
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
tmp = path.with_suffix(".tmp")
|
|
49
|
+
tmp.write_text(json.dumps(rows))
|
|
50
|
+
tmp.replace(path)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def refresh() -> list[dict]:
|
|
54
|
+
request = urllib.request.Request(SEC_URL, headers={"User-Agent": _user_agent()})
|
|
55
|
+
with urllib.request.urlopen(request, timeout=20) as response:
|
|
56
|
+
raw = json.load(response)
|
|
57
|
+
rows = [{"t": v["ticker"], "n": v["title"], "r": rank}
|
|
58
|
+
for rank, v in enumerate(raw.values())]
|
|
59
|
+
_store(CACHE, rows)
|
|
60
|
+
return rows
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def parse_etfs(text: str) -> list[list[str]]:
|
|
64
|
+
"""``[symbol, name]`` for each live ETF in a Nasdaq Trader directory.
|
|
65
|
+
|
|
66
|
+
Pipe-delimited with a header row and a closing "File Creation Time"
|
|
67
|
+
line. Test issues and symbols no longer traded are dropped.
|
|
68
|
+
"""
|
|
69
|
+
lines = text.splitlines()
|
|
70
|
+
if not lines:
|
|
71
|
+
return []
|
|
72
|
+
head = lines[0].split("|")
|
|
73
|
+
try:
|
|
74
|
+
traded, sym, name, etf, test = (head.index(c) for c in (
|
|
75
|
+
"Nasdaq Traded", "Symbol", "Security Name", "ETF", "Test Issue"))
|
|
76
|
+
except ValueError:
|
|
77
|
+
return []
|
|
78
|
+
out = []
|
|
79
|
+
for line in lines[1:]:
|
|
80
|
+
f = line.split("|")
|
|
81
|
+
if len(f) < len(head):
|
|
82
|
+
continue
|
|
83
|
+
if f[traded] == "Y" and f[etf] == "Y" and f[test] == "N" and f[sym]:
|
|
84
|
+
out.append([f[sym].strip(), f[name].strip()])
|
|
85
|
+
return out
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def refresh_etfs() -> list[list[str]]:
|
|
89
|
+
request = urllib.request.Request(NASDAQ_URL, headers={"User-Agent": _user_agent()})
|
|
90
|
+
with urllib.request.urlopen(request, timeout=20) as response:
|
|
91
|
+
etfs = parse_etfs(response.read().decode("utf-8", "replace"))
|
|
92
|
+
if etfs:
|
|
93
|
+
_store(ETF_CACHE, etfs)
|
|
94
|
+
return etfs
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _etfs(force: bool) -> list[list[str]]:
|
|
98
|
+
"""The ETF rows, refreshed weekly. Never what breaks a lookup: when the
|
|
99
|
+
directory cannot be fetched, the last copy serves, else no ETFs."""
|
|
100
|
+
if force or not _fresh(ETF_CACHE):
|
|
101
|
+
try:
|
|
102
|
+
etfs = refresh_etfs()
|
|
103
|
+
if etfs:
|
|
104
|
+
return etfs
|
|
105
|
+
except (OSError, ValueError):
|
|
106
|
+
pass
|
|
107
|
+
try:
|
|
108
|
+
return json.loads(ETF_CACHE.read_text())
|
|
109
|
+
except (OSError, ValueError):
|
|
110
|
+
return []
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def merge(sec: list[dict], etfs: list[list[str]]) -> list[dict]:
|
|
114
|
+
"""SEC rows first, then each ETF the SEC file lacks, ranked after them."""
|
|
115
|
+
have = {r["t"] for r in sec}
|
|
116
|
+
extra = [(t, n) for t, n in etfs if t not in have]
|
|
117
|
+
return sec + [{"t": t, "n": n, "r": len(sec) + i} for i, (t, n) in enumerate(extra)]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def load(force: bool = False) -> list[dict]:
|
|
121
|
+
sec = refresh() if force or not _fresh() else json.loads(CACHE.read_text())
|
|
122
|
+
return merge(sec, _etfs(force))
|
|
@@ -30,7 +30,13 @@ import difflib
|
|
|
30
30
|
import re
|
|
31
31
|
from dataclasses import dataclass, field
|
|
32
32
|
|
|
33
|
-
__all__ = ["Decision", "candidates", "decide", "normalize", "phonetic", "resolve", "spelled"]
|
|
33
|
+
__all__ = ["TICKER", "Decision", "candidates", "decide", "normalize", "phonetic", "resolve", "spelled"]
|
|
34
|
+
|
|
35
|
+
# A stock (PLTR), share class (BRK/B), crypto pair (BTC/USD), index ($SPX)
|
|
36
|
+
# or dated future (/ESZ25:XCME). thinkorswim's symbol field must hold one
|
|
37
|
+
# of these before the agent types into it, so a shape left out here refuses
|
|
38
|
+
# every request that follows it.
|
|
39
|
+
TICKER = re.compile(r"^[./$]?[A-Z][A-Z0-9]{0,5}([/.][A-Z]{1,4})?(:[A-Z]+)?$")
|
|
34
40
|
|
|
35
41
|
# Corporate furniture, stripped from both sides before comparison. "and"
|
|
36
42
|
# is here because "&" normalises to it, so DEERE & CO reduces to "deere".
|
|
@@ -69,6 +69,31 @@ def loaded(label: str) -> bool:
|
|
|
69
69
|
capture_output=True, check=False).returncode == 0
|
|
70
70
|
|
|
71
71
|
|
|
72
|
+
def pid_in(printed: str) -> int | None:
|
|
73
|
+
"""The ``pid = N`` line of ``launchctl print``; None when the job is not running."""
|
|
74
|
+
for line in printed.splitlines():
|
|
75
|
+
key, _, value = line.strip().partition(" = ")
|
|
76
|
+
if key == "pid" and value.isdigit():
|
|
77
|
+
return int(value)
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def running_python(command: str) -> str | None:
|
|
82
|
+
"""The executable a running service was started from, or None if it is not running.
|
|
83
|
+
|
|
84
|
+
macOS grants Accessibility and Screen Recording to that exact file. A
|
|
85
|
+
Homebrew upgrade replaces it while the service keeps running the old,
|
|
86
|
+
deleted one - which then holds neither grant.
|
|
87
|
+
"""
|
|
88
|
+
printed = subprocess.run(["launchctl", "print", f"{_domain()}/{LABELS[command]}"],
|
|
89
|
+
capture_output=True, text=True, check=False)
|
|
90
|
+
pid = pid_in(printed.stdout) if printed.returncode == 0 else None
|
|
91
|
+
if pid is None:
|
|
92
|
+
return None
|
|
93
|
+
ps = subprocess.run(["ps", "-o", "comm=", "-p", str(pid)], capture_output=True, text=True, check=False)
|
|
94
|
+
return ps.stdout.strip() or None
|
|
95
|
+
|
|
96
|
+
|
|
72
97
|
def install(command: str, settle: float = 10.0) -> Path:
|
|
73
98
|
"""Write and (re)start one service. Idempotent.
|
|
74
99
|
|
|
@@ -294,7 +294,9 @@ def run(ask: Ask = input, say: Say = print, operator_url: str = OPERATOR_URL) ->
|
|
|
294
294
|
out.step(4, total, "Permissions")
|
|
295
295
|
probe = Path(tempfile.gettempdir()) / "chartremotely-permissions.json"
|
|
296
296
|
granted = services.probe_permissions(request=True, out=probe)
|
|
297
|
+
asked = False
|
|
297
298
|
while not (granted.get("accessibility") and granted.get("screen_recording")):
|
|
299
|
+
asked = True
|
|
298
300
|
python = granted.get("python", "the Python that runs ChartRemotely")
|
|
299
301
|
missing = [name for name, key in (("Accessibility", "accessibility"),
|
|
300
302
|
("Screen Recording", "screen_recording")) if not granted.get(key)]
|
|
@@ -302,8 +304,14 @@ def run(ask: Ask = input, say: Say = print, operator_url: str = OPERATOR_URL) ->
|
|
|
302
304
|
out.say(python)
|
|
303
305
|
pane = "Privacy_Accessibility" if not granted.get("accessibility") else "Privacy_ScreenCapture"
|
|
304
306
|
subprocess.run(["open", f"x-apple.systempreferences:com.apple.preference.security?{pane}"], check=False)
|
|
305
|
-
|
|
307
|
+
# The list's + picker will not select a bare executable; Finder can drag it in.
|
|
308
|
+
subprocess.run(["open", "-R", python], check=False)
|
|
309
|
+
out.ask("Switch it on in that list (or drag it in from the Finder window), then press Return… ")
|
|
306
310
|
granted = services.probe_permissions(request=False, out=probe)
|
|
311
|
+
if asked:
|
|
312
|
+
# A grant reaches a running process only when it restarts.
|
|
313
|
+
for command in ("serve", "relay"):
|
|
314
|
+
services.install(command)
|
|
307
315
|
out.ok("Accessibility and Screen Recording are allowed.")
|
|
308
316
|
|
|
309
317
|
# 5. Hearing: the speech model, and the display names a sentence may say.
|
|
@@ -38,6 +38,29 @@ class ChartNotIsolated(LookupError):
|
|
|
38
38
|
"""The chart's own pane could not be told apart from the whole window."""
|
|
39
39
|
|
|
40
40
|
|
|
41
|
+
class CaptureFailed(RuntimeError):
|
|
42
|
+
"""macOS would not photograph the window; the message says why."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
#: The fix for a refused capture. The grant belongs to the exact Python the
|
|
46
|
+
#: services run, so a Homebrew upgrade of that Python silently revokes it.
|
|
47
|
+
SCREEN_RECORDING_OFF = ("Screen Recording is off for this agent's Python "
|
|
48
|
+
"(a Python upgrade revokes it). Run: chartremotely setup")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def capture_failed(stderr: str, allowed: bool) -> CaptureFailed:
|
|
52
|
+
"""Name why ``screencapture`` failed, in words a reply can carry.
|
|
53
|
+
|
|
54
|
+
``allowed`` is whether this process holds Screen Recording. Without it,
|
|
55
|
+
that is the cause whatever screencapture printed; with it, its own first
|
|
56
|
+
line is the best account there is.
|
|
57
|
+
"""
|
|
58
|
+
if not allowed:
|
|
59
|
+
return CaptureFailed(SCREEN_RECORDING_OFF)
|
|
60
|
+
said = next((line.strip() for line in (stderr or "").splitlines() if line.strip()), "")
|
|
61
|
+
return CaptureFailed(f"screencapture failed: {said}" if said else "screencapture failed and said nothing")
|
|
62
|
+
|
|
63
|
+
|
|
41
64
|
def chart_crop(hit: tuple[int, int], panes: list[Rect], window: Rect,
|
|
42
65
|
image: tuple[int, int]) -> Rect:
|
|
43
66
|
"""The chart's pane, in the capture's own pixels.
|
|
@@ -14,9 +14,7 @@ import time
|
|
|
14
14
|
import Quartz
|
|
15
15
|
|
|
16
16
|
from . import ax, config, layout, popups
|
|
17
|
-
|
|
18
|
-
# Accepts tickers, futures (/ES), indices (.SPX) and share classes (BRK/B).
|
|
19
|
-
TICKER = re.compile(r"^[./$]?[A-Z]{1,6}([/.][A-Z]{1,2})?(:[A-Z]+)?$")
|
|
17
|
+
from .resolve import TICKER
|
|
20
18
|
|
|
21
19
|
#: Longest show() waits for the chart's title to name the new symbol.
|
|
22
20
|
COMMIT_WAIT = 3.0
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Siri's dictation (and, next, a Whisper transcription of the whole
|
|
4
4
|
utterance) matches against all of English. What we need back is small: a
|
|
5
|
-
company from the
|
|
5
|
+
company or ETF from the registry, one of ten time frames, and the name of a
|
|
6
6
|
display. This module takes the text as heard - "Palantir half on mac mini",
|
|
7
7
|
"shop thirty minutes", "GE daily on the studio", "apple as is on desk" - and
|
|
8
8
|
recovers those three, refusing rather than guessing.
|
|
@@ -22,7 +22,7 @@ It works right to left, the way people say it:
|
|
|
22
22
|
symbols charted lately as a prior. Too close to call is an answer too:
|
|
23
23
|
the options, so the reply can ask.
|
|
24
24
|
|
|
25
|
-
Pure and deterministic: no LLM, no network beyond the cached
|
|
25
|
+
Pure and deterministic: no LLM, no network beyond the cached security registry.
|
|
26
26
|
"""
|
|
27
27
|
|
|
28
28
|
from __future__ import annotations
|
|
@@ -183,7 +183,7 @@ def understand(text: str, *, recent: list[str], displays: list[str],
|
|
|
183
183
|
|
|
184
184
|
``recent``: symbols charted lately, most recent first (:mod:`recent`).
|
|
185
185
|
``displays``: the owner's display names, to split off "where". ``rows``:
|
|
186
|
-
the
|
|
186
|
+
the security registry, loaded from its cache when not given.
|
|
187
187
|
"""
|
|
188
188
|
if rows is None:
|
|
189
189
|
from . import registry
|
|
@@ -140,8 +140,10 @@ def capture(app=None) -> bytes:
|
|
|
140
140
|
|
|
141
141
|
with tempfile.TemporaryDirectory() as tmp:
|
|
142
142
|
shot = Path(tmp) / "window.png"
|
|
143
|
-
subprocess.run(["screencapture", "-x", "-o", "-l", str(target["kCGWindowNumber"]), str(shot)],
|
|
144
|
-
|
|
143
|
+
ran = subprocess.run(["screencapture", "-x", "-o", "-l", str(target["kCGWindowNumber"]), str(shot)],
|
|
144
|
+
check=False, capture_output=True, text=True)
|
|
145
|
+
if ran.returncode != 0 or not shot.exists():
|
|
146
|
+
raise snapshot.capture_failed(ran.stderr, bool(Quartz.CGPreflightScreenCaptureAccess()))
|
|
145
147
|
crop = snapshot.chart_crop(hit, panes, bounds, _pixel_size(shot))
|
|
146
148
|
chart = Path(tmp) / "chart.png"
|
|
147
149
|
_crop(shot, chart, *crop)
|
|
@@ -46,11 +46,13 @@ chartremotely.egg-info/requires.txt
|
|
|
46
46
|
chartremotely.egg-info/top_level.txt
|
|
47
47
|
chartremotely/assets/shortcut.plist
|
|
48
48
|
tests/test_changelog.py
|
|
49
|
+
tests/test_doctor.py
|
|
49
50
|
tests/test_hearing.py
|
|
50
51
|
tests/test_patron.py
|
|
51
52
|
tests/test_popups.py
|
|
52
53
|
tests/test_push.py
|
|
53
54
|
tests/test_recent.py
|
|
55
|
+
tests/test_registry.py
|
|
54
56
|
tests/test_relay.py
|
|
55
57
|
tests/test_requestlog.py
|
|
56
58
|
tests/test_resolve.py
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Doctor's verdicts on the services and their grants - the parts that need no Mac."""
|
|
2
|
+
|
|
3
|
+
from chartremotely import doctor, services
|
|
4
|
+
|
|
5
|
+
OLD = "/opt/homebrew/Cellar/python@3.12/3.12.13/Frameworks/Python.framework/Versions/3.12/bin/python3.12"
|
|
6
|
+
NEW = OLD.replace("3.12.13", "3.12.15_1")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_both_grants_held_is_ok():
|
|
10
|
+
status, _ = doctor.permission_verdict({"python": NEW, "accessibility": True, "screen_recording": True})
|
|
11
|
+
assert status == doctor.OK
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_a_missing_grant_names_it_the_python_and_the_fix():
|
|
15
|
+
status, detail = doctor.permission_verdict({"python": NEW, "accessibility": True, "screen_recording": False})
|
|
16
|
+
assert status == doctor.BAD
|
|
17
|
+
assert "Screen Recording" in detail and "Accessibility" not in detail
|
|
18
|
+
assert NEW in detail and "chartremotely setup" in detail
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_a_probe_that_never_answered_fails():
|
|
22
|
+
assert doctor.permission_verdict({})[0] == doctor.BAD
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_a_service_on_a_deleted_python_says_restart_it():
|
|
26
|
+
status, detail = doctor.service_verdict({"serve": NEW, "relay": OLD}, exists=lambda p: p == NEW)
|
|
27
|
+
assert status == doctor.BAD
|
|
28
|
+
assert "com.chartremotely.relay" in detail and "com.chartremotely.serve" not in detail
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_a_stopped_service_points_at_setup():
|
|
32
|
+
status, detail = doctor.service_verdict({"serve": None, "relay": NEW}, exists=lambda p: True)
|
|
33
|
+
assert status == doctor.BAD and "serve" in detail and "setup" in detail
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_both_running_on_a_live_python_is_ok():
|
|
37
|
+
assert doctor.service_verdict({"serve": NEW, "relay": NEW}, exists=lambda p: True)[0] == doctor.OK
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_launchctl_print_yields_the_pid_only_while_running():
|
|
41
|
+
assert services.pid_in("com.chartremotely.relay = {\n\tstate = running\n\tpid = 12804\n}") == 12804
|
|
42
|
+
assert services.pid_in("com.chartremotely.relay = {\n\tstate = not running\n}") is None
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""The registry merges SEC issuers with Nasdaq Trader's ETFs.
|
|
2
|
+
|
|
3
|
+
The ETF rows exist because "XBI swing scale" found nothing: the SEC file
|
|
4
|
+
lists a fund only when it files as its own company, and the sector ETFs
|
|
5
|
+
file as a series of a parent trust.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from chartremotely import registry
|
|
11
|
+
from chartremotely.resolve import TICKER, resolve
|
|
12
|
+
from chartremotely.understand import understand
|
|
13
|
+
|
|
14
|
+
# The real directory's header, rows as published, and its closing line.
|
|
15
|
+
DIRECTORY = """\
|
|
16
|
+
Nasdaq Traded|Symbol|Security Name|Listing Exchange|Market Category|ETF|Round Lot Size|Test Issue|Financial Status|CQS Symbol|NASDAQ Symbol|NextShares
|
|
17
|
+
Y|XBI|State Street SPDR S&P Biotech ETF|P| |Y|100|N||XBI|XBI|N
|
|
18
|
+
Y|XLE|State Street Energy Select Sector SPDR ETF|P| |Y|100|N||XLE|XLE|N
|
|
19
|
+
Y|QQQ|Invesco QQQ Trust, Series 1|Q|G|Y|40|N|N||QQQ|N
|
|
20
|
+
Y|SPY|SPDR S&P 500 ETF Trust|P| |Y|100|N||SPY|SPY|N
|
|
21
|
+
Y|STRC|Strategy Inc - Variable Rate Series A Perpetual Stretch Preferred Stock|Q|Q|N|100|N|N||STRC|N
|
|
22
|
+
Y|ZXZZT|NASDAQ TEST STOCK|Q|G|Y|100|Y|N||ZXZZT|N
|
|
23
|
+
N|GONE|Delisted Example ETF|P| |Y|100|N||GONE|GONE|N
|
|
24
|
+
File Creation Time: 1021202609:01|||||||||||
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
SEC = [
|
|
28
|
+
{"t": "NVDA", "n": "NVIDIA CORP", "r": 0},
|
|
29
|
+
{"t": "PLTR", "n": "Palantir Technologies Inc.", "r": 1},
|
|
30
|
+
{"t": "XOM", "n": "EXXON MOBIL CORP", "r": 2},
|
|
31
|
+
{"t": "KRYS", "n": "Krystal Biotech, Inc.", "r": 3},
|
|
32
|
+
{"t": "SPY", "n": "SPDR S&P 500 ETF TRUST", "r": 4},
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_only_live_etfs_are_taken():
|
|
37
|
+
assert registry.parse_etfs(DIRECTORY) == [
|
|
38
|
+
["XBI", "State Street SPDR S&P Biotech ETF"],
|
|
39
|
+
["XLE", "State Street Energy Select Sector SPDR ETF"],
|
|
40
|
+
["QQQ", "Invesco QQQ Trust, Series 1"],
|
|
41
|
+
["SPY", "SPDR S&P 500 ETF Trust"],
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@pytest.mark.parametrize("text", ["", "not a directory\n", "Symbol|Name\nXBI|x\n"])
|
|
46
|
+
def test_an_unexpected_directory_yields_nothing(text):
|
|
47
|
+
assert registry.parse_etfs(text) == []
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_etfs_rank_after_every_sec_issuer_and_never_duplicate_one():
|
|
51
|
+
rows = registry.merge(SEC, registry.parse_etfs(DIRECTORY))
|
|
52
|
+
assert [r["t"] for r in rows] == ["NVDA", "PLTR", "XOM", "KRYS", "SPY", "XBI", "XLE", "QQQ"]
|
|
53
|
+
assert [r["r"] for r in rows] == list(range(8))
|
|
54
|
+
# The SEC's own SPY row wins over the directory's.
|
|
55
|
+
assert next(r for r in rows if r["t"] == "SPY")["n"] == "SPDR S&P 500 ETF TRUST"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@pytest.fixture
|
|
59
|
+
def caches(tmp_path, monkeypatch):
|
|
60
|
+
monkeypatch.setattr(registry, "DATA_DIR", tmp_path)
|
|
61
|
+
monkeypatch.setattr(registry, "CACHE", tmp_path / "tickers.json")
|
|
62
|
+
monkeypatch.setattr(registry, "ETF_CACHE", tmp_path / "etfs.json")
|
|
63
|
+
monkeypatch.setattr(registry, "refresh", lambda: list(SEC))
|
|
64
|
+
return tmp_path
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_unreachable_directory_serves_the_last_copy(caches, monkeypatch):
|
|
68
|
+
registry._store(registry.ETF_CACHE, [["XBI", "State Street SPDR S&P Biotech ETF"]])
|
|
69
|
+
|
|
70
|
+
def down():
|
|
71
|
+
raise OSError("nasdaqtrader.com unreachable")
|
|
72
|
+
|
|
73
|
+
monkeypatch.setattr(registry, "refresh_etfs", down)
|
|
74
|
+
rows = registry.load(force=True)
|
|
75
|
+
assert [r["t"] for r in rows][-1] == "XBI"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_unreachable_directory_with_no_copy_still_loads_the_sec_file(caches, monkeypatch):
|
|
79
|
+
def down():
|
|
80
|
+
raise OSError("nasdaqtrader.com unreachable")
|
|
81
|
+
|
|
82
|
+
monkeypatch.setattr(registry, "refresh_etfs", down)
|
|
83
|
+
assert registry.load(force=True) == SEC
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@pytest.mark.parametrize("shape", [
|
|
87
|
+
"PLTR", "F", "BRK/B", "BTC/USD", "ETH/USD", "$SPX", ".SPX", "/ES", "/ESZ25:XCME", "XBI",
|
|
88
|
+
])
|
|
89
|
+
def test_ticker_shapes_thinkorswim_shows(shape):
|
|
90
|
+
assert TICKER.match(shape)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@pytest.mark.parametrize("junk", ["", "palantir", "PLTR INC", "12AB", "BTC/USDTXX", "/"])
|
|
94
|
+
def test_not_ticker_shaped(junk):
|
|
95
|
+
assert not TICKER.match(junk)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
ROWS = registry.merge(SEC, registry.parse_etfs(DIRECTORY))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@pytest.mark.parametrize("said,expected", [
|
|
102
|
+
("XBI", "XBI"),
|
|
103
|
+
("x b i", "XBI"),
|
|
104
|
+
("ex bee eye", "XBI"),
|
|
105
|
+
("XLE", "XLE"),
|
|
106
|
+
("x l e", "XLE"),
|
|
107
|
+
("q q q", "QQQ"),
|
|
108
|
+
("energy select sector", "XLE"),
|
|
109
|
+
# Companies still find themselves first.
|
|
110
|
+
("palantir", "PLTR"),
|
|
111
|
+
("exxon mobil", "XOM"),
|
|
112
|
+
])
|
|
113
|
+
def test_etfs_resolve_by_ticker_and_by_name(said, expected):
|
|
114
|
+
assert resolve(said, ROWS) == expected
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_an_etf_with_a_scale_and_a_display():
|
|
118
|
+
u = understand("XBI swing scale on Mac mini", recent=[], displays=["Mac mini"], rows=ROWS)
|
|
119
|
+
assert (u.ticker, u.scale, u.where) == ("XBI", "swing", "Mac mini")
|
|
@@ -61,3 +61,23 @@ def test_a_point_outside_every_pane_is_refused():
|
|
|
61
61
|
def test_the_crop_never_runs_past_the_image():
|
|
62
62
|
x, y, w, h = snapshot.chart_crop((10, 10), [(-50, -50, 400, 400)], (0, 0, 1000, 1000), (1000, 1000))
|
|
63
63
|
assert (x, y) == (0, 0) and w <= 1000 and h <= 1000
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_a_refused_capture_names_screen_recording_and_its_fix():
|
|
67
|
+
failed = snapshot.capture_failed("could not create image from window\n", allowed=False)
|
|
68
|
+
assert isinstance(failed, snapshot.CaptureFailed)
|
|
69
|
+
assert "Screen Recording" in str(failed) and "chartremotely setup" in str(failed)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_an_allowed_capture_that_fails_reports_what_screencapture_said():
|
|
73
|
+
failed = snapshot.capture_failed("\n could not create image from window\nmore\n", allowed=True)
|
|
74
|
+
assert str(failed) == "screencapture failed: could not create image from window"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_a_silent_failure_still_says_something():
|
|
78
|
+
assert "said nothing" in str(snapshot.capture_failed("", allowed=True))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_the_screen_recording_message_survives_the_reply_limit():
|
|
82
|
+
# vocab._short keeps 120 characters; the fix must not be cut off.
|
|
83
|
+
assert len(snapshot.SCREEN_RECORDING_OFF) <= 120
|
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
"""The SEC company registry, cached locally.
|
|
2
|
-
|
|
3
|
-
Roughly 10,500 US issuers including ETFs. Ordered by prominence in the
|
|
4
|
-
source file, which makes the index a usable tie-breaker: NVDA, AAPL and
|
|
5
|
-
GOOGL are the first three entries.
|
|
6
|
-
"""
|
|
7
|
-
|
|
8
|
-
from __future__ import annotations
|
|
9
|
-
|
|
10
|
-
import json
|
|
11
|
-
import time
|
|
12
|
-
import urllib.request
|
|
13
|
-
|
|
14
|
-
from .config import DATA_DIR
|
|
15
|
-
|
|
16
|
-
SEC_URL = "https://www.sec.gov/files/company_tickers.json"
|
|
17
|
-
CACHE = DATA_DIR / "tickers.json"
|
|
18
|
-
MAX_AGE = 7 * 24 * 3600
|
|
19
|
-
# The SEC requires a User-Agent carrying a contact address and answers 403
|
|
20
|
-
# without one. A repository URL alone is not enough.
|
|
21
|
-
DEFAULT_CONTACT = "chartremotely@example.com"
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
def _user_agent() -> str:
|
|
25
|
-
from .config import load
|
|
26
|
-
contact = load().get("contact") or DEFAULT_CONTACT
|
|
27
|
-
# SEC's documented format is plain "Name email". Anything fancier is
|
|
28
|
-
# rejected: a User-Agent carrying a URL answers 403, as does one with no
|
|
29
|
-
# contact address at all.
|
|
30
|
-
return f"ChartRemotely {contact}"
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def _fresh() -> bool:
|
|
34
|
-
return CACHE.exists() and time.time() - CACHE.stat().st_mtime < MAX_AGE
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
def refresh() -> list[dict]:
|
|
38
|
-
request = urllib.request.Request(SEC_URL, headers={"User-Agent": _user_agent()})
|
|
39
|
-
with urllib.request.urlopen(request, timeout=20) as response:
|
|
40
|
-
raw = json.load(response)
|
|
41
|
-
rows = [{"t": v["ticker"], "n": v["title"], "r": rank}
|
|
42
|
-
for rank, v in enumerate(raw.values())]
|
|
43
|
-
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
44
|
-
tmp = CACHE.with_suffix(".tmp")
|
|
45
|
-
tmp.write_text(json.dumps(rows))
|
|
46
|
-
tmp.replace(CACHE)
|
|
47
|
-
return rows
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
def load(force: bool = False) -> list[dict]:
|
|
51
|
-
if force or not _fresh():
|
|
52
|
-
return refresh()
|
|
53
|
-
return json.loads(CACHE.read_text())
|
|
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
|
|
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
|