Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions openadapt_capture/recorder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
)
Expand Down
58 changes: 58 additions & 0 deletions tests/test_headless_import.py
Original file line number Diff line number Diff line change
@@ -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)
)
Loading