"""test_linux_patches.py — verify the Linux platform patches are wired correctly. Run with the project virtualenv: .venv/bin/python linux/test_linux_patches.py These are the regressions that actually bit us during the Trixie port, so they are asserted rather than left to manual testing: 1. **Kivy WeakMethod name trap.** ``Clock`` stores ``callable.__name__`` and re-resolves it with ``getattr(instance, name)``. If a patched method is assigned under a name that differs from its own ``__name__``, the app dies ~20 s later with ``AttributeError`` — far from the cause. Every method this port replaces must be reachable under its own function name. 2. **SDL2 Wayland capability.** Kivy's PyPI wheel bundles an SDL2 *without* the wayland driver; the system SDL2 has it. Getting this wrong means no window at all on Raspberry Pi OS Trixie. 3. **WAYLAND_DISPLAY inference.** SDL2 requires the variable to be set — unlike ``wlopm``, it does not scan ``XDG_RUNTIME_DIR``. A systemd/cron launch has it unset, so the entry point must fill it in. The player does not need to be running; this only exercises wiring and detection. """ from __future__ import annotations import ctypes import glob import os import subprocess import sys from pathlib import Path HERE = Path(__file__).resolve().parent ROOT = HERE.parent SRC = ROOT / 'src' VENV = Path(os.environ.get('KIWY_VENV') or (ROOT / '.venv')) for path in (str(HERE), str(SRC)): if path not in sys.path: sys.path.insert(0, path) failures: list[str] = [] checks = 0 def check(label, condition, detail=''): global checks checks += 1 if condition: print(f' PASS {label}') else: print(f' FAIL {label}' + (f' — {detail}' if detail else '')) failures.append(label) # ── 1. WeakMethod name trap ────────────────────────────────────────── print('\n[1] Patched methods are resolvable by their own __name__') # Import the entry point's patching helpers without running the app. Importing # run_linux executes its env setup, which is harmless and actually desirable # here (it fills in WAYLAND_DISPLAY for the checks below). import run_linux # noqa: E402 main = run_linux._import_main() patches = { 'signal_screen_activity': run_linux._patch_display(main), 'weblink_adapter_factory': run_linux._patch_weblink_engines(main), 'test_connection': run_linux._patch_temp_paths(main), 'reset_player_auth': run_linux._patch_auth_path(main), } for label, applied in patches.items(): check(f'{label} patch applied', applied, 'patch reported failure') player_cls = main.SignagePlayer for attr in ('signal_screen_activity', 'weblink_adapter_factory'): func = getattr(player_cls, attr, None) check(f'{attr} exists', func is not None, 'attribute missing') if func is None: continue # For a staticmethod, inspect the underlying function. raw = player_cls.__dict__.get(attr) func = raw.__func__ if isinstance(raw, staticmethod) else raw name = getattr(func, '__name__', '') # The real invariant: Kivy resolves the callback with # getattr(instance, func.__name__), so that lookup must succeed and return # the same function. The name need not equal the attribute it replaced, but # it MUST be reachable on the class. resolved = getattr(player_cls, name, None) check( f'{attr}: getattr(cls, {name!r}) resolves', resolved is not None, f'Kivy Clock would raise AttributeError: no attribute {name!r}', ) check( f'{attr}: resolved function is the patched one', resolved is func, f'{name!r} resolved to a different object', ) # The Windows-only focus machinery must be gone from the shared app, so nothing # schedules Win32 work on a Wayland session. for attr in ('_focus_keeper_tick', '_focus_guardian_tick', '_bring_window_to_front_nonblocking', '_start_focus_guardian'): check( f'{attr} removed (Windows-only)', not hasattr(player_cls, attr), 'the platform-specific focus code should not be in the shared app', ) # ── 2. Display module behaviour ────────────────────────────────────── print('\n[2] linux_display detection and keep-awake') import linux_display as display # noqa: E402 status = display.status() print(f' info status = {status}') if status['wayland']: check('WAYLAND_DISPLAY is set after detection', bool(os.environ.get('WAYLAND_DISPLAY')), 'SDL2/wlopm need this variable') check('wlopm available', status['wlopm'], 'install wlopm') check('keep_display_awake reports success', display.keep_display_awake(force=True) is True, 'wlopm --on did not succeed') else: print(' SKIP not a Wayland session — display checks skipped') # With the tools explicitly disabled nothing must be attempted. os.environ[display.DISABLE_ENV_VAR] = '1' check('DISABLE escape hatch suppresses keep-awake', display.keep_display_awake(force=True) is False) check('DISABLE escape hatch suppresses blanker kill', display.neutralise_idle_blanker() == []) del os.environ[display.DISABLE_ENV_VAR] # ── 3. SDL2 Wayland capability ─────────────────────────────────────── print('\n[3] SDL2 in use supports the wayland driver') def drivers_of(lib_path): try: lib = ctypes.CDLL(str(lib_path)) lib.SDL_GetNumVideoDrivers.restype = ctypes.c_int lib.SDL_GetVideoDriver.restype = ctypes.c_char_p lib.SDL_GetVideoDriver.argtypes = [ctypes.c_int] n = lib.SDL_GetNumVideoDrivers() return [lib.SDL_GetVideoDriver(i).decode() for i in range(n)] except Exception as exc: return [f''] ext = glob.glob(str(VENV / '**' / '_window_sdl2*.so'), recursive=True) if not ext: print(' SKIP no Kivy SDL2 extension found in this venv') else: # Ask the dynamic loader which libSDL2 the extension actually resolves. linked = subprocess.run( ['ldd', ext[0]], capture_output=True, text=True, check=False, ).stdout resolved = None for line in linked.splitlines(): if 'libSDL2-2-' in line and '=>' in line: resolved = line.split('=>')[1].split('(')[0].strip() break check('Kivy resolves an SDL2 library', resolved is not None, 'ldd found none') if resolved: used = drivers_of(resolved) print(f' info {resolved}\n drivers = {used}') check('resolved SDL2 supports wayland', 'wayland' in used, 'run: bash linux/fix_kivy_sdl2.sh') # ── Summary ────────────────────────────────────────────────────────── print(f'\n{checks - len(failures)}/{checks} checks passed') if failures: print('\nFailed:') for name in failures: print(f' - {name}') sys.exit(1) print('All checks passed.')