chartremotely 0.2.16__tar.gz → 0.2.17__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.16 → chartremotely-0.2.17}/PKG-INFO +3 -1
- {chartremotely-0.2.16 → chartremotely-0.2.17}/chartremotely/hearing.py +16 -3
- {chartremotely-0.2.16 → chartremotely-0.2.17}/chartremotely/services.py +4 -1
- {chartremotely-0.2.16 → chartremotely-0.2.17}/chartremotely/setup.py +112 -47
- chartremotely-0.2.17/chartremotely/terminal.py +75 -0
- {chartremotely-0.2.16 → chartremotely-0.2.17}/chartremotely.egg-info/PKG-INFO +3 -1
- {chartremotely-0.2.16 → chartremotely-0.2.17}/chartremotely.egg-info/SOURCES.txt +1 -0
- {chartremotely-0.2.16 → chartremotely-0.2.17}/pyproject.toml +5 -1
- {chartremotely-0.2.16 → chartremotely-0.2.17}/tests/test_setup.py +50 -6
- {chartremotely-0.2.16 → chartremotely-0.2.17}/LICENSE +0 -0
- {chartremotely-0.2.16 → chartremotely-0.2.17}/README.md +0 -0
- {chartremotely-0.2.16 → chartremotely-0.2.17}/chartremotely/__init__.py +0 -0
- {chartremotely-0.2.16 → chartremotely-0.2.17}/chartremotely/answer.py +0 -0
- {chartremotely-0.2.16 → chartremotely-0.2.17}/chartremotely/assets/shortcut.plist +0 -0
- {chartremotely-0.2.16 → chartremotely-0.2.17}/chartremotely/ax.py +0 -0
- {chartremotely-0.2.16 → chartremotely-0.2.17}/chartremotely/cli.py +0 -0
- {chartremotely-0.2.16 → chartremotely-0.2.17}/chartremotely/config.py +0 -0
- {chartremotely-0.2.16 → chartremotely-0.2.17}/chartremotely/displays.py +0 -0
- {chartremotely-0.2.16 → chartremotely-0.2.17}/chartremotely/doctor.py +0 -0
- {chartremotely-0.2.16 → chartremotely-0.2.17}/chartremotely/guilock.py +0 -0
- {chartremotely-0.2.16 → chartremotely-0.2.17}/chartremotely/keystore.py +0 -0
- {chartremotely-0.2.16 → chartremotely-0.2.17}/chartremotely/layout.py +0 -0
- {chartremotely-0.2.16 → chartremotely-0.2.17}/chartremotely/mcpclient.py +0 -0
- {chartremotely-0.2.16 → chartremotely-0.2.17}/chartremotely/patron.py +0 -0
- {chartremotely-0.2.16 → chartremotely-0.2.17}/chartremotely/popups.py +0 -0
- {chartremotely-0.2.16 → chartremotely-0.2.17}/chartremotely/push.py +0 -0
- {chartremotely-0.2.16 → chartremotely-0.2.17}/chartremotely/recent.py +0 -0
- {chartremotely-0.2.16 → chartremotely-0.2.17}/chartremotely/registry.py +0 -0
- {chartremotely-0.2.16 → chartremotely-0.2.17}/chartremotely/relay.py +0 -0
- {chartremotely-0.2.16 → chartremotely-0.2.17}/chartremotely/requestlog.py +0 -0
- {chartremotely-0.2.16 → chartremotely-0.2.17}/chartremotely/resolve.py +0 -0
- {chartremotely-0.2.16 → chartremotely-0.2.17}/chartremotely/scales.py +0 -0
- {chartremotely-0.2.16 → chartremotely-0.2.17}/chartremotely/screen.py +0 -0
- {chartremotely-0.2.16 → chartremotely-0.2.17}/chartremotely/server.py +0 -0
- {chartremotely-0.2.16 → chartremotely-0.2.17}/chartremotely/shortcut.py +0 -0
- {chartremotely-0.2.16 → chartremotely-0.2.17}/chartremotely/snapshot.py +0 -0
- {chartremotely-0.2.16 → chartremotely-0.2.17}/chartremotely/studies.py +0 -0
- {chartremotely-0.2.16 → chartremotely-0.2.17}/chartremotely/symbol.py +0 -0
- {chartremotely-0.2.16 → chartremotely-0.2.17}/chartremotely/tailnet.py +0 -0
- {chartremotely-0.2.16 → chartremotely-0.2.17}/chartremotely/timeframe.py +0 -0
- {chartremotely-0.2.16 → chartremotely-0.2.17}/chartremotely/understand.py +0 -0
- {chartremotely-0.2.16 → chartremotely-0.2.17}/chartremotely/vocab.py +0 -0
- {chartremotely-0.2.16 → chartremotely-0.2.17}/chartremotely/voice.py +0 -0
- {chartremotely-0.2.16 → chartremotely-0.2.17}/chartremotely/window.py +0 -0
- {chartremotely-0.2.16 → chartremotely-0.2.17}/chartremotely.egg-info/dependency_links.txt +0 -0
- {chartremotely-0.2.16 → chartremotely-0.2.17}/chartremotely.egg-info/entry_points.txt +0 -0
- {chartremotely-0.2.16 → chartremotely-0.2.17}/chartremotely.egg-info/requires.txt +0 -0
- {chartremotely-0.2.16 → chartremotely-0.2.17}/chartremotely.egg-info/top_level.txt +0 -0
- {chartremotely-0.2.16 → chartremotely-0.2.17}/setup.cfg +0 -0
- {chartremotely-0.2.16 → chartremotely-0.2.17}/tests/test_hearing.py +0 -0
- {chartremotely-0.2.16 → chartremotely-0.2.17}/tests/test_patron.py +0 -0
- {chartremotely-0.2.16 → chartremotely-0.2.17}/tests/test_popups.py +0 -0
- {chartremotely-0.2.16 → chartremotely-0.2.17}/tests/test_push.py +0 -0
- {chartremotely-0.2.16 → chartremotely-0.2.17}/tests/test_recent.py +0 -0
- {chartremotely-0.2.16 → chartremotely-0.2.17}/tests/test_relay.py +0 -0
- {chartremotely-0.2.16 → chartremotely-0.2.17}/tests/test_requestlog.py +0 -0
- {chartremotely-0.2.16 → chartremotely-0.2.17}/tests/test_resolve.py +0 -0
- {chartremotely-0.2.16 → chartremotely-0.2.17}/tests/test_scales.py +0 -0
- {chartremotely-0.2.16 → chartremotely-0.2.17}/tests/test_screen.py +0 -0
- {chartremotely-0.2.16 → chartremotely-0.2.17}/tests/test_snapshot.py +0 -0
- {chartremotely-0.2.16 → chartremotely-0.2.17}/tests/test_stamp_changelog.py +0 -0
- {chartremotely-0.2.16 → chartremotely-0.2.17}/tests/test_timeframe.py +0 -0
- {chartremotely-0.2.16 → chartremotely-0.2.17}/tests/test_understand.py +0 -0
- {chartremotely-0.2.16 → chartremotely-0.2.17}/tests/test_voice.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.17
|
|
4
4
|
Summary: Remote control of desktop charting applications on macOS
|
|
5
5
|
Author: Lonnie VanZandt
|
|
6
6
|
License:
|
|
@@ -206,6 +206,8 @@ License:
|
|
|
206
206
|
See the License for the specific language governing permissions and
|
|
207
207
|
limitations under the License.
|
|
208
208
|
|
|
209
|
+
Project-URL: Repository, https://github.com/lonniev/ChartRemotely-agent
|
|
210
|
+
Project-URL: Changelog, https://github.com/lonniev/ChartRemotely-agent/blob/main/CHANGELOG.md
|
|
209
211
|
Keywords: thinkorswim,accessibility,automation,charting
|
|
210
212
|
Requires-Python: >=3.12
|
|
211
213
|
Description-Content-Type: text/markdown
|
|
@@ -243,7 +243,9 @@ def warm_in_background() -> threading.Thread:
|
|
|
243
243
|
|
|
244
244
|
|
|
245
245
|
def fetch_model(say: Callable[[str], None] = print) -> bool:
|
|
246
|
-
"""Download the model ahead of the first sentence (setup's step).
|
|
246
|
+
"""Download the model ahead of the first sentence (setup's step).
|
|
247
|
+
|
|
248
|
+
Silent when it is already on this Mac; a download shows its progress.
|
|
247
249
|
|
|
248
250
|
Returns whether it is ready. Skipped, with a reason, where it cannot run.
|
|
249
251
|
"""
|
|
@@ -255,7 +257,18 @@ def fetch_model(say: Callable[[str], None] = print) -> bool:
|
|
|
255
257
|
except ImportError:
|
|
256
258
|
say("The speech model's downloader is missing; reinstall ChartRemotely, then run setup.")
|
|
257
259
|
return False
|
|
258
|
-
|
|
260
|
+
from huggingface_hub.utils import disable_progress_bars, enable_progress_bars
|
|
261
|
+
|
|
262
|
+
disable_progress_bars()
|
|
263
|
+
try:
|
|
264
|
+
snapshot_download(repo_id=MODEL, local_files_only=True)
|
|
265
|
+
cached = True
|
|
266
|
+
except Exception: # noqa: BLE001 - not cached yet: fetch it, with progress
|
|
267
|
+
cached = False
|
|
268
|
+
finally:
|
|
269
|
+
enable_progress_bars()
|
|
270
|
+
if cached:
|
|
271
|
+
return True
|
|
272
|
+
say(f"Fetching the speech model ({MODEL_SIZE}, once)…")
|
|
259
273
|
snapshot_download(repo_id=MODEL)
|
|
260
|
-
say("The speech model is on this Mac.")
|
|
261
274
|
return True
|
|
@@ -105,7 +105,10 @@ def probe_permissions(request: bool, out: Path, timeout: float = 30.0) -> dict:
|
|
|
105
105
|
args = [executable(), "permissions", "--out", str(out)] + (["--request"] if request else [])
|
|
106
106
|
path.write_text(render(PROBE_LABEL, args, keep_alive=False, log=LOG_DIR / "chartremotely-permissions.log"))
|
|
107
107
|
subprocess.run(["launchctl", "bootout", f"{_domain()}/{PROBE_LABEL}"], capture_output=True, check=False)
|
|
108
|
-
subprocess.run(["launchctl", "bootstrap", _domain(), str(path)],
|
|
108
|
+
ran = subprocess.run(["launchctl", "bootstrap", _domain(), str(path)],
|
|
109
|
+
capture_output=True, text=True, check=False)
|
|
110
|
+
if ran.returncode != 0:
|
|
111
|
+
raise ServiceError(f"launchd would not run the permission check: {(ran.stderr or ran.stdout).strip()[:200]}")
|
|
109
112
|
try:
|
|
110
113
|
deadline = time.time() + timeout
|
|
111
114
|
while time.time() < deadline and not out.exists():
|
|
@@ -1,24 +1,25 @@
|
|
|
1
1
|
"""``chartremotely setup``: from a fresh Mac to a paired, voice-driven display.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
It first shows what is installing - version, where it came from (PyPI and
|
|
4
|
+
its release, or a local checkout), the Python it runs on - and the steps
|
|
5
|
+
ahead, and asks before changing anything. Each step checks first and skips
|
|
6
|
+
what is already done, so running setup again is always safe:
|
|
5
7
|
|
|
6
8
|
1. **Identity.** Either the patron's existing npub, proven by the Nostr DM
|
|
7
9
|
challenge (setup never sees their nsec), or a key made here for them.
|
|
8
10
|
A made key is kept for the human - their Keychain, then Safari's saved
|
|
9
11
|
passwords - and used once, in memory, to sign the pairing. It is never
|
|
10
|
-
written to this agent's config.
|
|
11
|
-
|
|
12
|
-
**Voice sign-in**: the ``dpop_token`` from the owner's DM proof, kept in
|
|
12
|
+
written to this agent's config. Then **pairing**, without a code to copy: setup adopts this machine's code itself.
|
|
13
|
+
and **voice sign-in**: the ``dpop_token`` from the owner's DM proof, kept in
|
|
13
14
|
the login Keychain (their npub goes in the config), so the listener can
|
|
14
15
|
call the operator's priced tools as that patron.
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
2. **Tailscale**: the Mac's tailnet address, with ``/chart`` forwarded to the agent.
|
|
17
|
+
3. **Services**: the listener and the relay, under launchd.
|
|
18
|
+
4. **Permissions**: Accessibility and Screen Recording, asked for by the
|
|
18
19
|
services' own Python, since that is the process macOS grants them to.
|
|
19
|
-
|
|
20
|
+
5. **Hearing**: the Whisper speech model, fetched once (about 1.6 GB), and
|
|
20
21
|
the owner's display names, so a sentence can say where.
|
|
21
|
-
|
|
22
|
+
6. **The Shortcut**: this Mac's "ChartRemotely" - talk, or type when it
|
|
22
23
|
asks - opened for import. A "ChartRemotely Ask" left by an older setup is
|
|
23
24
|
removed from the library (by "Shortcuts Events"; named for deleting by
|
|
24
25
|
hand if that fails).
|
|
@@ -26,12 +27,15 @@ again is always safe:
|
|
|
26
27
|
|
|
27
28
|
from __future__ import annotations
|
|
28
29
|
|
|
30
|
+
import json
|
|
29
31
|
import subprocess
|
|
32
|
+
import sys
|
|
30
33
|
import tempfile
|
|
31
34
|
from collections.abc import Callable
|
|
32
35
|
from pathlib import Path
|
|
33
36
|
|
|
34
37
|
from . import config, mcpclient, relay
|
|
38
|
+
from .terminal import Terminal, wants_color
|
|
35
39
|
|
|
36
40
|
OPERATOR_URL = "https://chartremotely-mcp.fastmcp.app"
|
|
37
41
|
SITE = "https://chartremotely.tollbooth-dpyc.com"
|
|
@@ -60,9 +64,9 @@ def prove_by_dm(client: mcpclient.Client, npub: str, ask: Ask, say: Say) -> str:
|
|
|
60
64
|
raise SetupError(sent.get("error") or "the operator did not send a proof request")
|
|
61
65
|
say(f"A Nostr DM is on its way to {npub[:12]}…")
|
|
62
66
|
say(f"Its confirmation code is: {phrase}")
|
|
63
|
-
say("Reply to
|
|
64
|
-
say("
|
|
65
|
-
say("
|
|
67
|
+
say("Reply to it from your Nostr client only if the codes match. In the reply you")
|
|
68
|
+
say("may set cache_duration (30 days, or unlimited) so voice keeps working that")
|
|
69
|
+
say("long; left alone, the sign-in lasts 2 hours.")
|
|
66
70
|
ask("Press Return once you have replied… ")
|
|
67
71
|
got = client.call("chart_receive_npub_proof", {"patron_npub": npub, "dpop_token": phrase})
|
|
68
72
|
if got.get("error"):
|
|
@@ -108,7 +112,7 @@ def identity(client: mcpclient.Client, ask: Ask, say: Say) -> tuple[str, str, bo
|
|
|
108
112
|
Returns (npub, proof for chart_pair_agent, whether that proof is a DM
|
|
109
113
|
sign-in the listener can keep using).
|
|
110
114
|
"""
|
|
111
|
-
say("Your Nostr identity owns this display
|
|
115
|
+
say("Your Nostr identity owns this display:")
|
|
112
116
|
say(" 1 I have an npub (I'll answer a Nostr DM to prove it)")
|
|
113
117
|
say(" 2 Use a key saved on this Mac")
|
|
114
118
|
say(" 3 Make me a new key")
|
|
@@ -184,90 +188,151 @@ def pair(client: mcpclient.Client, base: str, npub: str, proof: str, label: str)
|
|
|
184
188
|
return relay.collect(base, code, expires_in)
|
|
185
189
|
|
|
186
190
|
|
|
191
|
+
# -- what is being installed -------------------------------------------------
|
|
192
|
+
|
|
193
|
+
PLAN = [
|
|
194
|
+
("Identity", "prove who owns this display, by Nostr DM or a key made here"),
|
|
195
|
+
("Tailscale", "reach this Mac's listener at its tailnet address, /chart"),
|
|
196
|
+
("Services", "run the listener and the relay under launchd, from login"),
|
|
197
|
+
("Permissions", "allow Accessibility and Screen Recording"),
|
|
198
|
+
("Hearing", "fetch the Whisper speech model (about 1.6 GB, once)"),
|
|
199
|
+
("Shortcut", "make “ChartRemotely” and open it for you to add"),
|
|
200
|
+
]
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def provenance() -> list[tuple[str, str]]:
|
|
204
|
+
"""What is running setup, and where it came from: version, source, release, runtime."""
|
|
205
|
+
from importlib import metadata
|
|
206
|
+
|
|
207
|
+
dist = metadata.distribution("chartremotely")
|
|
208
|
+
version = dist.version
|
|
209
|
+
urls = dict(u.split(", ", 1) for u in dist.metadata.get_all("Project-URL") or [] if ", " in u)
|
|
210
|
+
direct = json.loads(dist.read_text("direct_url.json") or "null")
|
|
211
|
+
running = Path(__file__).resolve().parent
|
|
212
|
+
rows = [("Version", version)]
|
|
213
|
+
if "site-packages" not in running.parts:
|
|
214
|
+
# Imported straight from a source tree: its metadata may be a stale
|
|
215
|
+
# egg-info beside it, so claim nothing about PyPI.
|
|
216
|
+
rows = [("Version", f"{version} (source; releases stamp their own)"),
|
|
217
|
+
("From", f"source checkout {running.parent}")]
|
|
218
|
+
elif direct is None:
|
|
219
|
+
rows.append(("From", f"PyPI https://pypi.org/project/{dist.metadata['Name']}/{version}/"))
|
|
220
|
+
if urls.get("Repository"):
|
|
221
|
+
rows.append(("Release", f"{urls['Repository']}/releases/tag/v{version}"))
|
|
222
|
+
else:
|
|
223
|
+
where = direct.get("url", "").removeprefix("file://")
|
|
224
|
+
commit = (direct.get("vcs_info") or {}).get("commit_id")
|
|
225
|
+
editable = (direct.get("dir_info") or {}).get("editable")
|
|
226
|
+
rows.append(("From", f"{where}{'@' + commit[:12] if commit else ''}{' (editable)' if editable else ''}"))
|
|
227
|
+
rows.append(("Python", f"{sys.executable} ({sys.version.split()[0]})"))
|
|
228
|
+
home = str(Path.home())
|
|
229
|
+
return [(label, value.replace(home, "~")) for label, value in rows]
|
|
230
|
+
|
|
231
|
+
|
|
187
232
|
# -- the whole run ------------------------------------------------------------
|
|
188
233
|
|
|
189
234
|
def run(ask: Ask = input, say: Say = print, operator_url: str = OPERATOR_URL) -> int:
|
|
235
|
+
out = Terminal(write=say, read=ask, color=say is print and wants_color())
|
|
236
|
+
from importlib import metadata
|
|
237
|
+
|
|
238
|
+
out.title("ChartRemotely setup", metadata.distribution("chartremotely").metadata["Summary"])
|
|
239
|
+
out.facts(provenance())
|
|
240
|
+
out.plan("Setup will, skipping anything already done:", PLAN)
|
|
241
|
+
out.write("")
|
|
242
|
+
if not out.confirm("Continue?"):
|
|
243
|
+
out.say("Nothing was changed.")
|
|
244
|
+
return 0
|
|
245
|
+
total = len(PLAN)
|
|
246
|
+
|
|
190
247
|
cfg = config.load()
|
|
191
248
|
base = (cfg.get("operator_url") or operator_url).rstrip("/")
|
|
192
249
|
token = config.ensure_token()
|
|
193
250
|
|
|
194
|
-
# 1
|
|
251
|
+
# 1. Identity and pairing, unless this Mac is already paired.
|
|
252
|
+
out.step(1, total, "Identity")
|
|
195
253
|
client = mcpclient.Client(base)
|
|
196
254
|
if cfg.get("agent_id") and cfg.get("agent_secret"):
|
|
197
|
-
|
|
198
|
-
npub = cfg.get("owner_npub") or ask("Your npub (whose display this is): ").strip()
|
|
255
|
+
out.ok(f"This Mac is already paired ({cfg['agent_id']}).")
|
|
256
|
+
npub = cfg.get("owner_npub") or out.ask("Your npub (whose display this is): ").strip()
|
|
199
257
|
if not npub.startswith("npub1"):
|
|
200
258
|
raise SetupError("that is not an npub")
|
|
201
259
|
if has_sign_in(npub):
|
|
202
260
|
config.update(owner_npub=npub)
|
|
203
|
-
|
|
261
|
+
out.ok("Voice is already signed in.")
|
|
204
262
|
else:
|
|
205
|
-
voice_sign_in(client, npub, ask, say)
|
|
263
|
+
voice_sign_in(client, npub, out.ask, out.say)
|
|
206
264
|
else:
|
|
207
|
-
npub, proof, by_dm = identity(client, ask, say)
|
|
208
|
-
label = ask("Name this display (e.g. Desk, Office wall): ").strip() or "display"
|
|
265
|
+
npub, proof, by_dm = identity(client, out.ask, out.say)
|
|
266
|
+
label = out.ask("Name this display (e.g. Desk, Office wall): ").strip() or "display"
|
|
209
267
|
pair(client, base, npub, proof, label)
|
|
210
|
-
|
|
268
|
+
out.ok(f"Paired as “{label}”.")
|
|
211
269
|
if by_dm:
|
|
212
270
|
keep_sign_in(npub, proof)
|
|
213
|
-
|
|
271
|
+
out.ok("Voice is signed in with the same DM.")
|
|
214
272
|
else:
|
|
215
|
-
voice_sign_in(client, npub, ask, say)
|
|
273
|
+
voice_sign_in(client, npub, out.ask, out.say)
|
|
216
274
|
del proof
|
|
217
275
|
|
|
218
|
-
#
|
|
276
|
+
# 2. Tailscale.
|
|
219
277
|
from . import tailnet
|
|
220
278
|
|
|
279
|
+
out.step(2, total, "Tailscale")
|
|
221
280
|
url = tailnet.chart_url(tailnet.status())
|
|
222
281
|
tailnet.serve(int(cfg.get("port") or 8899))
|
|
223
|
-
|
|
282
|
+
out.ok(f"The Shortcut reaches this Mac at {url}")
|
|
224
283
|
|
|
225
|
-
#
|
|
284
|
+
# 3. Services.
|
|
226
285
|
from . import services
|
|
227
286
|
|
|
287
|
+
out.step(3, total, "Services")
|
|
228
288
|
for command in ("serve", "relay"):
|
|
229
289
|
services.install(command)
|
|
230
|
-
|
|
290
|
+
out.ok("The listener and the relay are running, and start with this Mac.")
|
|
231
291
|
|
|
232
|
-
#
|
|
292
|
+
# 4. Permissions, as the services' own Python sees them.
|
|
293
|
+
out.step(4, total, "Permissions")
|
|
233
294
|
probe = Path(tempfile.gettempdir()) / "chartremotely-permissions.json"
|
|
234
295
|
granted = services.probe_permissions(request=True, out=probe)
|
|
235
296
|
while not (granted.get("accessibility") and granted.get("screen_recording")):
|
|
236
297
|
python = granted.get("python", "the Python that runs ChartRemotely")
|
|
237
298
|
missing = [name for name, key in (("Accessibility", "accessibility"),
|
|
238
299
|
("Screen Recording", "screen_recording")) if not granted.get(key)]
|
|
239
|
-
|
|
300
|
+
out.warn(f"macOS needs you to allow {' and '.join(missing)} for:")
|
|
301
|
+
out.say(python)
|
|
240
302
|
pane = "Privacy_Accessibility" if not granted.get("accessibility") else "Privacy_ScreenCapture"
|
|
241
303
|
subprocess.run(["open", f"x-apple.systempreferences:com.apple.preference.security?{pane}"], check=False)
|
|
242
|
-
ask("Turn it on in System Settings, then press Return… ")
|
|
304
|
+
out.ask("Turn it on in System Settings, then press Return… ")
|
|
243
305
|
granted = services.probe_permissions(request=False, out=probe)
|
|
244
|
-
|
|
306
|
+
out.ok("Accessibility and Screen Recording are allowed.")
|
|
245
307
|
|
|
246
|
-
#
|
|
308
|
+
# 5. Hearing: the speech model, and the display names a sentence may say.
|
|
247
309
|
from . import displays, hearing
|
|
248
310
|
|
|
311
|
+
out.step(5, total, "Hearing")
|
|
249
312
|
try:
|
|
250
|
-
hearing.fetch_model(say)
|
|
313
|
+
if hearing.fetch_model(out.say):
|
|
314
|
+
out.ok("The speech model is on this Mac.")
|
|
251
315
|
except Exception as exc: # noqa: BLE001 - the typing box works without it
|
|
252
|
-
|
|
316
|
+
out.warn(f"The speech model could not be fetched ({type(exc).__name__}); run setup again later.")
|
|
253
317
|
if displays.refresh() is not None:
|
|
254
|
-
|
|
318
|
+
out.ok(f"Displays you can name: {', '.join(displays.load()) or 'none yet'}.")
|
|
255
319
|
|
|
256
|
-
#
|
|
320
|
+
# 6. The Shortcut.
|
|
257
321
|
from . import shortcut
|
|
258
322
|
|
|
323
|
+
out.step(6, total, "Shortcut")
|
|
259
324
|
subprocess.run(["open", str(shortcut.build(url, token))], check=False)
|
|
260
|
-
|
|
325
|
+
out.ok("“ChartRemotely” is open: add it (or Replace). iCloud brings it to your iPhone, iPad and Watch.")
|
|
261
326
|
removed, left = shortcut.remove_retired()
|
|
262
327
|
if removed:
|
|
263
|
-
|
|
328
|
+
out.ok(f"Removed {', '.join(f'“{n}”' for n in removed)}; “ChartRemotely” does it all now.")
|
|
264
329
|
if left:
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
say("
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
330
|
+
out.warn(f"Delete {', '.join(f'“{n}”' for n in left)} in the Shortcuts app; “ChartRemotely” does it all now.")
|
|
331
|
+
|
|
332
|
+
out.write("")
|
|
333
|
+
out.write(out.bold("Ready."))
|
|
334
|
+
out.say("Say “Hey Siri, ChartRemotely”, then one sentence: “Palantir, half, on mac mini”.")
|
|
335
|
+
out.say("No microphone, or it didn't hear you? It shows a box: type the same sentence.")
|
|
336
|
+
out.facts([("Your npub", npub), ("Your screens", SITE)])
|
|
337
|
+
out.write("")
|
|
273
338
|
return 0
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""How ``chartremotely setup`` looks: a header, numbered steps, quiet detail.
|
|
2
|
+
|
|
3
|
+
Colour only on a terminal that wants it (not piped, no ``NO_COLOR``, not
|
|
4
|
+
``TERM=dumb``); everywhere else the same lines print plain, so tests read
|
|
5
|
+
exactly what a person reads.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
|
|
14
|
+
INDENT = " "
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def wants_color(stream=sys.stdout) -> bool:
|
|
18
|
+
return (hasattr(stream, "isatty") and stream.isatty()
|
|
19
|
+
and not os.environ.get("NO_COLOR") and os.environ.get("TERM") != "dumb")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Terminal:
|
|
23
|
+
"""Lines for a person: :meth:`step` headings, :meth:`ok` and :meth:`warn` marks,
|
|
24
|
+
:meth:`say` detail and :meth:`ask` prompts, all indented under their step."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, write: Callable[[str], None] = print, read: Callable[[str], str] = input,
|
|
27
|
+
color: bool = False):
|
|
28
|
+
self.write, self.read, self.color = write, read, color
|
|
29
|
+
|
|
30
|
+
def _paint(self, code: str, text: str) -> str:
|
|
31
|
+
return f"\033[{code}m{text}\033[0m" if self.color else text
|
|
32
|
+
|
|
33
|
+
def bold(self, text: str) -> str:
|
|
34
|
+
return self._paint("1", text)
|
|
35
|
+
|
|
36
|
+
def dim(self, text: str) -> str:
|
|
37
|
+
return self._paint("2", text)
|
|
38
|
+
|
|
39
|
+
def title(self, name: str, summary: str) -> None:
|
|
40
|
+
self.write("")
|
|
41
|
+
self.write(self.bold(name))
|
|
42
|
+
self.write(self.dim(summary))
|
|
43
|
+
|
|
44
|
+
def facts(self, rows: list[tuple[str, str]]) -> None:
|
|
45
|
+
"""Label/value rows, labels aligned."""
|
|
46
|
+
width = max(len(label) for label, _ in rows)
|
|
47
|
+
self.write("")
|
|
48
|
+
for label, value in rows:
|
|
49
|
+
self.write(f" {self.dim(label.ljust(width))} {value}")
|
|
50
|
+
|
|
51
|
+
def plan(self, heading: str, steps: list[tuple[str, str]]) -> None:
|
|
52
|
+
width = max(len(name) for name, _ in steps)
|
|
53
|
+
self.write("")
|
|
54
|
+
self.write(heading)
|
|
55
|
+
for i, (name, what) in enumerate(steps, 1):
|
|
56
|
+
self.write(f" {self._paint('36', str(i))} {self.bold(name.ljust(width))} {what}")
|
|
57
|
+
|
|
58
|
+
def step(self, number: int, total: int, name: str) -> None:
|
|
59
|
+
self.write("")
|
|
60
|
+
self.write(f"{self._paint('36', f'[{number}/{total}]')} {self.bold(name)}")
|
|
61
|
+
|
|
62
|
+
def say(self, text: str) -> None:
|
|
63
|
+
self.write(f"{INDENT}{text}" if text else "")
|
|
64
|
+
|
|
65
|
+
def ok(self, text: str) -> None:
|
|
66
|
+
self.write(f" {self._paint('32', '✓')} {text}")
|
|
67
|
+
|
|
68
|
+
def warn(self, text: str) -> None:
|
|
69
|
+
self.write(f" {self._paint('33', '!')} {text}")
|
|
70
|
+
|
|
71
|
+
def ask(self, prompt: str) -> str:
|
|
72
|
+
return self.read(f" {self._paint('36', '›')} {prompt}")
|
|
73
|
+
|
|
74
|
+
def confirm(self, prompt: str) -> bool:
|
|
75
|
+
return not self.ask(f"{prompt} [Y/n] ").strip().lower().startswith("n")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: chartremotely
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.17
|
|
4
4
|
Summary: Remote control of desktop charting applications on macOS
|
|
5
5
|
Author: Lonnie VanZandt
|
|
6
6
|
License:
|
|
@@ -206,6 +206,8 @@ License:
|
|
|
206
206
|
See the License for the specific language governing permissions and
|
|
207
207
|
limitations under the License.
|
|
208
208
|
|
|
209
|
+
Project-URL: Repository, https://github.com/lonniev/ChartRemotely-agent
|
|
210
|
+
Project-URL: Changelog, https://github.com/lonniev/ChartRemotely-agent/blob/main/CHANGELOG.md
|
|
209
211
|
Keywords: thinkorswim,accessibility,automation,charting
|
|
210
212
|
Requires-Python: >=3.12
|
|
211
213
|
Description-Content-Type: text/markdown
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "chartremotely"
|
|
3
|
-
version = "0.2.
|
|
3
|
+
version = "0.2.17"
|
|
4
4
|
description = "Remote control of desktop charting applications on macOS"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.12"
|
|
@@ -28,6 +28,10 @@ macos = [
|
|
|
28
28
|
setup = ["tollbooth-dpyc[nostr]==0.90.0"]
|
|
29
29
|
dev = ["pytest>=8", "ruff>=0.6"]
|
|
30
30
|
|
|
31
|
+
[project.urls]
|
|
32
|
+
Repository = "https://github.com/lonniev/ChartRemotely-agent"
|
|
33
|
+
Changelog = "https://github.com/lonniev/ChartRemotely-agent/blob/main/CHANGELOG.md"
|
|
34
|
+
|
|
31
35
|
[project.scripts]
|
|
32
36
|
chartremotely = "chartremotely.cli:main"
|
|
33
37
|
|
|
@@ -264,7 +264,7 @@ def test_a_made_key_never_reaches_the_agents_config(monkeypatch, tmp_path):
|
|
|
264
264
|
_no_hearing(monkeypatch)
|
|
265
265
|
monkeypatch.setattr(setup.subprocess, "run", lambda *a, **k: None)
|
|
266
266
|
|
|
267
|
-
answers = iter(["3", "Desk", "n"])
|
|
267
|
+
answers = iter(["", "3", "Desk", "n"])
|
|
268
268
|
said = []
|
|
269
269
|
assert setup.run(ask=lambda q: next(answers), say=said.append) == 0
|
|
270
270
|
|
|
@@ -311,7 +311,7 @@ def test_a_dm_proven_owner_keeps_the_sign_in_in_the_keychain_not_the_config(monk
|
|
|
311
311
|
"chart_request_npub_proof": {"dpop_token": "seven-amber-door"},
|
|
312
312
|
"chart_receive_npub_proof": {"success": True, "dpop_token": "seven-amber-door"},
|
|
313
313
|
"chart_pair_agent": {"ok": True, "display": "Desk", "agent_id": "a1"}})
|
|
314
|
-
answers = iter(["1", NPUB, "", "Desk"])
|
|
314
|
+
answers = iter(["", "1", NPUB, "", "Desk"])
|
|
315
315
|
said = []
|
|
316
316
|
assert setup.run(ask=lambda q: next(answers), say=said.append) == 0
|
|
317
317
|
assert vault == {NPUB: "seven-amber-door"}
|
|
@@ -325,7 +325,7 @@ def test_an_already_paired_mac_is_signed_in_for_voice_by_dm(monkeypatch, tmp_pat
|
|
|
325
325
|
"chart_request_npub_proof": {"dpop_token": "seven-amber-door"},
|
|
326
326
|
"chart_receive_npub_proof": {"success": True, "dpop_token": "seven-amber-door"}})
|
|
327
327
|
config.update(operator_url="https://op.test", agent_id="a1", agent_secret="agent-secret")
|
|
328
|
-
answers = iter([NPUB, "y", ""])
|
|
328
|
+
answers = iter(["", NPUB, "y", ""])
|
|
329
329
|
assert setup.run(ask=lambda q: next(answers), say=lambda s: None) == 0
|
|
330
330
|
assert vault == {NPUB: "seven-amber-door"}
|
|
331
331
|
assert "seven-amber-door" not in (tmp_path / "config.json").read_text()
|
|
@@ -337,11 +337,55 @@ def test_setup_removes_a_retired_ask_shortcut_and_names_any_it_could_not(monkeyp
|
|
|
337
337
|
"chart_receive_npub_proof": {"success": True, "dpop_token": "seven-amber-door"}})
|
|
338
338
|
config.update(operator_url="https://op.test", agent_id="a1", agent_secret="agent-secret")
|
|
339
339
|
monkeypatch.setattr(shortcut, "remove_retired", lambda: (["ChartRemotely Ask"], ["ChartRemotely Ask 2"]))
|
|
340
|
-
answers = iter([NPUB, "y", ""])
|
|
340
|
+
answers = iter(["", NPUB, "y", ""])
|
|
341
341
|
said = []
|
|
342
342
|
assert setup.run(ask=lambda q: next(answers), say=said.append) == 0
|
|
343
|
-
assert "Removed “ChartRemotely Ask”; “ChartRemotely” does it all now." in said
|
|
344
|
-
assert "Delete “ChartRemotely Ask 2” in the Shortcuts app; “ChartRemotely” does it all now."
|
|
343
|
+
assert any(s.endswith("Removed “ChartRemotely Ask”; “ChartRemotely” does it all now.") for s in said)
|
|
344
|
+
assert any(s.endswith("Delete “ChartRemotely Ask 2” in the Shortcuts app; “ChartRemotely” does it all now.")
|
|
345
|
+
for s in said)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def test_setup_says_what_it_installs_and_changes_nothing_when_declined(monkeypatch, tmp_path):
|
|
349
|
+
touched = []
|
|
350
|
+
monkeypatch.setattr(config, "load", lambda: touched.append("config") or {})
|
|
351
|
+
said = []
|
|
352
|
+
assert setup.run(ask=lambda q: "n", say=said.append) == 0
|
|
353
|
+
text = "\n".join(said)
|
|
354
|
+
assert "ChartRemotely setup" in text and "Version" in text and "Python" in text
|
|
355
|
+
assert all(name in text for name, _ in setup.PLAN)
|
|
356
|
+
assert said[-1].strip() == "Nothing was changed." and touched == []
|
|
357
|
+
assert not any("\033[" in s for s in said), "no colour codes when not printing to a terminal"
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def test_provenance_names_pypi_and_its_release_or_the_local_checkout(monkeypatch):
|
|
361
|
+
from importlib import metadata
|
|
362
|
+
|
|
363
|
+
class Dist:
|
|
364
|
+
version = "9.9.9"
|
|
365
|
+
|
|
366
|
+
def __init__(self, direct):
|
|
367
|
+
self.direct = direct
|
|
368
|
+
self.metadata = type("M", (), {"__getitem__": lambda s, k: "chartremotely",
|
|
369
|
+
"get_all": lambda s, k: ["Repository, https://example.test/repo"]})()
|
|
370
|
+
|
|
371
|
+
def read_text(self, name):
|
|
372
|
+
return self.direct
|
|
373
|
+
|
|
374
|
+
monkeypatch.setattr(metadata, "distribution", lambda name: Dist(None))
|
|
375
|
+
monkeypatch.setattr(setup, "__file__", "/venv/lib/python3.12/site-packages/chartremotely/setup.py")
|
|
376
|
+
rows = dict(setup.provenance())
|
|
377
|
+
assert rows["Version"] == "9.9.9"
|
|
378
|
+
assert rows["From"] == "PyPI https://pypi.org/project/chartremotely/9.9.9/"
|
|
379
|
+
assert rows["Release"] == "https://example.test/repo/releases/tag/v9.9.9"
|
|
380
|
+
|
|
381
|
+
monkeypatch.setattr(metadata, "distribution", lambda name: Dist(json.dumps(
|
|
382
|
+
{"url": "file:///src/agent", "dir_info": {"editable": True}})))
|
|
383
|
+
rows = dict(setup.provenance())
|
|
384
|
+
assert rows["From"] == "/src/agent (editable)" and "Release" not in rows
|
|
385
|
+
|
|
386
|
+
monkeypatch.setattr(setup, "__file__", "/src/agent/chartremotely/setup.py")
|
|
387
|
+
rows = dict(setup.provenance())
|
|
388
|
+
assert rows["From"] == "source checkout /src/agent", "a source tree never claims PyPI"
|
|
345
389
|
|
|
346
390
|
|
|
347
391
|
class Library:
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|