diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8d11a60..1035e73 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,9 +24,16 @@ concurrency: cancel-in-progress: true jobs: + ruff: + runs-on: ubuntu-latest + timeout-minutes: &timeout-minutes 5 + steps: + - uses: actions/checkout@v6 + - uses: astral-sh/ruff-action@v4.0.0 + mypy: runs-on: ${{ matrix.os }} - timeout-minutes: &timeout-minutes 5 + timeout-minutes: *timeout-minutes strategy: # mypy is os and python-version sensitive. Test on all supported combinations matrix: @@ -42,3 +49,59 @@ jobs: activate-environment: true - run: uv sync --locked - run: mypy . --python-version=${{ matrix.python-version }} + + tests: + runs-on: ${{ matrix.os }} + timeout-minutes: *timeout-minutes + strategy: + # Test on all supported runtime combinations + matrix: + # Arm runners are faster (as long as the same wheels are available) + # This project doesn't have any code that should act differently per architecture + os: [windows-11-arm, ubuntu-24.04-arm, macos-latest] + # TODO: Run tests in parallel on free-threaded python to catch free-threading issues + # See: https://py-free-threading.github.io/testing/ + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] + fail-fast: false + steps: + - uses: actions/checkout@v6 + - name: Install Linux Packages + if: ${{ startsWith(matrix.os, 'ubuntu') }} + run: | + sudo apt update + # xvfb+openbox: headless X server and WM + # gedit: the test window (not preinstalled on GitHub runners) + # x11-xserver-utils (xrandr/xset): required by tests for window geometry + # dbus-x11: dbus-launch, so the GTK app (gedit) gets a session bus + sudo apt install -y xvfb openbox gedit x11-xserver-utils dbus-x11 + - uses: astral-sh/setup-uv@v8.2.0 + with: + python-version: ${{ matrix.python-version }} + activate-environment: true + - run: uv sync --locked + - name: Run tests (Linux) + if: ${{ startsWith(matrix.os, 'ubuntu') }} + working-directory: tests + run: xvfb-run --server-args="-screen 0 1280x1024x24" bash -c "openbox & sleep 1 && dbus-launch --exit-with-session python test_pywinbox.py" + - name: Run tests (Windows & macOS) + if: ${{ !startsWith(matrix.os, 'ubuntu') }} + working-directory: tests + run: python test_pywinbox.py + - name: Run tests (MacNSBox) + if: ${{ startsWith(matrix.os, 'macos') }} + working-directory: tests + run: python test_MacNSBox.py + + sphinx: + runs-on: ubuntu-24.04-arm # Keep in sync with build.os in .readthedocs.yaml + timeout-minutes: *timeout-minutes + steps: + - uses: actions/checkout@v6 + - uses: astral-sh/setup-uv@v8.2.0 + with: + python-version: "3.14" # Keep in sync with build.tools.python in .readthedocs.yaml + activate-environment: true + - run: uv sync --locked --no-default-groups --group=docs + - name: Build docs + # TODO: Add --fail-on-warning, but still too many warnings right now + run: sphinx-build --keep-going --builder html docs/source docs/_build/html diff --git a/pyproject.toml b/pyproject.toml index ae61319..a507024 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,6 +71,7 @@ dependencies = [ docs = ["myst-parser"] dev = [ { include-group = "docs" }, + "ruff>=0.15.16", "ewmhlib", "pywinctl>=0.3", "mypy>=0.990,<2", diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..f97e36a --- /dev/null +++ b/ruff.toml @@ -0,0 +1,90 @@ +[lint] +future-annotations = true +# https://docs.astral.sh/ruff/rules/ +extend-select = [ + "ANN2", # flake8-annotations: missing-return-type + "C4", # flake8-comprehensions + "F401", # unused-import + "F404", # late-future-import + "FA", # flake8-future-annotations + "FLY", # Flynt + "ICN", # flake8-import-conventions + "PERF", # Perflint + "PGH", # pygrep-hooks (blanket-* rules) + "PYI", # flake8-pyi + "RUF", # Ruff-specific rules + "SIM9", # flake8-simplify: split-static-string + zip-dict-keys-and-values + "SLOT", # flake8-slots + "TC", # flake8-type-checking + "TID", # flake8-tidy-imports + "UP", # pyupgrade + "YTT", # flake8-2020 +] +ignore = [ + # Only enforce return types on public functions. Where otherwise mypy infers as Any + # Still worth running `ruff check --fix --select=202` once in a while for autofixes + "ANN202", # missing-return-type-private-function + # Explicit is preferred + "UP015", # redundant-open-modes, + # Autofixes print-f style formatting to f-strings, + # which is sometimes simpler, but looses template code reading semantics + "UP032", # f-string + # TC helps prevent circular imports, reduce runtime cost of typing symbols, + # and prevent leaking implementations details into modules + # However stdlib is not at risk of circular import, is clearly not public API, + # and assume it's gonna be included in the import chain at some point anyway + "TC003", # typing-only-standard-library-import + # Typeshed doesn't want complex or non-literal defaults for maintenance and testing reasons. + # This doesn't affect us, let's have more complete stubs. + "PYI011", # typed-argument-default-in-stub + "PYI014", # argument-default-in-stub + "PYI053", # string-or-bytes-too-long + # Good to watch for, but often unfixable + # Anyway Python 3.11 introduced "zero cost" exception handling + "PERF203", # try-except-in-loop, + + # TODO: Add and configure isort (I) first + "RUF022", + + # TODO: Consider later + "UP031", # printf-string-formatting + "RUF059", # unused-unpacked-variable + + # TODO: Not autofixable, address in a separate PR ! + "E402", + "E722", + "PYI063", +] +# F401 would remove imports not marked as explicit re-exports, which may break API boundaries +extend-unsafe-fixes = ["F401"] + +[lint.per-file-ignores] +"**/typings/**/*.pyi" = [ + "E402", # https://github.com/astral-sh/ruff/issues/26160 + "F811", # Re-exports false positives + # The following can't be controlled for external libraries: + "A", # Shadowing builtin names + "E741", # ambiguous variable name + "F403", # `from . import *` used; unable to detect undefined names + "FBT", # flake8-boolean-trap + "ICN001", # unconventional-import-alias + "N8", # Naming conventions + "PLC2701", # Private name import + "PLE0302", # The special method expects a given signature + "PLR0904", # Too many public methods + "PLR0913", # Argument count + "PLR0917", # Too many positional arguments + "PLW3201", # misspelled dunder method name + "SLOT", # flake8-slots + # Stubs can sometimes re-export entire modules. + # Issues with using a star-imported name will be caught by type-checkers. + "F405", # may be undefined, or defined from star imports + # It's normal to be missing annotations for local stubs. + # If they were complete, we'd upload them to typeshed! + "ANN0", + "ANN2", +] + +# https://docs.astral.sh/ruff/settings/#lintflake8-type-checking +[lint.flake8-type-checking] +quote-annotations = true diff --git a/src/pywinbox/__init__.py b/src/pywinbox/__init__.py index 10dd9c6..464732f 100644 --- a/src/pywinbox/__init__.py +++ b/src/pywinbox/__init__.py @@ -1,5 +1,4 @@ #!/usr/bin/python -# -*- coding: utf-8 -*- from importlib.metadata import version as _importlib_version __all__ = [ diff --git a/src/pywinbox/_main.py b/src/pywinbox/_main.py index 2c853ca..42a475b 100644 --- a/src/pywinbox/_main.py +++ b/src/pywinbox/_main.py @@ -1,10 +1,9 @@ #!/usr/bin/python -# -*- coding: utf-8 -*- from __future__ import annotations import sys from collections.abc import Callable -from typing import Union, Tuple, NamedTuple, Optional +from typing import NamedTuple class Box(NamedTuple): """Container class to handle Box struct (left, top, width, height)""" @@ -41,7 +40,7 @@ def pointInBox(x: int, y: int, box: Box) -> bool: class PyWinBox: - def __init__(self, onQuery: Optional[Callable[[], Box]] = None, onSet: Optional[Callable[[Box], None]] = None, handle=None): + def __init__(self, onQuery: Callable[[], Box] | None = None, onSet: Callable[[Box], None] | None = None, handle=None) -> None: """ Class to access all area/window box properties. @@ -111,7 +110,7 @@ def onSet(self, newBox: Box): if self._handle is not None: _moveResizeWindow(self._handle, newBox) - def __repr__(self): + def __repr__(self) -> str: """Return a string of the constructor function call to create this Box object.""" return "%s(left=%s, top=%s, width=%s, height=%s)" % ( self.__class__.__name__, @@ -121,7 +120,7 @@ def __repr__(self): self._box.height, ) - def __str__(self): + def __str__(self) -> str: """Return a string representation of this Box object.""" return "(%s, %s, %s, %s)" % ( self._box.left, @@ -202,7 +201,7 @@ def position(self) -> Point: return Point(self._box.left, self._box.top) @position.setter - def position(self, value: Union[Point, Tuple[int, int]]): + def position(self, value: Point | tuple[int, int]): val: Point = Point(*value) self._box = self._onQuery() self._box = Box(val.x, val.y, self._box.width, self._box.height) @@ -214,7 +213,7 @@ def size(self) -> Size: return Size(self._box.width, self._box.height) @size.setter - def size(self, value: Union[Size, Tuple[int, int]]): + def size(self, value: Size | tuple[int, int]): val: Size = Size(*value) self._box = self._onQuery() self._box = Box(self._box.left, self._box.top, val.width, val.height) @@ -226,7 +225,7 @@ def box(self) -> Box: return self._box @box.setter - def box(self, value: Union[Box, Tuple[int, int, int, int]]): + def box(self, value: Box | tuple[int, int, int, int]): val: Box = Box(*value) self._box = val self._onSet(self._box) @@ -238,7 +237,7 @@ def rect(self) -> Rect: self._box.top + self._box.height) @rect.setter - def rect(self, value: Union[Rect, Tuple[int, int, int, int]]): + def rect(self, value: Rect | tuple[int, int, int, int]): val: Rect = Rect(*value) self._box = Box(val.left, val.top, abs(val.right - val.left), abs(val.bottom - val.top)) self._onSet(self._box) @@ -249,7 +248,7 @@ def topleft(self) -> Point: return Point(self._box.left, self._box.top) @topleft.setter - def topleft(self, value: Union[Point, Tuple[int, int]]): + def topleft(self, value: Point | tuple[int, int]): val: Point = Point(*value) self._box = self._onQuery() self._box = Box(val.x, val.y, self._box.width, self._box.height) @@ -261,7 +260,7 @@ def bottomleft(self) -> Point: return Point(self._box.left, self._box.top + self._box.height) @bottomleft.setter - def bottomleft(self, value: Union[Point, Tuple[int, int]]): + def bottomleft(self, value: Point | tuple[int, int]): val: Point = Point(*value) self._box = self._onQuery() self._box = Box(val.x, val.y - self._box.height, self._box.width, self._box.height) @@ -273,7 +272,7 @@ def topright(self) -> Point: return Point(self._box.left + self._box.width, self._box.top) @topright.setter - def topright(self, value: Union[Point, Tuple[int, int]]): + def topright(self, value: Point | tuple[int, int]): val: Point = Point(*value) self._box = self._onQuery() self._box = Box(val.x - self._box.width, val.y, self._box.width, self._box.height) @@ -285,7 +284,7 @@ def bottomright(self) -> Point: return Point(self._box.left + self._box.width, self._box.top + self._box.height) @bottomright.setter - def bottomright(self, value: Union[Point, Tuple[int, int]]): + def bottomright(self, value: Point | tuple[int, int]): val: Point = Point(*value) self._box = self._onQuery() self._box = Box(val.x - self._box.width, val.y - self._box.height, self._box.width, self._box.height) @@ -297,7 +296,7 @@ def midtop(self) -> Point: return Point(self._box.left + (self._box.width // 2), self._box.top) @midtop.setter - def midtop(self, value: Union[Point, Tuple[int, int]]): + def midtop(self, value: Point | tuple[int, int]): val: Point = Point(*value) self._box = self._onQuery() self._box = Box(val.x - (self._box.width // 2), val.y, self._box.width, self._box.height) @@ -309,7 +308,7 @@ def midbottom(self) -> Point: return Point(self._box.left + (self._box.width // 2), self._box.top + self._box.height) @midbottom.setter - def midbottom(self, value: Union[Point, Tuple[int, int]]): + def midbottom(self, value: Point | tuple[int, int]): val: Point = Point(*value) self._box = self._onQuery() self._box = Box(val.x - (self._box.width // 2), val.y - self._box.height, self._box.width, self._box.height) @@ -321,7 +320,7 @@ def midleft(self) -> Point: return Point(self._box.left, self._box.top + (self._box.height // 2)) @midleft.setter - def midleft(self, value: Union[Point, Tuple[int, int]]): + def midleft(self, value: Point | tuple[int, int]): val: Point = Point(*value) self._box = self._onQuery() self._box = Box(val.x, val.y - (self._box.height // 2), self._box.width, self._box.height) @@ -333,7 +332,7 @@ def midright(self) -> Point: return Point(self._box.left + self._box.width, self._box.top + (self._box.height // 2)) @midright.setter - def midright(self, value: Union[Point, Tuple[int, int]]): + def midright(self, value: Point | tuple[int, int]): val: Point = Point(*value) self._box = self._onQuery() self._box = Box(val.x - self._box.width, val.y - (self._box.height // 2), self._box.width, self._box.height) @@ -345,7 +344,7 @@ def center(self) -> Point: return Point(self._box.left + (self._box.width // 2), self._box.top + (self._box.height // 2)) @center.setter - def center(self, value: Union[Point, Tuple[int, int]]): + def center(self, value: Point | tuple[int, int]): val: Point = Point(*value) self._box = self._onQuery() self._box = Box(val.x - (self._box.width // 2), val.y - (self._box.height // 2), self._box.width, self._box.height) diff --git a/src/pywinbox/_pywinbox_linux.py b/src/pywinbox/_pywinbox_linux.py index 4f10e59..bc5f183 100644 --- a/src/pywinbox/_pywinbox_linux.py +++ b/src/pywinbox/_pywinbox_linux.py @@ -1,12 +1,10 @@ #!/usr/bin/python -# -*- coding: utf-8 -*- from __future__ import annotations import os import sys assert sys.platform == "linux" -from typing import Union, Optional from Xlib.xobject.drawable import Window as XWindow @@ -14,7 +12,7 @@ from ewmhlib import EwmhWindow -def _getHandle(handle: Union[int, XWindow]) -> Optional[EwmhWindow]: +def _getHandle(handle: int | XWindow) -> EwmhWindow | None: newHandle = None if isinstance(handle, int): diff --git a/src/pywinbox/_pywinbox_macos.py b/src/pywinbox/_pywinbox_macos.py index 5bb81dc..a9c6c60 100644 --- a/src/pywinbox/_pywinbox_macos.py +++ b/src/pywinbox/_pywinbox_macos.py @@ -1,5 +1,4 @@ #!/usr/bin/python -# -*- coding: utf-8 -*- # Incomplete type stubs for pyobjc # mypy: disable_error_code = no-any-return from __future__ import annotations @@ -9,7 +8,7 @@ assert sys.platform == "darwin" import subprocess -from typing import NamedTuple, Union, Optional, cast +from typing import NamedTuple, cast import AppKit @@ -27,7 +26,7 @@ class _macOSCGHandle(NamedTuple): windowTitle: str -def _getHandle(handle) -> Optional[Union[_macOSCGHandle, _macOSNSHandle]]: +def _getHandle(handle) -> _macOSCGHandle | _macOSNSHandle | None: newHandle = None if isinstance(handle, tuple): app, window = handle @@ -71,21 +70,21 @@ def _checkPermissions(activate: bool = False) -> bool: return ret == "true" -def _getWindowBox(handle: Union[_macOSNSHandle, _macOSCGHandle], flipValues: bool = False): +def _getWindowBox(handle: _macOSNSHandle | _macOSCGHandle, flipValues: bool = False): if handle.isNSHandle: - handle = cast(_macOSNSHandle, handle) + handle = cast("_macOSNSHandle", handle) return _NSgetWindowBox(handle.window, flipValues) else: - handle = cast(_macOSCGHandle, handle) + handle = cast("_macOSCGHandle", handle) return _CGgetWindowBox(handle.appName, handle.windowTitle) -def _moveResizeWindow(handle: Union[_macOSNSHandle, _macOSCGHandle], newBox: Box, flipValues: bool = False): +def _moveResizeWindow(handle: _macOSNSHandle | _macOSCGHandle, newBox: Box, flipValues: bool = False): if handle.isNSHandle: - handle = cast(_macOSNSHandle, handle) + handle = cast("_macOSNSHandle", handle) _NSmoveResizeTo(handle.window, newBox, flipValues) else: - handle = cast(_macOSCGHandle, handle) + handle = cast("_macOSCGHandle", handle) _CGmoveResizeTo(handle.appName, handle.windowTitle, newBox) diff --git a/src/pywinbox/_pywinbox_win.py b/src/pywinbox/_pywinbox_win.py index ce201c1..02f2e4e 100644 --- a/src/pywinbox/_pywinbox_win.py +++ b/src/pywinbox/_pywinbox_win.py @@ -1,12 +1,10 @@ #!/usr/bin/python -# -*- coding: utf-8 -*- from __future__ import annotations import sys assert sys.platform == "win32" -from typing import Union, Optional import ctypes import win32gui @@ -25,7 +23,7 @@ ctypes.windll.shcore.SetProcessDpiAwareness(1) -def _getHandle(handle: Union[int, str]) -> Optional[int]: +def _getHandle(handle: int | str) -> int | None: newHandle = None if isinstance(handle, str): try: diff --git a/tests/test_MacNSBox.py b/tests/test_MacNSBox.py index 7ca71ae..cefc4d8 100644 --- a/tests/test_MacNSBox.py +++ b/tests/test_MacNSBox.py @@ -1,15 +1,15 @@ #!/usr/bin/env python -# encoding: utf-8 # Lawrence Akka - https://sourceforge.net/p/pyobjc/mailman/pyobjc-dev/thread/0B4BC391-6491-445D-92D0-7B1CEF6F51BE%40me.com/#msg27726282 # We need to import the relevant object definitions from PyObjC +import os import sys +import traceback import pywinbox -assert sys.platform == "darwin" import time @@ -25,11 +25,25 @@ class Delegate(NSObject): npw = None - def applicationSupportsSecureRestorableState_(self, app): + def applicationSupportsSecureRestorableState_(self, app) -> bool: return True - def applicationDidFinishLaunching_(self, aNotification: None): + def applicationDidFinishLaunching_(self, aNotification: None) -> None: '''Called automatically when the application has launched''' + # PyObjC swallows exceptions raised inside delegate callbacks (only logging the type), + # so the window would never close and the app would hang forever. + # Catch everything, print the traceback, and force-exit nonzero so the test fails fast. + # We can't use NSApp().terminate_() (it exits 0) nor sys.exit() (SystemExit isn't an + # Exception subclass, so PyObjC swallows it too): os._exit() is the only reliable path. + try: + self._runChecks() + except Exception: + traceback.print_exc() + sys.stdout.flush() + sys.stderr.flush() + os._exit(1) + + def _runChecks(self) -> None: # Set it as the frontmost application NSApp().activateIgnoringOtherApps_(True) @@ -38,9 +52,18 @@ def applicationDidFinishLaunching_(self, aNotification: None): handle = win print(win.title(), win.frame(), type(win.frame().origin)) print() + assert handle is not None myPyBox = pywinbox.PyWinBox(onQuery=None, onSet=None, handle=handle) + # macOS won't let a window's top edge rise above the menu bar, so any + # target whose top edge exceeds the usable screen height gets clamped. + # CI runners have a small headless display, so derive bottom targets + # from the actual screen height instead of assuming a tall desktop. + screenH = int(handle.screen().frame().size.height) + print("SCREEN", handle.screen().frame()) + lowBottom = min(700, screenH - 100) + timelap = 0.3 print("MOVE left = 200", myPyBox.box, myPyBox.rect) @@ -58,10 +81,10 @@ def applicationDidFinishLaunching_(self, aNotification: None): time.sleep(timelap) assert myPyBox.top == 200 - print("MOVE bottom = 800", myPyBox.box, myPyBox.rect) - myPyBox.bottom = 800 + print(f"MOVE bottom = {lowBottom}", myPyBox.box, myPyBox.rect) + myPyBox.bottom = lowBottom time.sleep(timelap) - assert myPyBox.bottom == 800 + assert myPyBox.bottom == lowBottom print("MOVE topleft = (300, 400)", myPyBox.box, myPyBox.rect) myPyBox.topleft = (300, 400) @@ -73,10 +96,10 @@ def applicationDidFinishLaunching_(self, aNotification: None): time.sleep(timelap) assert myPyBox.topright == (300, 400) - print("MOVE bottomleft = (300, 700)", myPyBox.box, myPyBox.rect) - myPyBox.bottomleft = (300, 700) + print(f"MOVE bottomleft = (300, {lowBottom})", myPyBox.box, myPyBox.rect) + myPyBox.bottomleft = (300, lowBottom) time.sleep(timelap) - assert myPyBox.bottomleft == (300, 700) + assert myPyBox.bottomleft == (300, lowBottom) print("MOVE bottomright = (1000, 200)", myPyBox.box, myPyBox.rect) myPyBox.bottomright = (1000, 200) @@ -98,10 +121,10 @@ def applicationDidFinishLaunching_(self, aNotification: None): time.sleep(timelap) assert myPyBox.midtop == (300, 400) - print("MOVE midbottom = (300, 700)", myPyBox.box, myPyBox.rect) - myPyBox.midbottom = (300, 700) + print(f"MOVE midbottom = (300, {lowBottom})", myPyBox.box, myPyBox.rect) + myPyBox.midbottom = (300, lowBottom) time.sleep(timelap) - assert myPyBox.midbottom == (300, 700) + assert myPyBox.midbottom == (300, lowBottom) print("MOVE center = (300, 400)", myPyBox.box, myPyBox.rect) myPyBox.center = (300, 400) @@ -137,23 +160,23 @@ def applicationDidFinishLaunching_(self, aNotification: None): print("CLOSE") win.close() - def windowWillClose_(self, aNotification: None): + def windowWillClose_(self, aNotification: None) -> None: '''Called automatically when the window is closed''' print("Window has been closed") # Terminate the application NSApp().terminate_(self) - def windowDidBecomeKey_(self, aNotification: None): + def windowDidBecomeKey_(self, aNotification: None) -> None: print("Now I'm ACTIVE") -def demo(): +def demo() -> None: # Create a new application instance ... a = NSApplication.sharedApplication() # ... and create its delegate. Note the use of the # Objective C constructors below, because Delegate # is a subclass of an Objective C class, NSObject - delegate = Delegate.alloc().init() + delegate: Delegate = Delegate.alloc().init() # Tell the application which delegate object to use. a.setDelegate_(delegate) @@ -168,11 +191,14 @@ def demo(): # to be the same delegate as the application is using)... w.setDelegate_(delegate) # ... and set some properties. Unicode strings are preferred. - w.setTitle_(u'Hello, World!') + w.setTitle_('Hello, World!') # All set. Now we can show the window ... w.orderFrontRegardless() # ... and start the application + # On success the window closes -> windowWillClose_ -> terminate_ (exits 0). + # On failure _runChecks' handler calls os._exit(1). Either way control does not + # return here, so there's nothing to do after a.run(). a.run() #AppHelper.runEventLoop() diff --git a/tests/test_pywinbox.py b/tests/test_pywinbox.py index e31f084..a446764 100644 --- a/tests/test_pywinbox.py +++ b/tests/test_pywinbox.py @@ -1,158 +1,172 @@ #!/usr/bin/python -# -*- coding: utf-8 -*- from __future__ import annotations +import os import subprocess import sys import time +from typing import TypedDict -import pywinbox import pywinctl +import pywinbox -def test_basic(): - - npw = None - if sys.platform == "win32": - subprocess.Popen('notepad') - time.sleep(0.5) +class GetWindowKwargs(TypedDict): + title: str + condition: int # TODO: Consider making pywinctl.Re an IntEnum - npw = pywinctl.getActiveWindow() +def test_basic() -> None: + if sys.platform == "win32": + process = "notepad" + get_window_kwargs: GetWindowKwargs = { + "title": "Notepad", + "condition": pywinctl.Re.ENDSWITH, + } elif sys.platform == "linux": - subprocess.Popen('gedit') - time.sleep(5) - - npw = pywinctl.getActiveWindow() - + process = "gedit" + get_window_kwargs: GetWindowKwargs = { + "title": "gedit", + "condition": pywinctl.Re.ENDSWITH, + } elif sys.platform == "darwin": if not pywinctl.checkPermissions(activate=True): exit() - subprocess.Popen(['touch', 'test.py']) - time.sleep(2) - subprocess.Popen(['open', '-a', 'TextEdit', 'test.py']) - time.sleep(5) - - windows = pywinctl.getWindowsWithTitle('test.py') - if windows: - npw = windows[0] - - if npw is not None: - - wait = True - timelap = 0.5 - - # Test maximize/minimize/restore. - if npw.isMaximized: # Make sure it starts un-maximized - npw.restore(wait=wait) - assert not npw.isMaximized - - npw.size = (600, 400) - - if sys.platform == "darwin": - myPyBox = pywinbox.PyWinBox(onQuery=None, onSet=None, handle=(npw.getAppName(), npw.title or "")) - else: - myPyBox = pywinbox.PyWinBox(onQuery=None, onSet=None, handle=npw.getHandle()) - - print("INIT", npw.box, npw.rect) - - myPyBox.left = 250 - time.sleep(timelap) - print("LEFT", npw.box, npw.rect) - assert npw.left == 250 - - myPyBox.right = 950 - time.sleep(timelap) - print("RIGHT", npw.box, npw.rect) - assert npw.right == 950 - - myPyBox.top = 150 - time.sleep(timelap) - print("TOP", npw.box, npw.rect) - assert npw.top == 150 - - myPyBox.bottom = 775 - time.sleep(timelap) - print("BOTTOM", npw.box, npw.rect) - assert npw.bottom == 775 - - myPyBox.topleft = (155, 350) - time.sleep(timelap) - print(npw.box, npw.rect) - assert npw.topleft == (155, 350) - - myPyBox.topright = (1000, 300) - time.sleep(timelap) - print(npw.box, npw.rect) - assert npw.topright == (1000, 300) - - myPyBox.bottomleft = (300, 975) - time.sleep(timelap) - print(npw.box, npw.rect) - assert npw.bottomleft == (300, 975) - - myPyBox.bottomright = (1000, 900) - time.sleep(timelap) - print(npw.box, npw.rect) - assert npw.bottomright == (1000, 900) - - myPyBox.midleft = (300, 400) - time.sleep(timelap) - print(npw.box, npw.rect) - assert npw.midleft == (300, 400) - - myPyBox.midright = (1050, 600) - time.sleep(timelap) - print(npw.box, npw.rect) - assert npw.midright == (1050, 600) - - myPyBox.midtop = (500, 350) - time.sleep(timelap) - print(npw.box, npw.rect) - assert npw.midtop == (500, 350) - - myPyBox.midbottom = (500, 800) - time.sleep(timelap) - print(npw.box, npw.rect) - assert npw.midbottom == (500, 800) - - myPyBox.center = (500, 350) - time.sleep(timelap) - print(npw.box, npw.rect) - assert npw.center == (500, 350) - - myPyBox.centerx = 1000 - time.sleep(timelap) - print(npw.box, npw.rect) - assert npw.centerx == 1000 - - myPyBox.centery = 600 - time.sleep(timelap) - print(npw.box, npw.rect) - assert npw.centery == 600 - - myPyBox.width = 700 - time.sleep(timelap) - print(npw.box, npw.rect) - assert npw.width == 700 - - myPyBox.height = 400 - time.sleep(timelap) - print(npw.box, npw.rect) - assert npw.height == 400 - - myPyBox.size = (551, 401) - time.sleep(timelap) - print(npw.box, npw.rect) - assert npw.size == (551, 401) - - # Test closing - npw.close() - - -def main(): + process = ["open", "-a", "TextEdit", __file__] + get_window_kwargs: GetWindowKwargs = { + "title": os.path.basename(__file__), + "condition": pywinctl.Re.IS, + } + else: + raise NotImplementedError( + "PyWinCtl currently does not support this platform. " + + "If you have useful knowledge, please contribute! https://github.com/Kalmat/PyWinCtl" + ) + + subprocess.Popen(process) + + testWindows: list[pywinctl.Window] = [] + deadline = time.time() + 15 + while not testWindows and time.time() < deadline: + time.sleep(0.5) + testWindows = pywinctl.getWindowsWithTitle(**get_window_kwargs) + assert len(testWindows) == 1 + + npw = testWindows[0] + wait = True + timelap = 0.5 + + # Test maximize/minimize/restore. + if npw.isMaximized: # Make sure it starts un-maximized + npw.restore(wait=wait) + assert not npw.isMaximized + + npw.size = (600, 400) + + if sys.platform == "darwin": + myPyBox = pywinbox.PyWinBox(onQuery=None, onSet=None, handle=(npw.getAppName(), npw.title or "")) + else: + myPyBox = pywinbox.PyWinBox(onQuery=None, onSet=None, handle=npw.getHandle()) + + print("INIT", npw.box, npw.rect) + + myPyBox.left = 250 + time.sleep(timelap) + print("LEFT", npw.box, npw.rect) + assert npw.left == 250 + + myPyBox.right = 950 + time.sleep(timelap) + print("RIGHT", npw.box, npw.rect) + assert npw.right == 950 + + myPyBox.top = 150 + time.sleep(timelap) + print("TOP", npw.box, npw.rect) + assert npw.top == 150 + + myPyBox.bottom = 775 + time.sleep(timelap) + print("BOTTOM", npw.box, npw.rect) + assert npw.bottom == 775 + + myPyBox.topleft = (155, 350) + time.sleep(timelap) + print(npw.box, npw.rect) + assert npw.topleft == (155, 350) + + myPyBox.topright = (1000, 300) + time.sleep(timelap) + print(npw.box, npw.rect) + assert npw.topright == (1000, 300) + + myPyBox.bottomleft = (300, 975) + time.sleep(timelap) + print(npw.box, npw.rect) + assert npw.bottomleft == (300, 975) + + myPyBox.bottomright = (1000, 900) + time.sleep(timelap) + print(npw.box, npw.rect) + assert npw.bottomright == (1000, 900) + + myPyBox.midleft = (300, 400) + time.sleep(timelap) + print(npw.box, npw.rect) + assert npw.midleft == (300, 400) + + myPyBox.midright = (1050, 600) + time.sleep(timelap) + print(npw.box, npw.rect) + assert npw.midright == (1050, 600) + + myPyBox.midtop = (500, 350) + time.sleep(timelap) + print(npw.box, npw.rect) + assert npw.midtop == (500, 350) + + myPyBox.midbottom = (500, 800) + time.sleep(timelap) + print(npw.box, npw.rect) + assert npw.midbottom == (500, 800) + + myPyBox.center = (500, 350) + time.sleep(timelap) + print(npw.box, npw.rect) + assert npw.center == (500, 350) + + myPyBox.centerx = 1000 + time.sleep(timelap) + print(npw.box, npw.rect) + assert npw.centerx == 1000 + + myPyBox.centery = 600 + time.sleep(timelap) + print(npw.box, npw.rect) + assert npw.centery == 600 + + myPyBox.width = 700 + time.sleep(timelap) + print(npw.box, npw.rect) + assert npw.width == 700 + + myPyBox.height = 400 + time.sleep(timelap) + print(npw.box, npw.rect) + assert npw.height == 400 + + myPyBox.size = (551, 401) + time.sleep(timelap) + print(npw.box, npw.rect) + assert npw.size == (551, 401) + + # Test closing + npw.close() + + +def main() -> None: test_basic() diff --git a/uv.lock b/uv.lock index c7f7e7f..a01244f 100644 --- a/uv.lock +++ b/uv.lock @@ -8292,6 +8292,7 @@ dev = [ { name = "myst-parser", version = "4.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, { name = "myst-parser", version = "5.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "pywinctl" }, + { name = "ruff" }, { name = "types-python-xlib", version = "0.33.0.20250809", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "types-python-xlib", version = "0.33.0.20260518", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "types-pywin32", version = "311.0.0.20251008", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, @@ -8317,6 +8318,7 @@ dev = [ { name = "mypy", specifier = ">=0.990,<2" }, { name = "myst-parser" }, { name = "pywinctl", specifier = ">=0.3" }, + { name = "ruff", specifier = ">=0.15.16" }, { name = "types-python-xlib", specifier = ">=0.32" }, { name = "types-pywin32", specifier = ">=305.0.0.3" }, ] @@ -8461,6 +8463,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/54/6f679c435d28e0a568d8e8a7c0a93a09010818634c3c3907fc98d8983770/roman_numerals-4.1.0-py3-none-any.whl", hash = "sha256:647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7", size = 7676, upload-time = "2025-12-17T18:25:33.098Z" }, ] +[[package]] +name = "ruff" +version = "0.15.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/a9/3abdf488f1bf3d24c699415e454ed554a6350d5d89ce183be1ee0a3361ac/ruff-0.15.17.tar.gz", hash = "sha256:2ec446937fd16c8c4de2674a209cc5af64d9c6f17d21fbf1151054fa0bcf5219", size = 4743346, upload-time = "2026-06-11T17:54:47.663Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/4d/e11259f5da07cb6afb2d074c31bf09da9671993f7329d4f15d2fdc458301/ruff-0.15.17-py3-none-linux_armv6l.whl", hash = "sha256:d9feddb927fc68bd295f5eebc587a7e42cfaf9b65f60ca4a2386febff575da8f", size = 10856677, upload-time = "2026-06-11T17:54:49.533Z" }, + { url = "https://files.pythonhosted.org/packages/29/3e/772d679e1a0dc058e58875bd2c0cb713a0530877b4a76fee3c7966df0d49/ruff-0.15.17-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:25805a226d741c47d274a35ad5c10a7dde175fcddfa511d7cf3da0a21eb3eab7", size = 11223443, upload-time = "2026-06-11T17:55:00.573Z" }, + { url = "https://files.pythonhosted.org/packages/68/58/bd41f7688b2fd5623012605130ed70e60aa7f2244baa3d5066bdd61530c8/ruff-0.15.17-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f6ad73b14c2d18a3bf8ad7cb6974294d7f613a7898604826058e6ac64918ef4d", size = 10566458, upload-time = "2026-06-11T17:55:07.52Z" }, + { url = "https://files.pythonhosted.org/packages/d8/5b/733371013fcf1ec339e477ece6ab42bfe10bdd9bba8ee88a9516aa56bfc0/ruff-0.15.17-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ba0c1e4f95bcb3869d0d30cbd5917071ef2e28665abfec970cdab0492c713ed", size = 10914483, upload-time = "2026-06-11T17:55:05.501Z" }, + { url = "https://files.pythonhosted.org/packages/bd/cc/6f24251cc0252f7239391ccb85833f320efad14ebe5b443943f37ced6332/ruff-0.15.17-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:81647960f10bff57d2e51cadd0c3950fe598400c852863a038720ef5b8cca91e", size = 10647497, upload-time = "2026-06-11T17:54:57.733Z" }, + { url = "https://files.pythonhosted.org/packages/68/dd/0d10c17ce1a1624d6fc3156309c3f834fdb5dfaad026ec90c85684f3990e/ruff-0.15.17-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e01a84ddbc8c16c23055ba3924476850f1bbc1917cebbb9376665a63e74260d", size = 11416967, upload-time = "2026-06-11T17:54:51.461Z" }, + { url = "https://files.pythonhosted.org/packages/2f/91/556bfb156f6144f355e831c23db00b2fc4120f86b3ce81cc5f7fd2df51f3/ruff-0.15.17-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84fe9f653152f8f294f9f7e03bf3a453d8b4a27f7a59c78c8666167f2b17b96c", size = 12335770, upload-time = "2026-06-11T17:54:45.793Z" }, + { url = "https://files.pythonhosted.org/packages/88/82/8b5999aa13355e926f06d9f42a32dcca862f623bf0363785ff89d607dffd/ruff-0.15.17-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c0fe88a7676e7a05b73174d4d4a59cb2ac21ff8263583f87a81a6018475a978", size = 11575441, upload-time = "2026-06-11T17:54:32.661Z" }, + { url = "https://files.pythonhosted.org/packages/11/93/f10377bb04109ca0e8cbc483ff1982c54b6d418210041776f93e8cdc7fa9/ruff-0.15.17-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecfc3c7878fff94633ab0348524e093f9ce3243080416dd7d14f8ba400174719", size = 11557614, upload-time = "2026-06-11T17:54:34.698Z" }, + { url = "https://files.pythonhosted.org/packages/c7/a6/eeeae7f7d5493df41649ab3db92f086b2d0a30199e4efdf8e3dd7a033f24/ruff-0.15.17-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:b8461180b22420b1bdc289909410930761629fddf2a5aaf60fae1ab26cedc4c4", size = 11544450, upload-time = "2026-06-11T17:54:39.042Z" }, + { url = "https://files.pythonhosted.org/packages/32/88/5991ce565129a24dd4a00db1254b3b5db2e53018cbe4018ea5a89738e727/ruff-0.15.17-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6eccbe50a038b503e7140b441aa9c7fc8c1f36edf23ebef9f4165c2f28f568b7", size = 10892524, upload-time = "2026-06-11T17:55:09.432Z" }, + { url = "https://files.pythonhosted.org/packages/f5/1d/0fdd248313425f55223968af04b0a42125466a8d88d21c1d99c6af0a51e8/ruff-0.15.17-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:382fc0521025f5a8ad447d8bdd523545d0d7646adb718eb1c2dac5065ec27c0f", size = 10659573, upload-time = "2026-06-11T17:54:36.824Z" }, + { url = "https://files.pythonhosted.org/packages/9e/0e/072e8260deb9461062ce9311ced27a8e541229a6ffd483013dd37661e43e/ruff-0.15.17-py3-none-musllinux_1_2_i686.whl", hash = "sha256:456d41fcd1b2777ad63f09a6e7121d43f7b688bbc76a800c10f7f8fb1f912c3f", size = 11127818, upload-time = "2026-06-11T17:55:03.124Z" }, + { url = "https://files.pythonhosted.org/packages/ab/b4/55060a34163121498014696b5f656db5b8c6963768f227dbf0d76b311073/ruff-0.15.17-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b1a04bcc94ae6194e9db05d16ad31f298a7194bfbcb08258bbe589cee1d587b8", size = 11655901, upload-time = "2026-06-11T17:54:53.562Z" }, + { url = "https://files.pythonhosted.org/packages/49/71/9b29d6b87cef468d697f43c6a91e3fae4a80185779d7d5a4ef27d173439f/ruff-0.15.17-py3-none-win32.whl", hash = "sha256:596065960ab1ff593f744220c9fe6580eda00a95003cffa9f4048bb5b1bf0392", size = 10925574, upload-time = "2026-06-11T17:54:55.723Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b2/8fc77f3723228836fa5d12497eb71c808f83782e10d058d2b15cfa14640b/ruff-0.15.17-py3-none-win_amd64.whl", hash = "sha256:6769e5fa1710b179b92e0bfa5a51735b35baea9013dadb06d5f44cbcf9547084", size = 12058788, upload-time = "2026-06-11T17:54:41.042Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c7/c53e8dbff9c9dc4b7928773421ae294a5d28fcb8dcda1a089579d3a7e510/ruff-0.15.17-py3-none-win_arm64.whl", hash = "sha256:f3be1fbb34bcdfd146240d8fb92a709d4c2c8191348580a3c044ec60fa0b4456", size = 11355275, upload-time = "2026-06-11T17:54:43.635Z" }, +] + [[package]] name = "six" version = "1.17.0"