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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 64 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ dependencies = [
docs = ["myst-parser"]
dev = [
{ include-group = "docs" },
"ruff>=0.15.16",
"ewmhlib",
"pywinctl>=0.3",
"mypy>=0.990,<2",
Expand Down
90 changes: 90 additions & 0 deletions ruff.toml
Original file line number Diff line number Diff line change
@@ -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
1 change: 0 additions & 1 deletion src/pywinbox/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-
from importlib.metadata import version as _importlib_version

__all__ = [
Expand Down
35 changes: 17 additions & 18 deletions src/pywinbox/_main.py
Original file line number Diff line number Diff line change
@@ -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)"""
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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__,
Expand All @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand Down
4 changes: 1 addition & 3 deletions src/pywinbox/_pywinbox_linux.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,18 @@
#!/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

from ._main import Box
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):
Expand Down
Loading
Loading