From 10971480ab8f3c236986e2f3c76fd5bb800e821b Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Sat, 13 Jun 2026 11:55:31 -0400 Subject: [PATCH] fix: don't take a screenshot at import time (breaks headless imports) recorder.py computed monitor dimensions via utils.take_screenshot() at module scope, so `import openadapt_capture` crashed in any headless environment whose display reported a zero-size region. This took down `openadapt version` and `openadapt doctor` (found via the new CLI smoke tests in OpenAdaptAI/OpenAdapt). Move the computation into the video- setup function that is its only consumer. Adds tests/test_headless_import.py: a deterministic AST guard that no package module calls a display API (take_screenshot/get_monitor_dims/ grab) at import scope. A subprocess import test is unreliable (only reproduces on a genuinely headless display); this fails regardless of environment. Co-Authored-By: Claude Fable 5 --- openadapt_capture/recorder.py | 5 ++- tests/test_headless_import.py | 58 +++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 3 deletions(-) create mode 100644 tests/test_headless_import.py diff --git a/openadapt_capture/recorder.py b/openadapt_capture/recorder.py index 62c610b..3362ae7 100644 --- a/openadapt_capture/recorder.py +++ b/openadapt_capture/recorder.py @@ -142,9 +142,6 @@ def __bool__(self): stop_sequence_detected = False ws_server_instance = None -# TODO XXX replace with utils.get_monitor_dims() once fixed -monitor_width, monitor_height = utils.take_screenshot().size - def collect_stats(performance_snapshots: list[tracemalloc.Snapshot]) -> None: """Collects and appends performance snapshots using tracemalloc. @@ -546,6 +543,8 @@ def video_pre_callback( dict[str, Any]: The updated state. """ video_file_path = video.get_video_file_path(recording.timestamp, video_dir) + # TODO XXX replace with utils.get_monitor_dims() once fixed + monitor_width, monitor_height = utils.take_screenshot().size video_container, video_stream, video_start_timestamp = ( video.initialize_video_writer(video_file_path, monitor_width, monitor_height) ) diff --git a/tests/test_headless_import.py b/tests/test_headless_import.py new file mode 100644 index 0000000..c7e4172 --- /dev/null +++ b/tests/test_headless_import.py @@ -0,0 +1,58 @@ +"""Importing openadapt_capture must not call display APIs at module scope. + +recorder.py used to compute monitor dimensions via a screenshot at +module scope (`monitor_width, monitor_height = utils.take_screenshot() +.size`), so `import openadapt_capture` crashed in any headless +environment whose display reported a zero-size region, taking down +`openadapt version`/`doctor` with it. + +A subprocess import test is unreliable here (it only reproduces on a +genuinely headless display), so this guards the invariant statically: +no module-level call to a display/screenshot API in any package module. +Importing a library should be cheap and side-effect free. +""" + +from __future__ import annotations + +import ast +from pathlib import Path + +PACKAGE_ROOT = Path(__file__).resolve().parent.parent / "openadapt_capture" + +# Calls that touch the screen/display and must not run at import time. +FORBIDDEN_AT_MODULE_SCOPE = {"take_screenshot", "get_monitor_dims", "grab"} + + +def _module_level_calls(tree: ast.Module): + """Yield Call nodes that execute at import (module body, not inside + a function or class definition).""" + for node in tree.body: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + continue + for sub in ast.walk(node): + if isinstance(sub, ast.Call): + yield sub + + +def _called_name(call: ast.Call) -> str | None: + func = call.func + if isinstance(func, ast.Name): + return func.id + if isinstance(func, ast.Attribute): + return func.attr + return None + + +def test_no_display_calls_at_module_scope(): + problems = [] + for path in PACKAGE_ROOT.rglob("*.py"): + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + for call in _module_level_calls(tree): + name = _called_name(call) + if name in FORBIDDEN_AT_MODULE_SCOPE: + problems.append(f"{path}:{call.lineno}: module-level {name}()") + assert not problems, ( + "Display/screenshot calls at import time break headless imports " + "(CI, servers, containers). Move them inside the function that " + "uses them:\n " + "\n ".join(problems) + )