chartremotely 0.3.7__tar.gz → 0.3.9__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. {chartremotely-0.3.7 → chartremotely-0.3.9}/PKG-INFO +2 -2
  2. {chartremotely-0.3.7 → chartremotely-0.3.9}/chartremotely/doctor.py +42 -13
  3. {chartremotely-0.3.7 → chartremotely-0.3.9}/chartremotely/services.py +25 -0
  4. {chartremotely-0.3.7 → chartremotely-0.3.9}/chartremotely/setup.py +9 -1
  5. {chartremotely-0.3.7 → chartremotely-0.3.9}/chartremotely/snapshot.py +23 -0
  6. {chartremotely-0.3.7 → chartremotely-0.3.9}/chartremotely/window.py +4 -2
  7. {chartremotely-0.3.7 → chartremotely-0.3.9}/chartremotely.egg-info/PKG-INFO +2 -2
  8. {chartremotely-0.3.7 → chartremotely-0.3.9}/chartremotely.egg-info/SOURCES.txt +1 -0
  9. {chartremotely-0.3.7 → chartremotely-0.3.9}/chartremotely.egg-info/requires.txt +1 -1
  10. {chartremotely-0.3.7 → chartremotely-0.3.9}/pyproject.toml +2 -2
  11. chartremotely-0.3.9/tests/test_doctor.py +42 -0
  12. {chartremotely-0.3.7 → chartremotely-0.3.9}/tests/test_snapshot.py +20 -0
  13. {chartremotely-0.3.7 → chartremotely-0.3.9}/LICENSE +0 -0
  14. {chartremotely-0.3.7 → chartremotely-0.3.9}/README.md +0 -0
  15. {chartremotely-0.3.7 → chartremotely-0.3.9}/chartremotely/__init__.py +0 -0
  16. {chartremotely-0.3.7 → chartremotely-0.3.9}/chartremotely/answer.py +0 -0
  17. {chartremotely-0.3.7 → chartremotely-0.3.9}/chartremotely/assets/shortcut.plist +0 -0
  18. {chartremotely-0.3.7 → chartremotely-0.3.9}/chartremotely/ax.py +0 -0
  19. {chartremotely-0.3.7 → chartremotely-0.3.9}/chartremotely/cli.py +0 -0
  20. {chartremotely-0.3.7 → chartremotely-0.3.9}/chartremotely/config.py +0 -0
  21. {chartremotely-0.3.7 → chartremotely-0.3.9}/chartremotely/displays.py +0 -0
  22. {chartremotely-0.3.7 → chartremotely-0.3.9}/chartremotely/guilock.py +0 -0
  23. {chartremotely-0.3.7 → chartremotely-0.3.9}/chartremotely/hearing.py +0 -0
  24. {chartremotely-0.3.7 → chartremotely-0.3.9}/chartremotely/holder.py +0 -0
  25. {chartremotely-0.3.7 → chartremotely-0.3.9}/chartremotely/keystore.py +0 -0
  26. {chartremotely-0.3.7 → chartremotely-0.3.9}/chartremotely/layout.py +0 -0
  27. {chartremotely-0.3.7 → chartremotely-0.3.9}/chartremotely/mcpclient.py +0 -0
  28. {chartremotely-0.3.7 → chartremotely-0.3.9}/chartremotely/patron.py +0 -0
  29. {chartremotely-0.3.7 → chartremotely-0.3.9}/chartremotely/popups.py +0 -0
  30. {chartremotely-0.3.7 → chartremotely-0.3.9}/chartremotely/push.py +0 -0
  31. {chartremotely-0.3.7 → chartremotely-0.3.9}/chartremotely/recent.py +0 -0
  32. {chartremotely-0.3.7 → chartremotely-0.3.9}/chartremotely/registry.py +0 -0
  33. {chartremotely-0.3.7 → chartremotely-0.3.9}/chartremotely/relay.py +0 -0
  34. {chartremotely-0.3.7 → chartremotely-0.3.9}/chartremotely/requestlog.py +0 -0
  35. {chartremotely-0.3.7 → chartremotely-0.3.9}/chartremotely/resolve.py +0 -0
  36. {chartremotely-0.3.7 → chartremotely-0.3.9}/chartremotely/scales.py +0 -0
  37. {chartremotely-0.3.7 → chartremotely-0.3.9}/chartremotely/screen.py +0 -0
  38. {chartremotely-0.3.7 → chartremotely-0.3.9}/chartremotely/server.py +0 -0
  39. {chartremotely-0.3.7 → chartremotely-0.3.9}/chartremotely/shortcut.py +0 -0
  40. {chartremotely-0.3.7 → chartremotely-0.3.9}/chartremotely/studies.py +0 -0
  41. {chartremotely-0.3.7 → chartremotely-0.3.9}/chartremotely/symbol.py +0 -0
  42. {chartremotely-0.3.7 → chartremotely-0.3.9}/chartremotely/tailnet.py +0 -0
  43. {chartremotely-0.3.7 → chartremotely-0.3.9}/chartremotely/terminal.py +0 -0
  44. {chartremotely-0.3.7 → chartremotely-0.3.9}/chartremotely/timeframe.py +0 -0
  45. {chartremotely-0.3.7 → chartremotely-0.3.9}/chartremotely/understand.py +0 -0
  46. {chartremotely-0.3.7 → chartremotely-0.3.9}/chartremotely/vocab.py +0 -0
  47. {chartremotely-0.3.7 → chartremotely-0.3.9}/chartremotely/voice.py +0 -0
  48. {chartremotely-0.3.7 → chartremotely-0.3.9}/chartremotely.egg-info/dependency_links.txt +0 -0
  49. {chartremotely-0.3.7 → chartremotely-0.3.9}/chartremotely.egg-info/entry_points.txt +0 -0
  50. {chartremotely-0.3.7 → chartremotely-0.3.9}/chartremotely.egg-info/top_level.txt +0 -0
  51. {chartremotely-0.3.7 → chartremotely-0.3.9}/setup.cfg +0 -0
  52. {chartremotely-0.3.7 → chartremotely-0.3.9}/tests/test_changelog.py +0 -0
  53. {chartremotely-0.3.7 → chartremotely-0.3.9}/tests/test_hearing.py +0 -0
  54. {chartremotely-0.3.7 → chartremotely-0.3.9}/tests/test_patron.py +0 -0
  55. {chartremotely-0.3.7 → chartremotely-0.3.9}/tests/test_popups.py +0 -0
  56. {chartremotely-0.3.7 → chartremotely-0.3.9}/tests/test_push.py +0 -0
  57. {chartremotely-0.3.7 → chartremotely-0.3.9}/tests/test_recent.py +0 -0
  58. {chartremotely-0.3.7 → chartremotely-0.3.9}/tests/test_registry.py +0 -0
  59. {chartremotely-0.3.7 → chartremotely-0.3.9}/tests/test_relay.py +0 -0
  60. {chartremotely-0.3.7 → chartremotely-0.3.9}/tests/test_requestlog.py +0 -0
  61. {chartremotely-0.3.7 → chartremotely-0.3.9}/tests/test_resolve.py +0 -0
  62. {chartremotely-0.3.7 → chartremotely-0.3.9}/tests/test_scales.py +0 -0
  63. {chartremotely-0.3.7 → chartremotely-0.3.9}/tests/test_screen.py +0 -0
  64. {chartremotely-0.3.7 → chartremotely-0.3.9}/tests/test_setup.py +0 -0
  65. {chartremotely-0.3.7 → chartremotely-0.3.9}/tests/test_stamp_changelog.py +0 -0
  66. {chartremotely-0.3.7 → chartremotely-0.3.9}/tests/test_timeframe.py +0 -0
  67. {chartremotely-0.3.7 → chartremotely-0.3.9}/tests/test_understand.py +0 -0
  68. {chartremotely-0.3.7 → chartremotely-0.3.9}/tests/test_voice.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chartremotely
3
- Version: 0.3.7
3
+ Version: 0.3.9
4
4
  Summary: Remote control of desktop charting applications on macOS
5
5
  Author: Lonnie VanZandt
6
6
  License:
@@ -219,7 +219,7 @@ Requires-Dist: pyobjc-framework-Quartz>=10; extra == "macos"
219
219
  Requires-Dist: pyobjc-framework-Security>=10; extra == "macos"
220
220
  Requires-Dist: mlx-whisper>=0.4.3; (sys_platform == "darwin" and platform_machine == "arm64") and extra == "macos"
221
221
  Provides-Extra: setup
222
- Requires-Dist: tollbooth-dpyc[nostr]==0.105.1; extra == "setup"
222
+ Requires-Dist: tollbooth-dpyc[nostr]==0.106.0; extra == "setup"
223
223
  Provides-Extra: dev
224
224
  Requires-Dist: pytest>=8; extra == "dev"
225
225
  Requires-Dist: ruff>=0.6; extra == "dev"
@@ -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 _check_accessibility() -> tuple[str, str]:
37
- """A denied grant looks like an empty tree, not an error."""
38
- from . import ax
39
- try:
40
- app = ax.running_app()
41
- except ax.NotRunning:
42
- return WARN, "skipped - thinkorswim is not running"
43
- windows = ax.attr(ax.handle(app), "AXWindows")
44
- if not windows:
45
- return BAD, ("no accessibility access - grant it in System Settings > "
46
- "Privacy & Security > Accessibility. This cannot be automated.")
47
- return OK, f"accessibility granted ({len(windows)} windows visible)"
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
- ("Accessibility", _check_accessibility),
128
+ ("Services", _check_services),
129
+ ("Permissions", _check_permissions),
101
130
  ("Chart controls", _check_controls),
102
131
  ("Registry", _check_registry),
103
132
  ("Listener", _check_listener),
@@ -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
- out.ask("Turn it on in System Settings, then press Return… ")
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.
@@ -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
- check=True, capture_output=True)
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)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chartremotely
3
- Version: 0.3.7
3
+ Version: 0.3.9
4
4
  Summary: Remote control of desktop charting applications on macOS
5
5
  Author: Lonnie VanZandt
6
6
  License:
@@ -219,7 +219,7 @@ Requires-Dist: pyobjc-framework-Quartz>=10; extra == "macos"
219
219
  Requires-Dist: pyobjc-framework-Security>=10; extra == "macos"
220
220
  Requires-Dist: mlx-whisper>=0.4.3; (sys_platform == "darwin" and platform_machine == "arm64") and extra == "macos"
221
221
  Provides-Extra: setup
222
- Requires-Dist: tollbooth-dpyc[nostr]==0.105.1; extra == "setup"
222
+ Requires-Dist: tollbooth-dpyc[nostr]==0.106.0; extra == "setup"
223
223
  Provides-Extra: dev
224
224
  Requires-Dist: pytest>=8; extra == "dev"
225
225
  Requires-Dist: ruff>=0.6; extra == "dev"
@@ -46,6 +46,7 @@ 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
@@ -13,4 +13,4 @@ pyobjc-framework-Security>=10
13
13
  mlx-whisper>=0.4.3
14
14
 
15
15
  [setup]
16
- tollbooth-dpyc[nostr]==0.105.1
16
+ tollbooth-dpyc[nostr]==0.106.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "chartremotely"
3
- version = "0.3.7"
3
+ version = "0.3.9"
4
4
  description = "Remote control of desktop charting applications on macOS"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -25,7 +25,7 @@ macos = [
25
25
  ]
26
26
  # `chartremotely setup` makes and proves Nostr identities. The key and proof
27
27
  # code is the tollbooth-dpyc SDK's own, never rewritten here.
28
- setup = ["tollbooth-dpyc[nostr]==0.105.1"]
28
+ setup = ["tollbooth-dpyc[nostr]==0.106.0"]
29
29
  dev = ["pytest>=8", "ruff>=0.6"]
30
30
 
31
31
  [project.urls]
@@ -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
@@ -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
File without changes
File without changes
File without changes