diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4778b91..804d13f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -121,3 +121,22 @@ jobs: - name: Run ruff run: uv run ruff check openadapt_capture/ + + package-contract: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + version: "latest" + + - name: Set up Python + run: uv python install 3.12 + + - name: Build wheel and source distribution + run: uv build + + - name: Verify release archive boundaries + run: python scripts/verify_distribution.py dist/* diff --git a/openadapt_capture/comparison.py b/openadapt_capture/comparison.py index b2665db..da9eb69 100644 --- a/openadapt_capture/comparison.py +++ b/openadapt_capture/comparison.py @@ -206,6 +206,10 @@ def plot_comparison( PIL Image if neither output_path nor show, else None. """ try: + import matplotlib + + if not show: + matplotlib.use("Agg") import matplotlib.pyplot as plt except ImportError: raise ImportError( diff --git a/openadapt_capture/recorder.py b/openadapt_capture/recorder.py index 5ce8e92..be63857 100644 --- a/openadapt_capture/recorder.py +++ b/openadapt_capture/recorder.py @@ -43,7 +43,7 @@ from pynput import keyboard, mouse from tqdm import tqdm -from openadapt_capture import platform, plotting, utils, video, window +from openadapt_capture import platform, utils, video, window from openadapt_capture.config import config from openadapt_capture.db import create_db, crud, get_session_for_path from openadapt_capture.db.models import ActionEvent, Recording @@ -154,11 +154,102 @@ def __bool__(self): "browser": True, } NUM_MEMORY_STATS_TO_LOG = 3 +STARTUP_WAIT_POLL_SECONDS = 0.1 +PRE_READY_TASK_JOIN_TIMEOUT_SECONDS = 2.0 stop_sequence_detected = False ws_server_instance = None +def _wait_for_tasks_started( + task_by_name: dict[str, Any], + task_started_events: dict[str, Any], + terminate_processing: Any, +) -> bool: + """Wait for pipeline readiness while honoring shutdown and worker failure. + + Returns ``True`` only when every task has announced readiness. A shutdown + request or a task that exits before setting its readiness event fails the + startup and signals the rest of the pipeline to stop. + """ + expected_starts = len(task_by_name) + logger.info(f"{expected_starts=}") + + while True: + if terminate_processing.is_set(): + logger.info("Recording startup cancelled before all tasks were ready") + return False + + waiting_for = [ + name + for name, event in task_started_events.items() + if not event.is_set() + ] + if not waiting_for: + return True + + stopped_before_ready = [ + name + for name in waiting_for + if name in task_by_name and not task_by_name[name].is_alive() + ] + if stopped_before_ready: + logger.error( + "Recording tasks exited before readiness: " + f"{stopped_before_ready}" + ) + terminate_processing.set() + return False + + logger.info(f"Waiting for tasks to start: {waiting_for}") + logger.info( + f"Started tasks: {expected_starts - len(waiting_for)}/{expected_starts}" + ) + terminate_processing.wait(STARTUP_WAIT_POLL_SECONDS) + + +def _join_tasks( + task_by_name: dict[str, Any], + task_names: list[str], + *, + timeout: float | None = None, +) -> list[str]: + """Join pipeline tasks, bounding teardown when startup never completed.""" + deadline = time.monotonic() + timeout if timeout is not None else None + lingering: list[str] = [] + + for task_name in task_names: + task = task_by_name.get(task_name) + if task is None: + continue + + logger.info(f"joining {task_name=}...") + if deadline is None: + task.join() + else: + task.join(timeout=max(0.0, deadline - time.monotonic())) + + if not task.is_alive(): + continue + + # Processes can be stopped after the shared graceful-shutdown deadline. + # Python cannot forcibly stop threads; recorder-owned threads are daemons + # and all receive terminate_processing before this helper is called. + if isinstance(task, multiprocessing.process.BaseProcess): + logger.warning( + f"terminating {task_name!r} after pre-ready shutdown timeout" + ) + task.terminate() + task.join(timeout=0.5) + + if task.is_alive(): + lingering.append(task_name) + + if lingering: + logger.warning(f"tasks still exiting after bounded shutdown: {lingering}") + return lingering + + def collect_stats(performance_snapshots: list[tracemalloc.Snapshot]) -> None: """Collects and appends performance snapshots using tracemalloc. @@ -1613,6 +1704,7 @@ def record( if config.RECORD_WINDOW_DATA and window_scope is None: window_event_reader = threading.Thread( target=read_window_events, + daemon=True, args=( event_q, terminate_processing, @@ -1628,6 +1720,7 @@ def record( if config.RECORD_BROWSER_EVENTS: browser_event_reader = threading.Thread( target=run_browser_event_server, + daemon=True, args=( event_q, terminate_processing, @@ -1642,6 +1735,7 @@ def record( screen_event_reader = threading.Thread( target=read_screen_events, + daemon=True, args=( event_q, terminate_processing, @@ -1656,6 +1750,7 @@ def record( keyboard_event_reader = threading.Thread( target=read_keyboard_events, + daemon=True, args=( event_q, terminate_processing, @@ -1668,6 +1763,7 @@ def record( mouse_event_reader = threading.Thread( target=read_mouse_events, + daemon=True, args=( event_q, terminate_processing, @@ -1692,6 +1788,7 @@ def record( event_processor = threading.Thread( target=process_events, + daemon=True, args=( event_q, screen_write_q, @@ -1875,35 +1972,31 @@ def record( # TODO: discard events until everything is ready - # Wait for all to signal they've started - expected_starts = len(task_by_name) - logger.info(f"{expected_starts=}") - while True: - started_tasks = sum(event.is_set() for event in task_started_events.values()) - if started_tasks >= expected_starts: - break - waiting_for = [ - task for task, event in task_started_events.items() if not event.is_set() - ] - logger.info(f"Waiting for tasks to start: {waiting_for}") - logger.info(f"Started tasks: {started_tasks}/{expected_starts}") - time.sleep(1) # Sleep to reduce busy waiting - - for _ in range(5): - logger.info("*" * 40) - logger.info("All readers and writers have started. Waiting for input events...") - - if status_pipe: - status_pipe.send({"type": "record.started"}) - global stop_sequence_detected stop_sequence_detected = False - try: - while not (stop_sequence_detected or terminate_processing.is_set()): - time.sleep(1) - terminate_processing.set() - except KeyboardInterrupt: - terminate_processing.set() + startup_ready = _wait_for_tasks_started( + task_by_name, + task_started_events, + terminate_processing, + ) + if startup_ready: + for _ in range(5): + logger.info("*" * 40) + logger.info( + "All readers and writers have started. Waiting for input events..." + ) + + if status_pipe: + status_pipe.send({"type": "record.started"}) + + try: + while not (stop_sequence_detected or terminate_processing.is_set()): + terminate_processing.wait(1) + except KeyboardInterrupt: + terminate_processing.set() + else: + logger.info("Tearing down recording after incomplete startup") + terminate_processing.set() if status_pipe: status_pipe.send({"type": "record.stopping"}) @@ -1912,14 +2005,11 @@ def record( collect_stats(performance_snapshots) log_memory_usage(_tracker, performance_snapshots) - def join_tasks(task_names: list[str]) -> None: - for task_name in task_names: - if task_name in task_by_name: - logger.info(f"joining {task_name=}...") - task = task_by_name[task_name] - task.join() - - join_tasks( + pre_ready_timeout = ( + None if startup_ready else PRE_READY_TASK_JOIN_TIMEOUT_SECONDS + ) + _join_tasks( + task_by_name, [ "window_event_reader", "browser_event_reader", @@ -1933,18 +2023,23 @@ def join_tasks(task_names: list[str]) -> None: "window_event_writer", "video_writer", "audio_recorder", - ] + ], + timeout=pre_ready_timeout, ) terminate_perf_event.set() - join_tasks( + _join_tasks( + task_by_name, [ "perf_stats_writer", "mem_writer", - ] + ], + timeout=pre_ready_timeout, ) - if config.PLOT_PERFORMANCE: + if config.PLOT_PERFORMANCE and startup_ready: + from openadapt_capture import plotting + session = get_session_for_path(db_path) plotting.plot_performance( session, recording, save_dir=capture_dir, @@ -2119,6 +2214,7 @@ def __init__( self._status_recv, self._status_send = multiprocessing.Pipe(duplex=False) self._ready_event = threading.Event() self._stopped_event = threading.Event() + self._ready_or_stopped_event = threading.Event() # Internal self._record_thread: threading.Thread | None = None @@ -2134,8 +2230,10 @@ def _drain_status_pipe(self) -> None: if isinstance(msg, dict): if msg.get("type") == "record.started": self._ready_event.set() + self._ready_or_stopped_event.set() elif msg.get("type") == "record.stopped": self._stopped_event.set() + self._ready_or_stopped_event.set() except (EOFError, OSError): pass @@ -2143,20 +2241,30 @@ def _run_record(self) -> None: """Thread target: apply config overrides, then call record().""" from openadapt_capture.config import config_override - with config_override(self._recording_config): - record( - task_description=self.task_description, - capture_dir=self.capture_dir, - terminate_processing=self._terminate_processing, - terminate_recording=self._terminate_recording, - status_pipe=self._status_send, - num_action_events=self._num_action_events, - num_screen_events=self._num_screen_events, - num_window_events=self._num_window_events, - num_browser_events=self._num_browser_events, - num_video_events=self._num_video_events, - send_profile=self._send_profile, - ) + try: + with config_override(self._recording_config): + record( + task_description=self.task_description, + capture_dir=self.capture_dir, + terminate_processing=self._terminate_processing, + terminate_recording=self._terminate_recording, + status_pipe=self._status_send, + num_action_events=self._num_action_events, + num_screen_events=self._num_screen_events, + num_window_events=self._num_window_events, + num_browser_events=self._num_browser_events, + num_video_events=self._num_video_events, + send_profile=self._send_profile, + ) + except BaseException: + # A setup exception must wake wait_for_ready() and let context-manager + # teardown finish instead of leaving callers blocked for its timeout. + try: + self._status_send.send({"type": "record.stopped"}) + except (BrokenPipeError, EOFError, OSError): + self._stopped_event.set() + self._ready_or_stopped_event.set() + raise def __enter__(self) -> "Recorder": # Start status drain thread @@ -2185,9 +2293,10 @@ def stop(self) -> None: def wait_for_ready(self, timeout: float = 60) -> bool: """Block until all recording threads/processes have started. - Returns True if ready, False if timeout expired. + Returns True if ready, False if startup stopped or the timeout expired. """ - return self._ready_event.wait(timeout=timeout) + self._ready_or_stopped_event.wait(timeout=timeout) + return self._ready_event.is_set() @property def is_recording(self) -> bool: diff --git a/openadapt_capture/stats.py b/openadapt_capture/stats.py index 464749c..7423fc3 100644 --- a/openadapt_capture/stats.py +++ b/openadapt_capture/stats.py @@ -117,6 +117,10 @@ def plot( PIL Image if neither output_path nor show, else None. """ try: + import matplotlib + + if not show: + matplotlib.use("Agg") import matplotlib.pyplot as plt except ImportError: raise ImportError( diff --git a/openadapt_capture/window/_macos.py b/openadapt_capture/window/_macos.py index 415c195..7df0acb 100644 --- a/openadapt_capture/window/_macos.py +++ b/openadapt_capture/window/_macos.py @@ -1,11 +1,7 @@ -"""macOS platform window capture using Quartz/AppKit. - -Copied from legacy OpenAdapt window/_macos.py. Only import paths changed. -""" +"""macOS window and accessibility capture using native PyObjC frameworks.""" import pickle import plistlib -import re import time from pprint import pprint from typing import Any, Literal, Union @@ -14,11 +10,11 @@ import AppKit import ApplicationServices import Foundation - import oa_atomacos import Quartz except ImportError as e: raise ImportError( - f"macOS window capture requires AppKit, Quartz, and oa_atomacos: {e}" + f"macOS window capture requires PyObjC AppKit, Quartz, and " + f"ApplicationServices: {e}" ) from loguru import logger @@ -257,24 +253,10 @@ def deepconvert_objc(object: Any) -> Any | list | dict | Literal[0]: value = {deepconvert_objc(k): deepconvert_objc(v) for k, v in object.items()} elif isinstance(object, strings): value = str(object) - # handle core-foundation class AXValueRef + # Handle Core Foundation AXValueRef without a third-party accessibility + # wrapper. PyObjC returns ``(success, value)`` from AXValueGetValue. elif isinstance(object, ApplicationServices.AXValueRef): - # convert to dict - note: this object is not iterable - # TODO: access directly, e.g. via - # ApplicationServices.AXUIElementCopyAttributeValue - rep = repr(object) - x_value = re.search(r"x:([\d.]+)", rep) - y_value = re.search(r"y:([\d.]+)", rep) - w_value = re.search(r"w:([\d.]+)", rep) - h_value = re.search(r"h:([\d.]+)", rep) - type_value = re.search(r"type\s?=\s?(\w+)", rep) - value = { - "x": float(x_value.group(1)) if x_value else None, - "y": float(y_value.group(1)) if y_value else None, - "w": float(w_value.group(1)) if w_value else None, - "h": float(h_value.group(1)) if h_value else None, - "type": type_value.group(1) if type_value else None, - } + value = _convert_ax_value(object) elif isinstance(object, Foundation.NSURL): value = str(object.absoluteString()) elif isinstance(object, Foundation.__NSCFAttributedString): @@ -294,11 +276,50 @@ def deepconvert_objc(object: Any) -> Any | list | dict | Literal[0]: "github.com/OpenAdaptAI/openadapt-capture/issues/new" ) logger.warning(f"{object=}") - if value: - value = oa_atomacos._converter.Converter().convert_value(value) return value +def _convert_ax_value(value: ApplicationServices.AXValueRef) -> Any: + """Convert a PyObjC AXValue into stable, pickle-safe primitives.""" + value_type = ApplicationServices.AXValueGetType(value) + success, raw_value = ApplicationServices.AXValueGetValue( + value, + value_type, + None, + ) + if not success: + logger.warning(f"Could not convert AXValue of type {value_type}") + return None + + if value_type == ApplicationServices.kAXValueCGPointType: + return { + "x": float(raw_value.x), + "y": float(raw_value.y), + "type": "CGPoint", + } + if value_type == ApplicationServices.kAXValueCGSizeType: + return { + "w": float(raw_value.width), + "h": float(raw_value.height), + "type": "CGSize", + } + if value_type == ApplicationServices.kAXValueCGRectType: + return { + "x": float(raw_value.origin.x), + "y": float(raw_value.origin.y), + "w": float(raw_value.size.width), + "h": float(raw_value.size.height), + "type": "CGRect", + } + if value_type == ApplicationServices.kAXValueCFRangeType: + return { + "location": int(raw_value.location), + "length": int(raw_value.length), + "type": "CFRange", + } + return str(raw_value) + + def get_active_element_state(x: int, y: int) -> dict: """Get the state of the active element at the specified coordinates. @@ -309,11 +330,23 @@ def get_active_element_state(x: int, y: int) -> dict: Returns: dict: A dictionary containing the state of the active element. """ - window_meta = get_active_window_meta() - pid = window_meta["kCGWindowOwnerPID"] - app = oa_atomacos._a11y.AXUIElement.from_pid(pid) - el = app.get_element_at_position(x, y) - state = dump_state(el.ref) + system_wide = ApplicationServices.AXUIElementCreateSystemWide() + error_code, element = ( + ApplicationServices.AXUIElementCopyElementAtPosition( + system_wide, + float(x), + float(y), + None, + ) + ) + if error_code != ApplicationServices.kAXErrorSuccess or element is None: + logger.warning( + "Could not resolve accessibility element at " + f"({x}, {y}): AX error {error_code}" + ) + return {} + + state = dump_state(element) state = deepconvert_objc(state) try: pickle.dumps(state, protocol=pickle.HIGHEST_PROTOCOL) diff --git a/pyproject.toml b/pyproject.toml index 320bd1b..0fce451 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,7 +41,11 @@ dependencies = [ "pympler>=1.0.0", "tqdm>=4.0.0", "numpy>=1.26.0", - "oa-atomacos>=3.2.0; sys_platform == 'darwin'", + # Recorder generates a performance plot by default, and the package-level + # API imports the plotting module while exposing Recorder. + "matplotlib>=3.10.8", + # Native macOS window/accessibility observation via permissive PyObjC. + "pyobjc-framework-ApplicationServices>=12.2.1; sys_platform == 'darwin'", ] [project.optional-dependencies] @@ -76,7 +80,6 @@ dev = [ "pytest-cov>=4.0.0", "pytest-timeout>=2.0.0", "ruff>=0.1.0", - "matplotlib>=3.5.0", "numpy>=1.21.0", ] @@ -128,7 +131,6 @@ patch_tags = ["fix", "perf"] [dependency-groups] dev = [ - "matplotlib>=3.10.8", "numpy>=2.2.6", "psutil>=7.2.2", "pytest>=9.0.2", diff --git a/scripts/verify_distribution.py b/scripts/verify_distribution.py new file mode 100644 index 0000000..7b61f98 --- /dev/null +++ b/scripts/verify_distribution.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""Verify release archives preserve the MIT package boundary.""" + +from __future__ import annotations + +import argparse +import tarfile +import zipfile +from pathlib import Path + +FORBIDDEN_DEPENDENCIES = ("oa-atomacos",) +FORBIDDEN_SOURCE_TOKENS = ("oa_atomacos",) + + +def _archive_files(path: Path) -> dict[str, bytes]: + if path.suffix == ".whl": + with zipfile.ZipFile(path) as archive: + return { + name: archive.read(name) + for name in archive.namelist() + if not name.endswith("/") + } + if path.name.endswith(".tar.gz"): + with tarfile.open(path, "r:gz") as archive: + return { + member.name: extracted.read() + for member in archive.getmembers() + if member.isfile() + and (extracted := archive.extractfile(member)) is not None + } + raise ValueError(f"Unsupported distribution archive: {path}") + + +def verify_distribution(path: Path) -> None: + files = _archive_files(path) + assert any(Path(name).name == "LICENSE" for name in files), ( + f"{path}: MIT LICENSE file is missing" + ) + + metadata_files = [ + content.decode("utf-8") + for name, content in files.items() + if name.endswith((".dist-info/METADATA", "/PKG-INFO")) + ] + assert metadata_files, f"{path}: package metadata is missing" + metadata = "\n".join(metadata_files).lower() + for dependency in FORBIDDEN_DEPENDENCIES: + assert f"requires-dist: {dependency}" not in metadata, ( + f"{path}: forbidden dependency {dependency!r} is in package metadata" + ) + + python_sources = "\n".join( + content.decode("utf-8") + for name, content in files.items() + if name.endswith(".py") + and "/openadapt_capture/" in f"/{name}" + ) + for token in FORBIDDEN_SOURCE_TOKENS: + assert token not in python_sources, ( + f"{path}: forbidden source token {token!r} is in the archive" + ) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("archives", nargs="+", type=Path) + args = parser.parse_args() + for archive in args.archives: + verify_distribution(archive) + print(f"verified {archive}") + + +if __name__ == "__main__": + main() diff --git a/tests/test_highlevel.py b/tests/test_highlevel.py index 4669110..cd707bc 100644 --- a/tests/test_highlevel.py +++ b/tests/test_highlevel.py @@ -4,6 +4,7 @@ """ import tempfile +import threading import time from pathlib import Path @@ -14,8 +15,11 @@ # Recorder requires pynput which needs a display server try: - from openadapt_capture.recorder import Recorder + from openadapt_capture import recorder as recorder_module + + Recorder = recorder_module.Recorder except ImportError: + recorder_module = None Recorder = None @@ -136,6 +140,52 @@ def test_recorder_video_frame_count_property(self): rec = Recorder("/tmp/test_never_created") assert rec.video_frame_count == 0 + def test_stop_during_incomplete_startup_returns_promptly( + self, monkeypatch, tmp_path + ): + """A worker that never announces readiness cannot trap context teardown.""" + worker_entered = threading.Event() + + def stalled_record( + *, + terminate_processing, + terminate_recording, + status_pipe, + **_kwargs, + ): + def wait_for_shutdown(): + worker_entered.set() + terminate_processing.wait() + + worker = threading.Thread(target=wait_for_shutdown, daemon=True) + worker.start() + tasks = {"never_ready": worker} + started_events = {"never_ready": threading.Event()} + + assert not recorder_module._wait_for_tasks_started( + tasks, + started_events, + terminate_processing, + ) + assert not recorder_module._join_tasks( + tasks, + ["never_ready"], + timeout=0.5, + ) + status_pipe.send({"type": "record.stopped"}) + terminate_recording.set() + + monkeypatch.setattr(recorder_module, "record", stalled_record) + recorder = Recorder(str(tmp_path / "incomplete-startup")) + + with recorder: + assert worker_entered.wait(timeout=1) + stop_started = time.monotonic() + recorder.stop() + + assert time.monotonic() - stop_started < 1 + assert recorder.wait_for_ready(timeout=0) is False + class TestCapture: """Tests for Capture/CaptureSession class.""" diff --git a/tests/test_macos_accessibility.py b/tests/test_macos_accessibility.py new file mode 100644 index 0000000..87e3608 --- /dev/null +++ b/tests/test_macos_accessibility.py @@ -0,0 +1,85 @@ +"""Tests for the permissive native macOS accessibility implementation.""" + +from __future__ import annotations + +import sys + +import pytest + +pytestmark = pytest.mark.skipif( + sys.platform != "darwin", + reason="PyObjC accessibility APIs are available only on macOS", +) + + +def test_ax_geometry_values_convert_to_primitives() -> None: + import ApplicationServices + + from openadapt_capture.window import _macos + + point = ApplicationServices.AXValueCreate( + ApplicationServices.kAXValueCGPointType, + (1.5, 2.5), + ) + size = ApplicationServices.AXValueCreate( + ApplicationServices.kAXValueCGSizeType, + (3.5, 4.5), + ) + rect = ApplicationServices.AXValueCreate( + ApplicationServices.kAXValueCGRectType, + ((1.0, 2.0), (3.0, 4.0)), + ) + + assert _macos.deepconvert_objc(point) == { + "x": 1.5, + "y": 2.5, + "type": "CGPoint", + } + assert _macos.deepconvert_objc(size) == { + "w": 3.5, + "h": 4.5, + "type": "CGSize", + } + assert _macos.deepconvert_objc(rect) == { + "x": 1.0, + "y": 2.0, + "w": 3.0, + "h": 4.0, + "type": "CGRect", + } + + +def test_element_at_position_uses_native_application_services( + monkeypatch, +) -> None: + from openadapt_capture.window import _macos + + system_wide = object() + element = object() + calls = [] + + monkeypatch.setattr( + _macos.ApplicationServices, + "AXUIElementCreateSystemWide", + lambda: system_wide, + ) + + def copy_element(system, x, y, output): + calls.append((system, x, y, output)) + return _macos.ApplicationServices.kAXErrorSuccess, element + + monkeypatch.setattr( + _macos.ApplicationServices, + "AXUIElementCopyElementAtPosition", + copy_element, + ) + monkeypatch.setattr( + _macos, + "dump_state", + lambda candidate: {"AXRole": "AXButton"} if candidate is element else {}, + ) + + assert _macos.get_active_element_state(120, 240) == { + "AXRole": "AXButton" + } + assert calls == [(system_wide, 120.0, 240.0, None)] diff --git a/tests/test_runtime_import_contract.py b/tests/test_runtime_import_contract.py new file mode 100644 index 0000000..969e18d --- /dev/null +++ b/tests/test_runtime_import_contract.py @@ -0,0 +1,70 @@ +"""Contracts for the installed package's default runtime API.""" + +from __future__ import annotations + +import ast +import re +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def _runtime_dependency_names() -> set[str]: + pyproject = (ROOT / "pyproject.toml").read_text(encoding="utf-8") + match = re.search( + r"(?ms)^dependencies = \[\n(?P.*?)^\]\n", + pyproject, + ) + assert match is not None + requirements = ast.literal_eval("[" + match.group("requirements") + "]") + return { + re.split(r"[\s\[<>=!~;]", requirement, maxsplit=1)[0].lower() + for requirement in requirements + } + + +def test_recorder_import_dependencies_are_in_the_default_install() -> None: + """The default package API must not rely on development-only packages.""" + assert "matplotlib" in _runtime_dependency_names() + + +def test_macos_accessibility_uses_permissive_runtime_dependencies() -> None: + """The MIT package must not pull a GPL accessibility wrapper.""" + runtime_dependencies = _runtime_dependency_names() + assert "oa-atomacos" not in runtime_dependencies + assert "pyobjc-framework-applicationservices" in runtime_dependencies + + package_sources = "\n".join( + path.read_text(encoding="utf-8") + for path in (ROOT / "openadapt_capture").rglob("*.py") + ) + assert "oa_atomacos" not in package_sources + + +def test_default_install_exposes_recorder() -> None: + """Recorder import never fails because a runtime dependency is undeclared.""" + result = subprocess.run( + [ + sys.executable, + "-c", + ( + "import importlib; " + "\ntry:\n" + " module = importlib.import_module('openadapt_capture.recorder')\n" + "except ImportError as exc:\n" + " assert exc.name != 'matplotlib' and " + "'matplotlib' not in str(exc), exc\n" + "else:\n" + " assert module.Recorder is not None\n" + ), + ], + cwd=ROOT, + capture_output=True, + text=True, + ) + assert result.returncode == 0, ( + "Recorder depended on an undeclared default-runtime package.\n" + f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" + )