wintegrate 0.1.0__py3-none-any.whl

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.
wintegrate/__init__.py ADDED
@@ -0,0 +1,114 @@
1
+ """wintegrate: A CI-first Windows UI automation library designed for unattended runners."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.metadata import PackageNotFoundError
6
+ from importlib.metadata import version as _version
7
+
8
+ try:
9
+ __version__ = _version("wintegrate")
10
+ except PackageNotFoundError: # running from a source tree without an install
11
+ __version__ = "0.0.0.dev0"
12
+
13
+ from wintegrate.apps import CALCULATOR, NOTEPAD, AppHandle, AppSpec
14
+ from wintegrate.diagnostics import CensusDiff, ContinuousRecorder, WindowCensus, WindowSnapshot
15
+ from wintegrate.element import UiaElement
16
+ from wintegrate.env import (
17
+ PlatformCapabilities,
18
+ desktop_only,
19
+ env,
20
+ is_windows_desktop,
21
+ is_windows_server,
22
+ server_only,
23
+ )
24
+ from wintegrate.exceptions import (
25
+ ActionVerificationError,
26
+ DiagnosticPipelineError,
27
+ ElementNotFoundError,
28
+ FocusStealDetectedError,
29
+ TextMismatchError,
30
+ WindowDiscoveryTimeoutError,
31
+ WintegrateError,
32
+ )
33
+ from wintegrate.interop import (
34
+ KEY_NAMES,
35
+ get_composition_string,
36
+ get_foreground_window,
37
+ get_ime_status,
38
+ get_keyboard_layout,
39
+ get_keyboard_layout_list,
40
+ get_process_image_name,
41
+ get_window_class,
42
+ get_window_pid,
43
+ get_window_title,
44
+ parse_key_spec,
45
+ send_char_input,
46
+ send_keys,
47
+ send_mouse_click,
48
+ send_physical_keys,
49
+ send_vk_input,
50
+ set_ime_conversion,
51
+ set_ime_open,
52
+ )
53
+ from wintegrate.recorder import (
54
+ ActionPlayer,
55
+ RecordedAction,
56
+ TextActionTimelineRecorder,
57
+ inspect_desktop_tree,
58
+ )
59
+ from wintegrate.session import Session, SessionConfig
60
+ from wintegrate.text import count_lines, normalize_line_endings
61
+ from wintegrate.window import Window
62
+
63
+ __all__ = [
64
+ "__version__",
65
+ "Session",
66
+ "SessionConfig",
67
+ "Window",
68
+ "UiaElement",
69
+ "AppSpec",
70
+ "AppHandle",
71
+ "NOTEPAD",
72
+ "CALCULATOR",
73
+ "env",
74
+ "PlatformCapabilities",
75
+ "is_windows_server",
76
+ "is_windows_desktop",
77
+ "desktop_only",
78
+ "server_only",
79
+ "normalize_line_endings",
80
+ "count_lines",
81
+ "send_char_input",
82
+ "send_keys",
83
+ "send_vk_input",
84
+ "send_physical_keys",
85
+ "get_ime_status",
86
+ "set_ime_open",
87
+ "set_ime_conversion",
88
+ "get_composition_string",
89
+ "get_keyboard_layout",
90
+ "get_keyboard_layout_list",
91
+ "parse_key_spec",
92
+ "KEY_NAMES",
93
+ "send_mouse_click",
94
+ "get_foreground_window",
95
+ "get_process_image_name",
96
+ "get_window_class",
97
+ "get_window_pid",
98
+ "get_window_title",
99
+ "WintegrateError",
100
+ "WindowDiscoveryTimeoutError",
101
+ "ElementNotFoundError",
102
+ "ActionVerificationError",
103
+ "TextMismatchError",
104
+ "FocusStealDetectedError",
105
+ "DiagnosticPipelineError",
106
+ "ContinuousRecorder",
107
+ "WindowCensus",
108
+ "WindowSnapshot",
109
+ "CensusDiff",
110
+ "RecordedAction",
111
+ "TextActionTimelineRecorder",
112
+ "ActionPlayer",
113
+ "inspect_desktop_tree",
114
+ ]
wintegrate/apps.py ADDED
@@ -0,0 +1,100 @@
1
+ """Well-known application specs and a managed application lifecycle handle.
2
+
3
+ These package the launch/discovery pitfalls of modern Windows apps so callers
4
+ don't have to rediscover them:
5
+
6
+ - Titles are localized; process image names, window classes, and UIA control
7
+ types are not. AppSpec matching prefers the locale-independent identities and
8
+ keeps ``title_pattern`` only as a last-resort fallback (this is also where
9
+ localized variants live, once, instead of in every caller).
10
+ - Store apps are single-instance: a leaked instance makes the next launch open
11
+ a tab in the old window instead of a new top-level window, breaking window
12
+ discovery. ``fresh`` sweeps leftover instances before launching.
13
+ - Cold Store-app starts regularly exceed 10s on CI runners (ARM64 especially),
14
+ so managed launches default to a generous discovery timeout.
15
+ - A launch that times out must not leak: AppHandle is a context manager that
16
+ guarantees window close + process kill on exit.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import logging
22
+ import subprocess
23
+ from dataclasses import dataclass
24
+
25
+ from wintegrate.element import UiaElement
26
+ from wintegrate.window import Window
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class AppSpec:
33
+ """Locale-independent identity of an application for launch-and-discover."""
34
+
35
+ name: str
36
+ command: tuple[str, ...]
37
+ process_names: tuple[str, ...] = ()
38
+ window_classes: tuple[str, ...] = ()
39
+ title_pattern: str | None = None
40
+ text_input_ladder: tuple[dict, ...] | None = None
41
+
42
+
43
+ NOTEPAD = AppSpec(
44
+ name="notepad",
45
+ command=("notepad.exe",),
46
+ process_names=("notepad.exe",),
47
+ window_classes=("Notepad",), # same class for classic and Store Notepad
48
+ )
49
+
50
+ CALCULATOR = AppSpec(
51
+ name="calculator",
52
+ command=("calc.exe",),
53
+ # Win11 WinUI / Win10 UWP / legacy Server builds
54
+ process_names=("calculatorapp.exe", "calculator.exe", "win32calc.exe"),
55
+ # UWP windows can be owned by ApplicationFrameHost rather than the app
56
+ # process, so keep a title fallback (localized variants live here, once).
57
+ title_pattern="(?i)calculator|小算盤|计算器|電卓",
58
+ )
59
+
60
+
61
+ class AppHandle:
62
+ """Owns one launched application: window + launcher process + guaranteed cleanup."""
63
+
64
+ def __init__(
65
+ self,
66
+ proc: subprocess.Popen | None,
67
+ window: Window,
68
+ spec: AppSpec | None = None,
69
+ ):
70
+ self.proc = proc
71
+ self.window = window
72
+ self.spec = spec
73
+
74
+ def find_text_input(self, timeout: float = 20.0) -> UiaElement:
75
+ ladder = self.spec.text_input_ladder if self.spec else None
76
+ return self.window.find_text_input(timeout=timeout, ladder=ladder)
77
+
78
+ def close(self) -> None:
79
+ try:
80
+ self.window.close(force=True)
81
+ except Exception as exc:
82
+ logger.debug(f"AppHandle window close failed ({type(exc).__name__}): {exc}")
83
+ if self.proc:
84
+ try:
85
+ self.proc.kill()
86
+ except Exception:
87
+ pass
88
+
89
+ def __enter__(self) -> AppHandle:
90
+ return self
91
+
92
+ def __exit__(self, exc_type, exc_val, exc_tb) -> bool:
93
+ self.close()
94
+ return False
95
+
96
+
97
+ def kill_processes(names: tuple[str, ...] | list[str]) -> None:
98
+ """Best-effort force-kill by image name (fresh-launch sweep for single-instance apps)."""
99
+ for name in names:
100
+ subprocess.run(["taskkill", "/F", "/IM", name], capture_output=True, check=False)
@@ -0,0 +1,409 @@
1
+ """CI-first diagnostic subsystem: streaming video recorder, desktop window census, and process tracking."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ctypes
6
+ import glob
7
+ import logging
8
+ import os
9
+ import shutil
10
+ import subprocess
11
+ import threading
12
+ import time
13
+ from dataclasses import dataclass, field
14
+ from fractions import Fraction
15
+ from pathlib import Path
16
+
17
+ from wintegrate.exceptions import DiagnosticPipelineError
18
+ from wintegrate.interop import (
19
+ BITMAPINFOHEADER,
20
+ WNDENUMPROC,
21
+ attach_to_input_desktop,
22
+ gdi32,
23
+ get_input_desktop_handle,
24
+ get_window_class,
25
+ get_window_pid,
26
+ get_window_title,
27
+ user32,
28
+ )
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ _PILLOW_HINT = (
33
+ "Screen capture needs Pillow, which ships in the optional 'video' extra: "
34
+ "pip install 'wintegrate[video]'"
35
+ )
36
+
37
+
38
+ def _load_pil_image():
39
+ """Imports PIL.Image on demand so the core install does not require Pillow."""
40
+ try:
41
+ from PIL import Image
42
+ except ImportError as exc: # pragma: no cover - depends on install extras
43
+ raise DiagnosticPipelineError(_PILLOW_HINT) from exc
44
+ return Image
45
+
46
+
47
+ def capture_screen_image():
48
+ """Captures the current primary display using GDI BitBlt into a PIL Image."""
49
+ Image = _load_pil_image()
50
+ w = user32.GetSystemMetrics(0)
51
+ h = user32.GetSystemMetrics(1)
52
+ hdc_screen = user32.GetDC(0)
53
+ hdc_mem = gdi32.CreateCompatibleDC(hdc_screen)
54
+ hbmp = gdi32.CreateCompatibleBitmap(hdc_screen, w, h)
55
+ gdi32.SelectObject(hdc_mem, hbmp)
56
+ gdi32.BitBlt(hdc_mem, 0, 0, w, h, hdc_screen, 0, 0, 0x00CC0020)
57
+
58
+ bmi = BITMAPINFOHEADER()
59
+ bmi.biSize = ctypes.sizeof(BITMAPINFOHEADER)
60
+ bmi.biWidth = w
61
+ bmi.biHeight = -h # top-down DIB
62
+ bmi.biPlanes = 1
63
+ bmi.biBitCount = 32
64
+ bmi.biCompression = 0
65
+ buf = ctypes.create_string_buffer(w * h * 4)
66
+ gdi32.GetDIBits(hdc_mem, hbmp, 0, h, buf, ctypes.byref(bmi), 0)
67
+
68
+ gdi32.DeleteDC(hdc_mem)
69
+ user32.ReleaseDC(0, hdc_screen)
70
+ gdi32.DeleteObject(hbmp)
71
+
72
+ return Image.frombuffer("RGBA", (w, h), buf, "raw", "BGRA", 0, 1).convert("RGB")
73
+
74
+
75
+ def resolve_ffmpeg_exe() -> str | None:
76
+ """
77
+ Finds a verified, existing ffmpeg executable across x64 and ARM64.
78
+ Searches system PATH, standard Windows / Chocolatey / WinGet directories,
79
+ and imageio_ffmpeg.
80
+ """
81
+ # 1. System PATH
82
+ exe = shutil.which("ffmpeg")
83
+ if exe and os.path.isfile(exe):
84
+ return exe
85
+
86
+ # 2. Common Windows and Package Manager directories
87
+ common_patterns = [
88
+ r"C:\ProgramData\chocolatey\bin\ffmpeg.exe",
89
+ r"C:\Program Files\FFmpeg\bin\ffmpeg.exe",
90
+ r"C:\ffmpeg\bin\ffmpeg.exe",
91
+ r"C:\tools\ffmpeg\bin\ffmpeg.exe",
92
+ r"C:\Users\*\AppData\Local\Microsoft\WinGet\Packages\*\*\bin\ffmpeg.exe",
93
+ r"C:\Users\*\AppData\Local\Microsoft\WinGet\Links\ffmpeg.exe",
94
+ ]
95
+ for pattern in common_patterns:
96
+ matches = glob.glob(pattern)
97
+ for m in matches:
98
+ if os.path.isfile(m):
99
+ return m
100
+
101
+ # 3. imageio_ffmpeg fallback (verify file exists)
102
+ try:
103
+ import imageio_ffmpeg
104
+
105
+ exe = imageio_ffmpeg.get_ffmpeg_exe()
106
+ if exe and os.path.isfile(exe):
107
+ return exe
108
+ except Exception as exc:
109
+ logger.debug(f"imageio_ffmpeg lookup failed ({type(exc).__name__}): {exc}")
110
+
111
+ return None
112
+
113
+
114
+ class ContinuousRecorder:
115
+ """
116
+ Low-memory streaming desktop screen recorder.
117
+
118
+ Encodes in-process through PyAV (which bundles FFmpeg) when it is installed,
119
+ and otherwise streams raw frames to an external FFmpeg subprocess over stdin
120
+ with stderr redirected to a disk log, so the pipe buffer cannot deadlock.
121
+
122
+ PyAV is preferred because it is the only route that works everywhere the
123
+ library runs: no ffmpeg build ships a `win_arm64` wheel on PyPI, so on Windows
124
+ ARM64 the subprocess path depends on an ffmpeg the user installed themselves.
125
+ """
126
+
127
+ def __init__(self, output_path: str | Path, fps: int = 30):
128
+ self.output_path = Path(output_path)
129
+ self.fps = fps
130
+ self.interval = 1.0 / fps
131
+ self.stop_event = threading.Event()
132
+ self.thread: threading.Thread | None = None
133
+ self._proc: subprocess.Popen | None = None
134
+ self._stderr_file = None
135
+ self._frame_count = 0
136
+ self._ffmpeg_exe = resolve_ffmpeg_exe()
137
+ # PyAV encoding state
138
+ self._av = None
139
+ self._container = None
140
+ self._stream = None
141
+ self._t0 = 0.0
142
+ self._size: tuple[int, int] = (0, 0)
143
+
144
+ @property
145
+ def backend(self) -> str | None:
146
+ """ "pyav", "ffmpeg", or None when recording is not running."""
147
+ if self._container is not None:
148
+ return "pyav"
149
+ if self._proc is not None:
150
+ return "ffmpeg"
151
+ return None
152
+
153
+ def _start_pyav(self, w: int, h: int) -> bool:
154
+ try:
155
+ import av
156
+ except ImportError:
157
+ return False
158
+
159
+ codec = "libx264" if self.output_path.suffix.lower() == ".mp4" else "libvpx-vp9"
160
+ container = None
161
+ try:
162
+ container = av.open(str(self.output_path), mode="w")
163
+ stream = container.add_stream(codec, rate=self.fps)
164
+ stream.width, stream.height = w, h
165
+ stream.pix_fmt = "yuv420p"
166
+ # Wall-clock presentation timestamps in milliseconds. Screen capture
167
+ # rarely sustains the nominal frame rate, and encoding at a fixed rate
168
+ # would then play the recording back faster than the run happened.
169
+ stream.codec_context.time_base = Fraction(1, 1000)
170
+ except Exception as exc:
171
+ logger.warning(f"PyAV encoder unavailable ({type(exc).__name__}): {exc}")
172
+ if container is not None:
173
+ try:
174
+ container.close()
175
+ except Exception:
176
+ pass
177
+ return False
178
+
179
+ self._av = av
180
+ self._container = container
181
+ self._stream = stream
182
+ self._size = (w, h)
183
+ return True
184
+
185
+ def start(self) -> bool:
186
+ self.output_path.parent.mkdir(parents=True, exist_ok=True)
187
+
188
+ # libx264 requires even dimensions; odd desktop resolutions are rare but real.
189
+ w = user32.GetSystemMetrics(0) & ~1
190
+ h = user32.GetSystemMetrics(1) & ~1
191
+
192
+ if self._start_pyav(w, h):
193
+ self.stop_event.clear()
194
+ self._frame_count = 0
195
+ self._t0 = time.monotonic()
196
+ self.thread = threading.Thread(target=self._record_loop, daemon=True)
197
+ self.thread.start()
198
+ logger.info(
199
+ f"ContinuousRecorder started via PyAV (streaming to {self.output_path} "
200
+ f"@ {self.fps} FPS)"
201
+ )
202
+ return True
203
+
204
+ if not self._ffmpeg_exe:
205
+ logger.warning(
206
+ "No FFmpeg available: PyAV is not installed and no ffmpeg executable was "
207
+ "found. Install the video extra (pip install 'wintegrate[video]') or put "
208
+ "ffmpeg on PATH. ContinuousRecorder video disabled."
209
+ )
210
+ return False
211
+
212
+ return self._start_ffmpeg_subprocess()
213
+
214
+ def _start_ffmpeg_subprocess(self) -> bool:
215
+ stderr_log_path = self.output_path.with_suffix(".ffmpeg.log")
216
+ self._stderr_file = open(stderr_log_path, "w", encoding="utf-8")
217
+
218
+ w = user32.GetSystemMetrics(0)
219
+ h = user32.GetSystemMetrics(1)
220
+
221
+ cmd = [
222
+ self._ffmpeg_exe,
223
+ "-y",
224
+ "-f",
225
+ "rawvideo",
226
+ "-vcodec",
227
+ "rawvideo",
228
+ "-s",
229
+ f"{w}x{h}",
230
+ "-pix_fmt",
231
+ "rgb24",
232
+ "-r",
233
+ str(self.fps),
234
+ "-i",
235
+ "-",
236
+ "-c:v",
237
+ "libx264" if self.output_path.suffix.lower() == ".mp4" else "libvpx-vp9",
238
+ "-pix_fmt",
239
+ "yuv420p",
240
+ str(self.output_path),
241
+ ]
242
+
243
+ try:
244
+ self._proc = subprocess.Popen(
245
+ cmd,
246
+ stdin=subprocess.PIPE,
247
+ stdout=subprocess.DEVNULL,
248
+ stderr=self._stderr_file,
249
+ )
250
+ except Exception as exc:
251
+ self._stderr_file.close()
252
+ logger.error(f"Failed to start FFmpeg subprocess ({type(exc).__name__}): {exc}")
253
+ return False
254
+
255
+ self.stop_event.clear()
256
+ self._frame_count = 0
257
+ self.thread = threading.Thread(target=self._record_loop, daemon=True)
258
+ self.thread.start()
259
+ logger.info(
260
+ f"ContinuousRecorder started via FFmpeg subprocess "
261
+ f"(streaming to {self.output_path} @ {self.fps} FPS)"
262
+ )
263
+ return True
264
+
265
+ def _encode_pyav_frame(self, img) -> None:
266
+ w, h = self._size
267
+ frame = self._av.VideoFrame.from_image(img)
268
+ if (frame.width, frame.height) != (w, h):
269
+ frame = frame.reformat(w, h, "yuv420p")
270
+ else:
271
+ frame = frame.reformat(format="yuv420p")
272
+ frame.pts = int((time.monotonic() - self._t0) * 1000)
273
+ frame.time_base = Fraction(1, 1000)
274
+ for packet in self._stream.encode(frame):
275
+ self._container.mux(packet)
276
+
277
+ def _record_loop(self):
278
+ attach_to_input_desktop()
279
+ while not self.stop_event.is_set():
280
+ t0 = time.monotonic()
281
+ try:
282
+ img = capture_screen_image()
283
+ if self._container is not None:
284
+ self._encode_pyav_frame(img)
285
+ self._frame_count += 1
286
+ elif self._proc and self._proc.stdin:
287
+ self._proc.stdin.write(img.tobytes())
288
+ self._frame_count += 1
289
+ except (BrokenPipeError, OSError) as exc:
290
+ logger.error(f"FFmpeg stdin pipe broken ({type(exc).__name__}): {exc}")
291
+ break
292
+ except Exception as exc:
293
+ logger.debug(f"Screen capture frame skipped ({type(exc).__name__}): {exc}")
294
+
295
+ elapsed = time.monotonic() - t0
296
+ sleep_time = max(0.001, self.interval - elapsed)
297
+ time.sleep(sleep_time)
298
+
299
+ def stop(self, timeout: float = 5.0) -> int:
300
+ self.stop_event.set()
301
+ if self.thread:
302
+ self.thread.join(timeout=timeout)
303
+
304
+ if self._container is not None:
305
+ # Flush the encoder before closing, or the tail of the recording — the
306
+ # part covering the failure being diagnosed — never reaches the file.
307
+ try:
308
+ for packet in self._stream.encode(None):
309
+ self._container.mux(packet)
310
+ except Exception as exc:
311
+ logger.debug(f"PyAV encoder flush warning ({type(exc).__name__}): {exc}")
312
+ try:
313
+ self._container.close()
314
+ except Exception as exc:
315
+ logger.debug(f"PyAV container close warning ({type(exc).__name__}): {exc}")
316
+ self._container = None
317
+ self._stream = None
318
+ logger.info(f"ContinuousRecorder stopped. Total frames recorded: {self._frame_count}")
319
+ return self._frame_count
320
+
321
+ if self._proc:
322
+ try:
323
+ if self._proc.stdin:
324
+ try:
325
+ self._proc.stdin.close()
326
+ except (OSError, BrokenPipeError):
327
+ pass
328
+ self._proc.wait(timeout=timeout)
329
+ except (subprocess.TimeoutExpired, OSError, BrokenPipeError):
330
+ try:
331
+ self._proc.kill()
332
+ except Exception:
333
+ pass
334
+ except Exception as exc:
335
+ logger.debug(
336
+ f"ContinuousRecorder process cleanup warning ({type(exc).__name__}): {exc}"
337
+ )
338
+ finally:
339
+ self._proc = None
340
+
341
+ if self._stderr_file:
342
+ try:
343
+ self._stderr_file.close()
344
+ except Exception:
345
+ pass
346
+ self._stderr_file = None
347
+
348
+ logger.info(f"ContinuousRecorder stopped. Total frames recorded: {self._frame_count}")
349
+ return self._frame_count
350
+
351
+
352
+ @dataclass
353
+ class WindowSnapshot:
354
+ hwnd: int
355
+ title: str
356
+ class_name: str
357
+ pid: int
358
+ is_visible: bool
359
+
360
+
361
+ @dataclass
362
+ class CensusDiff:
363
+ added: list[WindowSnapshot] = field(default_factory=list)
364
+ removed: list[WindowSnapshot] = field(default_factory=list)
365
+ persisted: list[WindowSnapshot] = field(default_factory=list)
366
+
367
+
368
+ class WindowCensus:
369
+ """Takes instant snapshots of all top-level desktop windows and diffs them."""
370
+
371
+ @staticmethod
372
+ def capture() -> list[WindowSnapshot]:
373
+ attach_to_input_desktop()
374
+ snapshots: list[WindowSnapshot] = []
375
+
376
+ def enum_proc(hwnd, _):
377
+ is_vis = bool(user32.IsWindowVisible(hwnd))
378
+ title = get_window_title(hwnd)
379
+ cls = get_window_class(hwnd)
380
+ pid = get_window_pid(hwnd)
381
+ snapshots.append(
382
+ WindowSnapshot(
383
+ hwnd=hwnd,
384
+ title=title,
385
+ class_name=cls,
386
+ pid=pid,
387
+ is_visible=is_vis,
388
+ )
389
+ )
390
+ return True
391
+
392
+ cb = WNDENUMPROC(enum_proc)
393
+ h_desk = get_input_desktop_handle()
394
+ if h_desk:
395
+ user32.EnumDesktopWindows(h_desk, cb, 0)
396
+ else:
397
+ user32.EnumWindows(cb, 0)
398
+ return snapshots
399
+
400
+ @classmethod
401
+ def diff(cls, before: list[WindowSnapshot], after: list[WindowSnapshot]) -> CensusDiff:
402
+ before_map = {w.hwnd: w for w in before}
403
+ after_map = {w.hwnd: w for w in after}
404
+
405
+ added = [w for hwnd, w in after_map.items() if hwnd not in before_map]
406
+ removed = [w for hwnd, w in before_map.items() if hwnd not in after_map]
407
+ persisted = [w for hwnd, w in after_map.items() if hwnd in before_map]
408
+
409
+ return CensusDiff(added=added, removed=removed, persisted=persisted)