python-win-windows-manager 0.1.0__tar.gz
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.
- python_win_windows_manager-0.1.0/.python-version +1 -0
- python_win_windows_manager-0.1.0/PKG-INFO +88 -0
- python_win_windows_manager-0.1.0/README.md +78 -0
- python_win_windows_manager-0.1.0/docs/API.md +85 -0
- python_win_windows_manager-0.1.0/examples/demo.py +94 -0
- python_win_windows_manager-0.1.0/pyproject.toml +24 -0
- python_win_windows_manager-0.1.0/src/window_manager/__init__.py +6 -0
- python_win_windows_manager-0.1.0/src/window_manager/core.py +228 -0
- python_win_windows_manager-0.1.0/src/window_manager/models.py +20 -0
- python_win_windows_manager-0.1.0/src/window_manager/monitor.py +164 -0
- python_win_windows_manager-0.1.0/src/window_manager/utils.py +38 -0
- python_win_windows_manager-0.1.0/tests/conftest.py +4 -0
- python_win_windows_manager-0.1.0/tests/test_core.py +114 -0
- python_win_windows_manager-0.1.0/tests/test_monitor.py +39 -0
- python_win_windows_manager-0.1.0/uv.lock +128 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.13
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: python-win-windows-manager
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Window manager and monitor for Windows
|
|
5
|
+
Project-URL: Homepage, https://github.com/username/repo
|
|
6
|
+
Requires-Python: >=3.13
|
|
7
|
+
Requires-Dist: psutil>=7.2.2
|
|
8
|
+
Requires-Dist: pywin32>=311
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# Windows Window Manager
|
|
12
|
+
|
|
13
|
+
A Python library for managing Windows windows using `win32gui` and `ctypes`. This project provides a robust interface for identifying, controlling, and monitoring windows on Windows systems.
|
|
14
|
+
|
|
15
|
+
## Features
|
|
16
|
+
|
|
17
|
+
- **Window Identification**: Get detailed information about all open windows (Handle, Title, Class Name, PID, Process Name).
|
|
18
|
+
- **Window Search**: Find windows by title or class name with exact or partial matching.
|
|
19
|
+
- **Window Control**:
|
|
20
|
+
- Minimize, Maximize, Restore
|
|
21
|
+
- Close
|
|
22
|
+
- Move and Resize
|
|
23
|
+
- Set to Foreground
|
|
24
|
+
- **Real-time Monitoring**: Monitor window creation, destruction, and state changes.
|
|
25
|
+
- **Robust Error Handling**: Handles permissions and invalid handles gracefully.
|
|
26
|
+
|
|
27
|
+
## Installation
|
|
28
|
+
|
|
29
|
+
This project uses `uv` for dependency management.
|
|
30
|
+
|
|
31
|
+
1. Clone the repository.
|
|
32
|
+
2. Install dependencies:
|
|
33
|
+
```bash
|
|
34
|
+
uv sync
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Usage
|
|
38
|
+
|
|
39
|
+
### Basic Example
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from window_manager import WindowManager
|
|
43
|
+
|
|
44
|
+
manager = WindowManager()
|
|
45
|
+
|
|
46
|
+
# List all visible windows
|
|
47
|
+
windows = manager.get_all_windows(visible_only=True)
|
|
48
|
+
for window in windows:
|
|
49
|
+
print(window)
|
|
50
|
+
|
|
51
|
+
# Find a window
|
|
52
|
+
notepad = manager.find_windows(title="Notepad")[0]
|
|
53
|
+
|
|
54
|
+
# Minimize
|
|
55
|
+
manager.minimize_window(notepad.handle)
|
|
56
|
+
|
|
57
|
+
# Restore
|
|
58
|
+
manager.restore_window(notepad.handle)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Monitoring Example
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
import time
|
|
65
|
+
from window_manager import WindowMonitor
|
|
66
|
+
|
|
67
|
+
def callback(event, hwnd, title):
|
|
68
|
+
print(f"Event: {event}, Window: {title}")
|
|
69
|
+
|
|
70
|
+
monitor = WindowMonitor(callback=callback)
|
|
71
|
+
monitor.start()
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
while True:
|
|
75
|
+
time.sleep(1)
|
|
76
|
+
except KeyboardInterrupt:
|
|
77
|
+
monitor.stop()
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Running Tests
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
uv run pytest tests
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Documentation
|
|
87
|
+
|
|
88
|
+
See [docs/API.md](docs/API.md) for detailed API documentation.
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# Windows Window Manager
|
|
2
|
+
|
|
3
|
+
A Python library for managing Windows windows using `win32gui` and `ctypes`. This project provides a robust interface for identifying, controlling, and monitoring windows on Windows systems.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Window Identification**: Get detailed information about all open windows (Handle, Title, Class Name, PID, Process Name).
|
|
8
|
+
- **Window Search**: Find windows by title or class name with exact or partial matching.
|
|
9
|
+
- **Window Control**:
|
|
10
|
+
- Minimize, Maximize, Restore
|
|
11
|
+
- Close
|
|
12
|
+
- Move and Resize
|
|
13
|
+
- Set to Foreground
|
|
14
|
+
- **Real-time Monitoring**: Monitor window creation, destruction, and state changes.
|
|
15
|
+
- **Robust Error Handling**: Handles permissions and invalid handles gracefully.
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
This project uses `uv` for dependency management.
|
|
20
|
+
|
|
21
|
+
1. Clone the repository.
|
|
22
|
+
2. Install dependencies:
|
|
23
|
+
```bash
|
|
24
|
+
uv sync
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
|
|
29
|
+
### Basic Example
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from window_manager import WindowManager
|
|
33
|
+
|
|
34
|
+
manager = WindowManager()
|
|
35
|
+
|
|
36
|
+
# List all visible windows
|
|
37
|
+
windows = manager.get_all_windows(visible_only=True)
|
|
38
|
+
for window in windows:
|
|
39
|
+
print(window)
|
|
40
|
+
|
|
41
|
+
# Find a window
|
|
42
|
+
notepad = manager.find_windows(title="Notepad")[0]
|
|
43
|
+
|
|
44
|
+
# Minimize
|
|
45
|
+
manager.minimize_window(notepad.handle)
|
|
46
|
+
|
|
47
|
+
# Restore
|
|
48
|
+
manager.restore_window(notepad.handle)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Monitoring Example
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
import time
|
|
55
|
+
from window_manager import WindowMonitor
|
|
56
|
+
|
|
57
|
+
def callback(event, hwnd, title):
|
|
58
|
+
print(f"Event: {event}, Window: {title}")
|
|
59
|
+
|
|
60
|
+
monitor = WindowMonitor(callback=callback)
|
|
61
|
+
monitor.start()
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
while True:
|
|
65
|
+
time.sleep(1)
|
|
66
|
+
except KeyboardInterrupt:
|
|
67
|
+
monitor.stop()
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Running Tests
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
uv run pytest tests
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Documentation
|
|
77
|
+
|
|
78
|
+
See [docs/API.md](docs/API.md) for detailed API documentation.
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# API Reference
|
|
2
|
+
|
|
3
|
+
## WindowManager
|
|
4
|
+
|
|
5
|
+
The core class for managing Windows windows.
|
|
6
|
+
|
|
7
|
+
### `get_all_windows(visible_only: bool = True) -> List[WindowInfo]`
|
|
8
|
+
|
|
9
|
+
Retrieves all open windows.
|
|
10
|
+
|
|
11
|
+
- **Parameters**:
|
|
12
|
+
- `visible_only` (bool): If `True`, only returns visible windows. Defaults to `True`.
|
|
13
|
+
- **Returns**:
|
|
14
|
+
- A list of `WindowInfo` objects.
|
|
15
|
+
|
|
16
|
+
### `find_windows(title: Optional[str] = None, class_name: Optional[str] = None, exact_match: bool = False) -> List[WindowInfo]`
|
|
17
|
+
|
|
18
|
+
Finds windows matching the given criteria.
|
|
19
|
+
|
|
20
|
+
- **Parameters**:
|
|
21
|
+
- `title` (str, optional): The window title to search for.
|
|
22
|
+
- `class_name` (str, optional): The window class name to search for.
|
|
23
|
+
- `exact_match` (bool): If `True`, requires exact string match. Otherwise, checks for containment. Defaults to `False`.
|
|
24
|
+
- **Returns**:
|
|
25
|
+
- A list of matching `WindowInfo` objects.
|
|
26
|
+
|
|
27
|
+
### `get_window_by_handle(handle: int) -> Optional[WindowInfo]`
|
|
28
|
+
|
|
29
|
+
Retrieves window information by handle.
|
|
30
|
+
|
|
31
|
+
- **Parameters**:
|
|
32
|
+
- `handle` (int): The window handle.
|
|
33
|
+
- **Returns**:
|
|
34
|
+
- A `WindowInfo` object if found, otherwise `None`.
|
|
35
|
+
|
|
36
|
+
### Window Controls
|
|
37
|
+
|
|
38
|
+
- `close_window(handle: int) -> bool`: Closes the window.
|
|
39
|
+
- `minimize_window(handle: int) -> bool`: Minimizes the window.
|
|
40
|
+
- `maximize_window(handle: int) -> bool`: Maximizes the window.
|
|
41
|
+
- `restore_window(handle: int) -> bool`: Restores the window.
|
|
42
|
+
- `move_window(handle: int, x: int, y: int, width: int, height: int) -> bool`: Moves and resizes the window.
|
|
43
|
+
- `set_foreground(handle: int) -> bool`: Brings the window to the foreground.
|
|
44
|
+
|
|
45
|
+
## WindowInfo
|
|
46
|
+
|
|
47
|
+
A dataclass representing window information.
|
|
48
|
+
|
|
49
|
+
- **Attributes**:
|
|
50
|
+
- `handle` (int): The window handle (HWND).
|
|
51
|
+
- `title` (str): The window title.
|
|
52
|
+
- `class_name` (str): The window class name.
|
|
53
|
+
- `pid` (int): The process ID associated with the window.
|
|
54
|
+
- `process_name` (str, optional): The name of the process.
|
|
55
|
+
- `rect` (tuple, optional): The window rectangle (left, top, right, bottom).
|
|
56
|
+
- `is_visible` (bool): Whether the window is visible.
|
|
57
|
+
|
|
58
|
+
## WindowMonitor
|
|
59
|
+
|
|
60
|
+
Monitors window events such as creation, destruction, and state changes.
|
|
61
|
+
|
|
62
|
+
### `__init__(callback: Optional[Callable[[str, int, str], None]] = None)`
|
|
63
|
+
|
|
64
|
+
Initializes the monitor.
|
|
65
|
+
|
|
66
|
+
- **Parameters**:
|
|
67
|
+
- `callback`: A function to call when an event occurs. Signature: `callback(event_name, hwnd, title)`.
|
|
68
|
+
|
|
69
|
+
### `start()`
|
|
70
|
+
|
|
71
|
+
Starts the monitoring thread.
|
|
72
|
+
|
|
73
|
+
### `stop()`
|
|
74
|
+
|
|
75
|
+
Stops the monitoring thread.
|
|
76
|
+
|
|
77
|
+
## Events
|
|
78
|
+
|
|
79
|
+
The following events are monitored:
|
|
80
|
+
- `Create`: Window created.
|
|
81
|
+
- `Destroy`: Window destroyed.
|
|
82
|
+
- `Show`: Window shown.
|
|
83
|
+
- `Hide`: Window hidden.
|
|
84
|
+
- `Foreground`: Window became foreground.
|
|
85
|
+
- `NameChange`: Window title changed.
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import time
|
|
3
|
+
import subprocess
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
# Add src to python path
|
|
7
|
+
sys.path.append(os.path.join(os.path.dirname(__file__), "../src"))
|
|
8
|
+
|
|
9
|
+
from window_manager import WindowManager, WindowMonitor, setup_logger
|
|
10
|
+
|
|
11
|
+
# Setup logging
|
|
12
|
+
logger = setup_logger("Demo", level=10) # DEBUG
|
|
13
|
+
|
|
14
|
+
def monitor_callback(event, hwnd, title):
|
|
15
|
+
print(f"[MONITOR] Event: {event}, HWND: {hwnd}, Title: '{title}'")
|
|
16
|
+
|
|
17
|
+
def main():
|
|
18
|
+
manager = WindowManager()
|
|
19
|
+
monitor = WindowMonitor(callback=monitor_callback)
|
|
20
|
+
|
|
21
|
+
print("Starting Window Monitor...")
|
|
22
|
+
monitor.start()
|
|
23
|
+
|
|
24
|
+
# List all windows
|
|
25
|
+
print("\nListing all visible windows:")
|
|
26
|
+
windows = manager.get_all_windows(visible_only=True)
|
|
27
|
+
for w in windows[:5]: # Show first 5
|
|
28
|
+
print(f" {w}")
|
|
29
|
+
print(f"Total visible windows: {len(windows)}")
|
|
30
|
+
|
|
31
|
+
# Create a test window (Notepad)
|
|
32
|
+
print("\nLaunching Notepad for testing...")
|
|
33
|
+
notepad_process = subprocess.Popen("notepad.exe")
|
|
34
|
+
time.sleep(2) # Wait for window to appear
|
|
35
|
+
|
|
36
|
+
# Find Notepad window
|
|
37
|
+
print("\nFinding Notepad window...")
|
|
38
|
+
notepad_windows = manager.find_windows(title="Notepad", exact_match=False)
|
|
39
|
+
|
|
40
|
+
if not notepad_windows:
|
|
41
|
+
print("Could not find Notepad window!")
|
|
42
|
+
monitor.stop()
|
|
43
|
+
notepad_process.terminate()
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
target_window = notepad_windows[0]
|
|
47
|
+
print(f"Found: {target_window}")
|
|
48
|
+
|
|
49
|
+
hwnd = target_window.handle
|
|
50
|
+
|
|
51
|
+
# Test manipulations
|
|
52
|
+
print("\nTesting manipulations:")
|
|
53
|
+
|
|
54
|
+
print(" Minimizing...")
|
|
55
|
+
manager.minimize_window(hwnd)
|
|
56
|
+
time.sleep(1)
|
|
57
|
+
|
|
58
|
+
print(" Restoring...")
|
|
59
|
+
manager.restore_window(hwnd)
|
|
60
|
+
time.sleep(1)
|
|
61
|
+
|
|
62
|
+
print(" Maximizing...")
|
|
63
|
+
manager.maximize_window(hwnd)
|
|
64
|
+
time.sleep(1)
|
|
65
|
+
|
|
66
|
+
print(" Restoring...")
|
|
67
|
+
manager.restore_window(hwnd)
|
|
68
|
+
time.sleep(1)
|
|
69
|
+
|
|
70
|
+
print(" Moving to (100, 100) with size 800x600...")
|
|
71
|
+
manager.move_window(hwnd, 100, 100, 800, 600)
|
|
72
|
+
time.sleep(1)
|
|
73
|
+
|
|
74
|
+
# Verify monitoring caught these events
|
|
75
|
+
print("\nWaiting for events to be logged...")
|
|
76
|
+
time.sleep(2)
|
|
77
|
+
|
|
78
|
+
# Close Notepad
|
|
79
|
+
print("\nClosing Notepad...")
|
|
80
|
+
manager.close_window(hwnd)
|
|
81
|
+
time.sleep(1)
|
|
82
|
+
|
|
83
|
+
# Stop monitor
|
|
84
|
+
print("\nStopping Monitor...")
|
|
85
|
+
monitor.stop()
|
|
86
|
+
|
|
87
|
+
# Ensure process is gone
|
|
88
|
+
if notepad_process.poll() is None:
|
|
89
|
+
notepad_process.terminate()
|
|
90
|
+
|
|
91
|
+
print("\nDemo completed.")
|
|
92
|
+
|
|
93
|
+
if __name__ == "__main__":
|
|
94
|
+
main()
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "python-win-windows-manager"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Window manager and monitor for Windows"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.13"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"psutil>=7.2.2",
|
|
9
|
+
"pywin32>=311",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
[dependency-groups]
|
|
13
|
+
dev = [
|
|
14
|
+
"pytest>=9.0.2",
|
|
15
|
+
]
|
|
16
|
+
[project.urls]
|
|
17
|
+
"Homepage" = "https://github.com/username/repo"
|
|
18
|
+
|
|
19
|
+
[build-system]
|
|
20
|
+
requires = ["hatchling"]
|
|
21
|
+
build-backend = "hatchling.build"
|
|
22
|
+
|
|
23
|
+
[tool.hatch.build.targets.wheel]
|
|
24
|
+
packages = ["src/window_manager"]
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import win32gui
|
|
3
|
+
import win32process
|
|
4
|
+
import win32con
|
|
5
|
+
import psutil
|
|
6
|
+
from typing import List, Optional, Tuple, Dict, Any
|
|
7
|
+
from .models import WindowInfo
|
|
8
|
+
from .utils import setup_logger
|
|
9
|
+
|
|
10
|
+
logger = setup_logger()
|
|
11
|
+
|
|
12
|
+
class WindowManagerError(Exception):
|
|
13
|
+
"""Base exception for WindowManager errors."""
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
class WindowManager:
|
|
17
|
+
"""
|
|
18
|
+
Core class for managing Windows windows.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self):
|
|
22
|
+
"""Initializes the WindowManager."""
|
|
23
|
+
self._logger = logger
|
|
24
|
+
|
|
25
|
+
def get_all_windows(self, visible_only: bool = True) -> List[WindowInfo]:
|
|
26
|
+
"""
|
|
27
|
+
Retrieves all open windows.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
visible_only: If True, only returns visible windows.
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
A list of WindowInfo objects.
|
|
34
|
+
"""
|
|
35
|
+
windows: List[WindowInfo] = []
|
|
36
|
+
|
|
37
|
+
def enum_handler(hwnd, ctx):
|
|
38
|
+
if visible_only and not win32gui.IsWindowVisible(hwnd):
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
title = win32gui.GetWindowText(hwnd)
|
|
43
|
+
class_name = win32gui.GetClassName(hwnd)
|
|
44
|
+
|
|
45
|
+
# Get process ID
|
|
46
|
+
_, pid = win32process.GetWindowThreadProcessId(hwnd)
|
|
47
|
+
|
|
48
|
+
# Get process name
|
|
49
|
+
try:
|
|
50
|
+
process = psutil.Process(pid)
|
|
51
|
+
process_name = process.name()
|
|
52
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
|
|
53
|
+
process_name = "Unknown"
|
|
54
|
+
|
|
55
|
+
# Get window rect
|
|
56
|
+
try:
|
|
57
|
+
rect = win32gui.GetWindowRect(hwnd)
|
|
58
|
+
except Exception:
|
|
59
|
+
rect = None
|
|
60
|
+
|
|
61
|
+
window_info = WindowInfo(
|
|
62
|
+
handle=hwnd,
|
|
63
|
+
title=title,
|
|
64
|
+
class_name=class_name,
|
|
65
|
+
pid=pid,
|
|
66
|
+
process_name=process_name,
|
|
67
|
+
rect=rect,
|
|
68
|
+
is_visible=win32gui.IsWindowVisible(hwnd)
|
|
69
|
+
)
|
|
70
|
+
windows.append(window_info)
|
|
71
|
+
except Exception as e:
|
|
72
|
+
self._logger.warning(f"Error processing window {hwnd}: {e}")
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
win32gui.EnumWindows(enum_handler, None)
|
|
76
|
+
except Exception as e:
|
|
77
|
+
self._logger.error(f"Failed to enumerate windows: {e}")
|
|
78
|
+
raise WindowManagerError(f"Failed to enumerate windows: {e}")
|
|
79
|
+
|
|
80
|
+
return windows
|
|
81
|
+
|
|
82
|
+
def find_windows(self, title: Optional[str] = None, class_name: Optional[str] = None, process_name: Optional[str] = None, exact_match: bool = False) -> List[WindowInfo]:
|
|
83
|
+
"""
|
|
84
|
+
Finds windows matching the given criteria.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
title: The window title to search for.
|
|
88
|
+
class_name: The window class name to search for.
|
|
89
|
+
exact_match: If True, requires exact string match. Otherwise, checks for containment.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
A list of matching WindowInfo objects.
|
|
93
|
+
"""
|
|
94
|
+
all_windows = self.get_all_windows(visible_only=False)
|
|
95
|
+
matched_windows = []
|
|
96
|
+
|
|
97
|
+
for window in all_windows:
|
|
98
|
+
match = True
|
|
99
|
+
|
|
100
|
+
if title:
|
|
101
|
+
if exact_match:
|
|
102
|
+
if window.title != title:
|
|
103
|
+
match = False
|
|
104
|
+
else:
|
|
105
|
+
if title.lower() not in window.title.lower():
|
|
106
|
+
match = False
|
|
107
|
+
|
|
108
|
+
if match and class_name:
|
|
109
|
+
if exact_match:
|
|
110
|
+
if window.class_name != class_name:
|
|
111
|
+
match = False
|
|
112
|
+
else:
|
|
113
|
+
if class_name.lower() not in window.class_name.lower():
|
|
114
|
+
match = False
|
|
115
|
+
|
|
116
|
+
if match and process_name:
|
|
117
|
+
if window.process_name is None or window.process_name.lower() != process_name.lower():
|
|
118
|
+
match = False
|
|
119
|
+
|
|
120
|
+
if match and (title or class_name or process_name):
|
|
121
|
+
matched_windows.append(window)
|
|
122
|
+
|
|
123
|
+
return matched_windows
|
|
124
|
+
|
|
125
|
+
def get_window_by_handle(self, handle: int) -> Optional[WindowInfo]:
|
|
126
|
+
"""
|
|
127
|
+
Retrieves window information by handle.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
handle: The window handle.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
A WindowInfo object if found, otherwise None.
|
|
134
|
+
"""
|
|
135
|
+
if not win32gui.IsWindow(handle):
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
title = win32gui.GetWindowText(handle)
|
|
140
|
+
class_name = win32gui.GetClassName(handle)
|
|
141
|
+
_, pid = win32process.GetWindowThreadProcessId(handle)
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
process = psutil.Process(pid)
|
|
145
|
+
process_name = process.name()
|
|
146
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
|
|
147
|
+
process_name = "Unknown"
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
rect = win32gui.GetWindowRect(handle)
|
|
151
|
+
except Exception:
|
|
152
|
+
rect = None
|
|
153
|
+
|
|
154
|
+
return WindowInfo(
|
|
155
|
+
handle=handle,
|
|
156
|
+
title=title,
|
|
157
|
+
class_name=class_name,
|
|
158
|
+
pid=pid,
|
|
159
|
+
process_name=process_name,
|
|
160
|
+
rect=rect,
|
|
161
|
+
is_visible=win32gui.IsWindowVisible(handle)
|
|
162
|
+
)
|
|
163
|
+
except Exception as e:
|
|
164
|
+
self._logger.error(f"Error getting window info for handle {handle}: {e}")
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
def close_window(self, handle: int) -> bool:
|
|
168
|
+
"""Closes the window with the given handle."""
|
|
169
|
+
try:
|
|
170
|
+
win32gui.PostMessage(handle, win32con.WM_CLOSE, 0, 0)
|
|
171
|
+
self._logger.info(f"Closed window {handle}")
|
|
172
|
+
return True
|
|
173
|
+
except Exception as e:
|
|
174
|
+
self._logger.error(f"Failed to close window {handle}: {e}")
|
|
175
|
+
return False
|
|
176
|
+
|
|
177
|
+
def minimize_window(self, handle: int) -> bool:
|
|
178
|
+
"""Minimizes the window."""
|
|
179
|
+
try:
|
|
180
|
+
win32gui.ShowWindow(handle, win32con.SW_MINIMIZE)
|
|
181
|
+
self._logger.info(f"Minimized window {handle}")
|
|
182
|
+
return True
|
|
183
|
+
except Exception as e:
|
|
184
|
+
self._logger.error(f"Failed to minimize window {handle}: {e}")
|
|
185
|
+
return False
|
|
186
|
+
|
|
187
|
+
def maximize_window(self, handle: int) -> bool:
|
|
188
|
+
"""Maximizes the window."""
|
|
189
|
+
try:
|
|
190
|
+
win32gui.ShowWindow(handle, win32con.SW_MAXIMIZE)
|
|
191
|
+
self._logger.info(f"Maximized window {handle}")
|
|
192
|
+
return True
|
|
193
|
+
except Exception as e:
|
|
194
|
+
self._logger.error(f"Failed to maximize window {handle}: {e}")
|
|
195
|
+
return False
|
|
196
|
+
|
|
197
|
+
def restore_window(self, handle: int) -> bool:
|
|
198
|
+
"""Restores the window."""
|
|
199
|
+
try:
|
|
200
|
+
win32gui.ShowWindow(handle, win32con.SW_RESTORE)
|
|
201
|
+
self._logger.info(f"Restored window {handle}")
|
|
202
|
+
return True
|
|
203
|
+
except Exception as e:
|
|
204
|
+
self._logger.error(f"Failed to restore window {handle}: {e}")
|
|
205
|
+
return False
|
|
206
|
+
|
|
207
|
+
def move_window(self, handle: int, x: int, y: int, width: int, height: int) -> bool:
|
|
208
|
+
"""Moves and resizes the window."""
|
|
209
|
+
try:
|
|
210
|
+
win32gui.MoveWindow(handle, x, y, width, height, True)
|
|
211
|
+
self._logger.info(f"Moved window {handle} to ({x}, {y}, {width}, {height})")
|
|
212
|
+
return True
|
|
213
|
+
except Exception as e:
|
|
214
|
+
self._logger.error(f"Failed to move window {handle}: {e}")
|
|
215
|
+
return False
|
|
216
|
+
|
|
217
|
+
def set_foreground(self, handle: int) -> bool:
|
|
218
|
+
"""Brings the window to the foreground."""
|
|
219
|
+
try:
|
|
220
|
+
# Sometimes setting foreground requires specific conditions or tricks
|
|
221
|
+
# like attaching thread input if the foreground lock timeout is set.
|
|
222
|
+
# For simplicity, we just try SetForegroundWindow.
|
|
223
|
+
win32gui.SetForegroundWindow(handle)
|
|
224
|
+
self._logger.info(f"Set window {handle} to foreground")
|
|
225
|
+
return True
|
|
226
|
+
except Exception as e:
|
|
227
|
+
self._logger.error(f"Failed to set foreground window {handle}: {e}")
|
|
228
|
+
return False
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
@dataclass
|
|
5
|
+
class WindowInfo:
|
|
6
|
+
"""
|
|
7
|
+
Represents information about a window.
|
|
8
|
+
"""
|
|
9
|
+
handle: int
|
|
10
|
+
title: str
|
|
11
|
+
class_name: str
|
|
12
|
+
pid: int
|
|
13
|
+
process_name: Optional[str] = None
|
|
14
|
+
rect: Optional[tuple[int, int, int, int]] = None # (left, top, right, bottom)
|
|
15
|
+
is_visible: bool = False
|
|
16
|
+
|
|
17
|
+
def __str__(self):
|
|
18
|
+
return (f"Window(handle={self.handle}, title='{self.title}', "
|
|
19
|
+
f"class='{self.class_name}', pid={self.pid}, "
|
|
20
|
+
f"process='{self.process_name}')")
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import ctypes
|
|
2
|
+
from ctypes import wintypes
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
import win32con
|
|
6
|
+
import win32api
|
|
7
|
+
import win32gui
|
|
8
|
+
from typing import Callable, Optional
|
|
9
|
+
from .utils import setup_logger
|
|
10
|
+
|
|
11
|
+
logger = setup_logger("WindowMonitor")
|
|
12
|
+
|
|
13
|
+
# Constants for SetWinEventHook
|
|
14
|
+
EVENT_MIN = 0x00000001
|
|
15
|
+
EVENT_MAX = 0x7FFFFFFF
|
|
16
|
+
EVENT_SYSTEM_FOREGROUND = 0x0003
|
|
17
|
+
EVENT_OBJECT_CREATE = 0x8000
|
|
18
|
+
EVENT_OBJECT_DESTROY = 0x8001
|
|
19
|
+
EVENT_OBJECT_SHOW = 0x8002
|
|
20
|
+
EVENT_OBJECT_HIDE = 0x8003
|
|
21
|
+
EVENT_OBJECT_NAMECHANGE = 0x800C
|
|
22
|
+
WINEVENT_OUTOFCONTEXT = 0x0000
|
|
23
|
+
|
|
24
|
+
# Callback function type
|
|
25
|
+
WinEventProcType = ctypes.WINFUNCTYPE(
|
|
26
|
+
None,
|
|
27
|
+
wintypes.HANDLE,
|
|
28
|
+
wintypes.DWORD,
|
|
29
|
+
wintypes.HWND,
|
|
30
|
+
wintypes.LONG,
|
|
31
|
+
wintypes.LONG,
|
|
32
|
+
wintypes.DWORD,
|
|
33
|
+
wintypes.DWORD
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
user32 = ctypes.windll.user32
|
|
37
|
+
|
|
38
|
+
class WindowMonitor:
|
|
39
|
+
"""
|
|
40
|
+
Monitors window events such as creation, destruction, and state changes.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(self, callback: Optional[Callable[[str, int, int], None]] = None):
|
|
44
|
+
"""
|
|
45
|
+
Initializes the WindowMonitor.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
callback: A function to call when an event occurs.
|
|
49
|
+
Signature: callback(event_name, hwnd, id_object)
|
|
50
|
+
"""
|
|
51
|
+
self._callback = callback
|
|
52
|
+
self._hook = None
|
|
53
|
+
self._thread = None
|
|
54
|
+
self._stop_event = threading.Event()
|
|
55
|
+
self._logger = logger
|
|
56
|
+
|
|
57
|
+
def _event_handler(self, hWinEventHook, event, hwnd, idObject, idChild, dwEventThread, dwmsEventTime):
|
|
58
|
+
"""
|
|
59
|
+
Internal callback for SetWinEventHook.
|
|
60
|
+
"""
|
|
61
|
+
if idObject != 0: # OBJID_WINDOW = 0
|
|
62
|
+
return
|
|
63
|
+
|
|
64
|
+
event_name = "Unknown"
|
|
65
|
+
if event == EVENT_OBJECT_CREATE:
|
|
66
|
+
event_name = "Create"
|
|
67
|
+
elif event == EVENT_OBJECT_DESTROY:
|
|
68
|
+
event_name = "Destroy"
|
|
69
|
+
elif event == EVENT_OBJECT_SHOW:
|
|
70
|
+
event_name = "Show"
|
|
71
|
+
elif event == EVENT_OBJECT_HIDE:
|
|
72
|
+
event_name = "Hide"
|
|
73
|
+
elif event == EVENT_SYSTEM_FOREGROUND:
|
|
74
|
+
event_name = "Foreground"
|
|
75
|
+
elif event == EVENT_OBJECT_NAMECHANGE:
|
|
76
|
+
event_name = "NameChange"
|
|
77
|
+
|
|
78
|
+
# Only log/callback for interesting events on actual windows
|
|
79
|
+
# Filter out some noise if necessary, but for now report all OBJID_WINDOW
|
|
80
|
+
|
|
81
|
+
# We can try to get window title here, but be careful as the window might be destroying
|
|
82
|
+
title = ""
|
|
83
|
+
try:
|
|
84
|
+
if event != EVENT_OBJECT_DESTROY:
|
|
85
|
+
# win32gui.GetWindowText might fail if window is gone
|
|
86
|
+
length = user32.GetWindowTextLengthW(hwnd)
|
|
87
|
+
buff = ctypes.create_unicode_buffer(length + 1)
|
|
88
|
+
user32.GetWindowTextW(hwnd, buff, length + 1)
|
|
89
|
+
title = buff.value
|
|
90
|
+
except Exception:
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
log_msg = f"Event: {event_name}, HWND: {hwnd}, Title: {title}"
|
|
94
|
+
self._logger.info(log_msg)
|
|
95
|
+
|
|
96
|
+
if self._callback:
|
|
97
|
+
try:
|
|
98
|
+
self._callback(event_name, hwnd, title)
|
|
99
|
+
except Exception as e:
|
|
100
|
+
self._logger.error(f"Error in user callback: {e}")
|
|
101
|
+
|
|
102
|
+
def _run(self):
|
|
103
|
+
"""
|
|
104
|
+
The thread function that runs the message loop.
|
|
105
|
+
"""
|
|
106
|
+
self._logger.info("Starting monitor thread...")
|
|
107
|
+
|
|
108
|
+
# Keep reference to the callback to prevent garbage collection
|
|
109
|
+
self._c_callback = WinEventProcType(self._event_handler)
|
|
110
|
+
|
|
111
|
+
# Set hook
|
|
112
|
+
self._hook = user32.SetWinEventHook(
|
|
113
|
+
EVENT_MIN,
|
|
114
|
+
EVENT_MAX,
|
|
115
|
+
0,
|
|
116
|
+
self._c_callback,
|
|
117
|
+
0,
|
|
118
|
+
0,
|
|
119
|
+
WINEVENT_OUTOFCONTEXT
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
if not self._hook:
|
|
123
|
+
self._logger.error("Failed to set win event hook")
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
# Message loop
|
|
127
|
+
msg = wintypes.MSG()
|
|
128
|
+
while not self._stop_event.is_set():
|
|
129
|
+
# PeekMessage is non-blocking, so we can check stop_event
|
|
130
|
+
# But SetWinEventHook needs a message pump.
|
|
131
|
+
# GetMessage blocks.
|
|
132
|
+
# We can use MsgWaitForMultipleObjects or just PeekMessage with a sleep.
|
|
133
|
+
|
|
134
|
+
if user32.PeekMessageW(ctypes.byref(msg), 0, 0, 0, 1): # PM_REMOVE = 1
|
|
135
|
+
if msg.message == win32con.WM_QUIT:
|
|
136
|
+
break
|
|
137
|
+
user32.TranslateMessage(ctypes.byref(msg))
|
|
138
|
+
user32.DispatchMessageW(ctypes.byref(msg))
|
|
139
|
+
else:
|
|
140
|
+
time.sleep(0.01) # Small sleep to avoid CPU spin
|
|
141
|
+
|
|
142
|
+
# Unhook
|
|
143
|
+
user32.UnhookWinEvent(self._hook)
|
|
144
|
+
self._logger.info("Monitor thread stopped.")
|
|
145
|
+
|
|
146
|
+
def start(self):
|
|
147
|
+
"""Starts the monitoring thread."""
|
|
148
|
+
if self._thread and self._thread.is_alive():
|
|
149
|
+
self._logger.warning("Monitor already running")
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
self._stop_event.clear()
|
|
153
|
+
self._thread = threading.Thread(target=self._run, daemon=True)
|
|
154
|
+
self._thread.start()
|
|
155
|
+
|
|
156
|
+
def stop(self):
|
|
157
|
+
"""Stops the monitoring thread."""
|
|
158
|
+
if not self._thread or not self._thread.is_alive():
|
|
159
|
+
return
|
|
160
|
+
|
|
161
|
+
self._stop_event.set()
|
|
162
|
+
self._thread.join(timeout=2)
|
|
163
|
+
if self._thread.is_alive():
|
|
164
|
+
self._logger.warning("Monitor thread did not stop gracefully")
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import sys
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
def setup_logger(name: str = "WindowManager", level: int = logging.INFO, log_file: Optional[str] = None) -> logging.Logger:
|
|
6
|
+
"""
|
|
7
|
+
Sets up a logger with the specified name, level, and optional file output.
|
|
8
|
+
|
|
9
|
+
Args:
|
|
10
|
+
name: The name of the logger.
|
|
11
|
+
level: The logging level.
|
|
12
|
+
log_file: Optional path to a log file.
|
|
13
|
+
|
|
14
|
+
Returns:
|
|
15
|
+
A configured logging.Logger instance.
|
|
16
|
+
"""
|
|
17
|
+
logger = logging.getLogger(name)
|
|
18
|
+
logger.setLevel(level)
|
|
19
|
+
|
|
20
|
+
if logger.hasHandlers():
|
|
21
|
+
return logger
|
|
22
|
+
|
|
23
|
+
formatter = logging.Formatter(
|
|
24
|
+
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# Console handler
|
|
28
|
+
console_handler = logging.StreamHandler(sys.stdout)
|
|
29
|
+
console_handler.setFormatter(formatter)
|
|
30
|
+
logger.addHandler(console_handler)
|
|
31
|
+
|
|
32
|
+
# File handler
|
|
33
|
+
if log_file:
|
|
34
|
+
file_handler = logging.FileHandler(log_file, encoding='utf-8')
|
|
35
|
+
file_handler.setFormatter(formatter)
|
|
36
|
+
logger.addHandler(file_handler)
|
|
37
|
+
|
|
38
|
+
return logger
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from unittest.mock import MagicMock, patch
|
|
3
|
+
from window_manager import WindowManager, WindowInfo
|
|
4
|
+
|
|
5
|
+
@pytest.fixture
|
|
6
|
+
def mock_win32gui():
|
|
7
|
+
with patch("window_manager.core.win32gui") as mock:
|
|
8
|
+
yield mock
|
|
9
|
+
|
|
10
|
+
@pytest.fixture
|
|
11
|
+
def mock_win32process():
|
|
12
|
+
with patch("window_manager.core.win32process") as mock:
|
|
13
|
+
yield mock
|
|
14
|
+
|
|
15
|
+
@pytest.fixture
|
|
16
|
+
def mock_psutil():
|
|
17
|
+
with patch("window_manager.core.psutil") as mock:
|
|
18
|
+
yield mock
|
|
19
|
+
|
|
20
|
+
@pytest.fixture
|
|
21
|
+
def manager():
|
|
22
|
+
return WindowManager()
|
|
23
|
+
|
|
24
|
+
def test_get_all_windows(manager, mock_win32gui, mock_win32process, mock_psutil):
|
|
25
|
+
# Setup mock data
|
|
26
|
+
mock_win32gui.IsWindowVisible.return_value = True
|
|
27
|
+
mock_win32gui.GetWindowText.return_value = "Test Window"
|
|
28
|
+
mock_win32gui.GetClassName.return_value = "TestClass"
|
|
29
|
+
mock_win32gui.GetWindowRect.return_value = (0, 0, 100, 100)
|
|
30
|
+
mock_win32process.GetWindowThreadProcessId.return_value = (0, 1234)
|
|
31
|
+
|
|
32
|
+
mock_process = MagicMock()
|
|
33
|
+
mock_process.name.return_value = "test.exe"
|
|
34
|
+
mock_psutil.Process.return_value = mock_process
|
|
35
|
+
|
|
36
|
+
# Simulate EnumWindows calling the callback once
|
|
37
|
+
def side_effect(callback, ctx):
|
|
38
|
+
callback(1, ctx)
|
|
39
|
+
mock_win32gui.EnumWindows.side_effect = side_effect
|
|
40
|
+
|
|
41
|
+
windows = manager.get_all_windows()
|
|
42
|
+
|
|
43
|
+
assert len(windows) == 1
|
|
44
|
+
assert windows[0].handle == 1
|
|
45
|
+
assert windows[0].title == "Test Window"
|
|
46
|
+
assert windows[0].class_name == "TestClass"
|
|
47
|
+
assert windows[0].pid == 1234
|
|
48
|
+
assert windows[0].process_name == "test.exe"
|
|
49
|
+
|
|
50
|
+
def test_find_windows(manager, mock_win32gui, mock_win32process, mock_psutil):
|
|
51
|
+
# Setup mock data
|
|
52
|
+
mock_win32gui.IsWindowVisible.return_value = True
|
|
53
|
+
|
|
54
|
+
def get_text(hwnd):
|
|
55
|
+
if hwnd == 1: return "Target Window"
|
|
56
|
+
if hwnd == 2: return "Other Window"
|
|
57
|
+
return ""
|
|
58
|
+
mock_win32gui.GetWindowText.side_effect = get_text
|
|
59
|
+
|
|
60
|
+
mock_win32gui.GetClassName.return_value = "TestClass"
|
|
61
|
+
mock_win32process.GetWindowThreadProcessId.return_value = (0, 1234)
|
|
62
|
+
|
|
63
|
+
mock_process = MagicMock()
|
|
64
|
+
mock_process.name.return_value = "test.exe"
|
|
65
|
+
mock_psutil.Process.return_value = mock_process
|
|
66
|
+
|
|
67
|
+
def side_effect(callback, ctx):
|
|
68
|
+
callback(1, ctx)
|
|
69
|
+
callback(2, ctx)
|
|
70
|
+
mock_win32gui.EnumWindows.side_effect = side_effect
|
|
71
|
+
|
|
72
|
+
# Test find by title
|
|
73
|
+
found = manager.find_windows(title="Target")
|
|
74
|
+
assert len(found) == 1
|
|
75
|
+
assert found[0].title == "Target Window"
|
|
76
|
+
|
|
77
|
+
# Test find by exact title
|
|
78
|
+
found = manager.find_windows(title="Target", exact_match=True)
|
|
79
|
+
assert len(found) == 0
|
|
80
|
+
|
|
81
|
+
found = manager.find_windows(title="Target Window", exact_match=True)
|
|
82
|
+
assert len(found) == 1
|
|
83
|
+
|
|
84
|
+
# Test find by process_name
|
|
85
|
+
found = manager.find_windows(process_name="test.exe")
|
|
86
|
+
assert len(found) == 2 # Both windows have same PID in this mock
|
|
87
|
+
|
|
88
|
+
found = manager.find_windows(process_name="other.exe")
|
|
89
|
+
assert len(found) == 0
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_window_controls(manager, mock_win32gui):
|
|
93
|
+
handle = 12345
|
|
94
|
+
|
|
95
|
+
# Close
|
|
96
|
+
manager.close_window(handle)
|
|
97
|
+
mock_win32gui.PostMessage.assert_called_with(handle, 16, 0, 0) # WM_CLOSE = 16 (0x10)
|
|
98
|
+
|
|
99
|
+
# Minimize
|
|
100
|
+
manager.minimize_window(handle)
|
|
101
|
+
mock_win32gui.ShowWindow.assert_called_with(handle, 6) # SW_MINIMIZE = 6
|
|
102
|
+
|
|
103
|
+
# Maximize
|
|
104
|
+
manager.maximize_window(handle)
|
|
105
|
+
mock_win32gui.ShowWindow.assert_called_with(handle, 3) # SW_MAXIMIZE = 3
|
|
106
|
+
|
|
107
|
+
# Restore
|
|
108
|
+
manager.restore_window(handle)
|
|
109
|
+
mock_win32gui.ShowWindow.assert_called_with(handle, 9) # SW_RESTORE = 9
|
|
110
|
+
|
|
111
|
+
# Move
|
|
112
|
+
manager.move_window(handle, 10, 20, 300, 200)
|
|
113
|
+
mock_win32gui.MoveWindow.assert_called_with(handle, 10, 20, 300, 200, True)
|
|
114
|
+
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from unittest.mock import MagicMock, patch
|
|
3
|
+
import time
|
|
4
|
+
from window_manager import WindowMonitor
|
|
5
|
+
|
|
6
|
+
@pytest.fixture
|
|
7
|
+
def mock_user32():
|
|
8
|
+
with patch("window_manager.monitor.user32") as mock:
|
|
9
|
+
yield mock
|
|
10
|
+
|
|
11
|
+
def test_monitor_lifecycle(mock_user32):
|
|
12
|
+
# Mock SetWinEventHook to return a fake hook handle
|
|
13
|
+
mock_user32.SetWinEventHook.return_value = 12345
|
|
14
|
+
mock_user32.UnhookWinEvent.return_value = 1
|
|
15
|
+
|
|
16
|
+
# Mock PeekMessage/GetMessage behavior to avoid infinite loop or blocking
|
|
17
|
+
# We want PeekMessage to return False initially to simulate no messages
|
|
18
|
+
# Then we want the loop to exit when stop is called.
|
|
19
|
+
# The loop condition is `while not self._stop_event.is_set():`
|
|
20
|
+
# So we just need to ensure PeekMessage doesn't block or return WM_QUIT immediately unless we want to test that.
|
|
21
|
+
|
|
22
|
+
mock_user32.PeekMessageW.return_value = 0
|
|
23
|
+
|
|
24
|
+
monitor = WindowMonitor()
|
|
25
|
+
monitor.start()
|
|
26
|
+
|
|
27
|
+
# Allow thread to start
|
|
28
|
+
time.sleep(0.1)
|
|
29
|
+
|
|
30
|
+
assert monitor._thread.is_alive()
|
|
31
|
+
assert monitor._hook == 12345
|
|
32
|
+
|
|
33
|
+
monitor.stop()
|
|
34
|
+
|
|
35
|
+
# Allow thread to stop
|
|
36
|
+
time.sleep(0.1)
|
|
37
|
+
|
|
38
|
+
assert not monitor._thread.is_alive()
|
|
39
|
+
mock_user32.UnhookWinEvent.assert_called_with(12345)
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
version = 1
|
|
2
|
+
revision = 3
|
|
3
|
+
requires-python = ">=3.13"
|
|
4
|
+
|
|
5
|
+
[[package]]
|
|
6
|
+
name = "colorama"
|
|
7
|
+
version = "0.4.6"
|
|
8
|
+
source = { registry = "https://pypi.org/simple" }
|
|
9
|
+
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
|
10
|
+
wheels = [
|
|
11
|
+
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[[package]]
|
|
15
|
+
name = "iniconfig"
|
|
16
|
+
version = "2.3.0"
|
|
17
|
+
source = { registry = "https://pypi.org/simple" }
|
|
18
|
+
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
|
19
|
+
wheels = [
|
|
20
|
+
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[[package]]
|
|
24
|
+
name = "packaging"
|
|
25
|
+
version = "26.0"
|
|
26
|
+
source = { registry = "https://pypi.org/simple" }
|
|
27
|
+
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
|
|
28
|
+
wheels = [
|
|
29
|
+
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[[package]]
|
|
33
|
+
name = "pluggy"
|
|
34
|
+
version = "1.6.0"
|
|
35
|
+
source = { registry = "https://pypi.org/simple" }
|
|
36
|
+
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
|
37
|
+
wheels = [
|
|
38
|
+
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
[[package]]
|
|
42
|
+
name = "psutil"
|
|
43
|
+
version = "7.2.2"
|
|
44
|
+
source = { registry = "https://pypi.org/simple" }
|
|
45
|
+
sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" }
|
|
46
|
+
wheels = [
|
|
47
|
+
{ url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" },
|
|
48
|
+
{ url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" },
|
|
49
|
+
{ url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" },
|
|
50
|
+
{ url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" },
|
|
51
|
+
{ url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" },
|
|
52
|
+
{ url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" },
|
|
53
|
+
{ url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" },
|
|
54
|
+
{ url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" },
|
|
55
|
+
{ url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" },
|
|
56
|
+
{ url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" },
|
|
57
|
+
{ url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" },
|
|
58
|
+
{ url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" },
|
|
59
|
+
{ url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" },
|
|
60
|
+
{ url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" },
|
|
61
|
+
{ url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" },
|
|
62
|
+
{ url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" },
|
|
63
|
+
{ url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" },
|
|
64
|
+
{ url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" },
|
|
65
|
+
{ url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" },
|
|
66
|
+
{ url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" },
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
[[package]]
|
|
70
|
+
name = "pygments"
|
|
71
|
+
version = "2.19.2"
|
|
72
|
+
source = { registry = "https://pypi.org/simple" }
|
|
73
|
+
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
|
|
74
|
+
wheels = [
|
|
75
|
+
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
[[package]]
|
|
79
|
+
name = "pytest"
|
|
80
|
+
version = "9.0.2"
|
|
81
|
+
source = { registry = "https://pypi.org/simple" }
|
|
82
|
+
dependencies = [
|
|
83
|
+
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
|
84
|
+
{ name = "iniconfig" },
|
|
85
|
+
{ name = "packaging" },
|
|
86
|
+
{ name = "pluggy" },
|
|
87
|
+
{ name = "pygments" },
|
|
88
|
+
]
|
|
89
|
+
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
|
|
90
|
+
wheels = [
|
|
91
|
+
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
[[package]]
|
|
95
|
+
name = "pywin32"
|
|
96
|
+
version = "311"
|
|
97
|
+
source = { registry = "https://pypi.org/simple" }
|
|
98
|
+
wheels = [
|
|
99
|
+
{ url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" },
|
|
100
|
+
{ url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" },
|
|
101
|
+
{ url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" },
|
|
102
|
+
{ url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" },
|
|
103
|
+
{ url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" },
|
|
104
|
+
{ url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" },
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
[[package]]
|
|
108
|
+
name = "window-manager"
|
|
109
|
+
version = "0.1.0"
|
|
110
|
+
source = { editable = "." }
|
|
111
|
+
dependencies = [
|
|
112
|
+
{ name = "psutil" },
|
|
113
|
+
{ name = "pywin32" },
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
[package.dev-dependencies]
|
|
117
|
+
dev = [
|
|
118
|
+
{ name = "pytest" },
|
|
119
|
+
]
|
|
120
|
+
|
|
121
|
+
[package.metadata]
|
|
122
|
+
requires-dist = [
|
|
123
|
+
{ name = "psutil", specifier = ">=7.2.2" },
|
|
124
|
+
{ name = "pywin32", specifier = ">=311" },
|
|
125
|
+
]
|
|
126
|
+
|
|
127
|
+
[package.metadata.requires-dev]
|
|
128
|
+
dev = [{ name = "pytest", specifier = ">=9.0.2" }]
|