diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8d11a60..b31447d 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,52 @@ 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: | + # xvfb+openbox: headless X server and WM + # x11-xserver-utils (xrandr/xset): required by tests for window geometry + sudo apt install -y xvfb openbox x11-xserver-utils + - 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 && python test_pymonctl.py" + - name: Run tests (Windows & macOS) + if: ${{ !startsWith(matrix.os, 'ubuntu') }} + working-directory: tests + run: python test_pymonctl.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 82fdfd7..f9d9788 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,6 +61,7 @@ dependencies = [ docs = ["myst-parser"] dev = [ { include-group = "docs" }, + "ruff>=0.15.16", "ewmhlib", "mypy>=0.990,<2", "types-python-xlib>=0.32", diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..6cbc1bb --- /dev/null +++ b/ruff.toml @@ -0,0 +1,95 @@ +[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", + "F401", + "PERF401", + "PYI063", + "RUF003", + "RUF012", + "RUF022", +] +# 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/pymonctl/__init__.py b/src/pymonctl/__init__.py index 396d267..c8592b9 100644 --- a/src/pymonctl/__init__.py +++ b/src/pymonctl/__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/pymonctl/_main.py b/src/pymonctl/_main.py index 1cd6dcd..a4fdaa4 100644 --- a/src/pymonctl/_main.py +++ b/src/pymonctl/_main.py @@ -1,15 +1,20 @@ #!/usr/bin/python -# -*- coding: utf-8 -*- from __future__ import annotations import sys import threading from abc import abstractmethod, ABC from collections.abc import Callable -from typing import List, Optional, Union, Tuple, cast +from typing import TYPE_CHECKING from ._structs import DisplayMode, ScreenValue, Size, Point, Box, Rect, Position, Orientation +if TYPE_CHECKING: + from Xlib.ext import randr + from Xlib.protocol.rq import Struct + from Xlib.xobject.drawable import Window as XWindow + import Xlib.display + def _pointInBox(x: int, y: int, left: int, top: int, width: int, height: int) -> bool: """Returns ``True`` if the ``(x, y)`` point is within the box described @@ -77,7 +82,7 @@ def getAllMonitorsDict() -> dict[str, ScreenValue]: return _updateScreens.getScreens() -def getMonitorsData(handle: Optional[int] = None): +def getMonitorsData(handle: int | None = None): # Linux ONLY since X11 is not thread-safe (randr crashes when querying in parallel from separate thread) if sys.platform == "linux": if _updateScreens is None: @@ -105,7 +110,7 @@ def getPrimary() -> Monitor: return _getPrimary() -def findMonitorsAtPoint(x: int, y: int) -> List[Monitor]: +def findMonitorsAtPoint(x: int, y: int) -> list[Monitor]: """ Get all Monitor class instances in which given coordinates (x, y) are found. @@ -116,7 +121,7 @@ def findMonitorsAtPoint(x: int, y: int) -> List[Monitor]: return _findMonitor(x, y) -def findMonitorsAtPointInfo(x: int, y: int) -> List[dict[str, ScreenValue]]: +def findMonitorsAtPointInfo(x: int, y: int) -> list[dict[str, ScreenValue]]: """ Get all monitors info in which given coordinates (x, y) are found. @@ -124,7 +129,7 @@ def findMonitorsAtPointInfo(x: int, y: int) -> List[dict[str, ScreenValue]]: :param y: target Y coordinate :return: list of monitor info (see getAllMonitorsDict() doc) as a list of dicts, or empty """ - info: List[dict[str, ScreenValue]] = [{}] + info: list[dict[str, ScreenValue]] = [{}] monitors = getAllMonitorsDict() for monitor in monitors.keys(): pos = monitors[monitor]["position"] @@ -134,7 +139,7 @@ def findMonitorsAtPointInfo(x: int, y: int) -> List[dict[str, ScreenValue]]: return info -def findMonitorWithName(name: str) -> Optional[Monitor]: +def findMonitorWithName(name: str) -> Monitor | None: """ Get the Monitor class instance which name matches given name. @@ -164,7 +169,7 @@ def findMonitorWithNameInfo(name: str) -> dict[str, ScreenValue]: return info -def arrangeMonitors(arrangement: dict[str, dict[str, Optional[Union[str, int, Position, Point, Size]]]]): +def arrangeMonitors(arrangement: dict[str, dict[str, str | int | Position | Point | Size | None]]): """ Arrange all monitors in a given shape. @@ -197,7 +202,7 @@ def arrangeMonitors(arrangement: dict[str, dict[str, Optional[Union[str, int, Po _arrangeMonitors(arrangement) -def saveSetup() -> List[Tuple[Monitor, ScreenValue]]: +def saveSetup() -> list[tuple[Monitor, ScreenValue]]: """ Save current monitors setup information to be restored afterward. @@ -207,14 +212,14 @@ def saveSetup() -> List[Tuple[Monitor, ScreenValue]]: :return: list of tuples containing all necessary info to restore saved setup as required by restoreSetup() """ - result: List[Tuple[Monitor, ScreenValue]] = [] + result: list[tuple[Monitor, ScreenValue]] = [] monDict: dict[str, ScreenValue] = getAllMonitorsDict() for monName in monDict.keys(): result.append((Monitor(monDict[monName]["id"]), monDict[monName])) return result -def restoreSetup(setup: List[Tuple[Monitor, ScreenValue]]): +def restoreSetup(setup: list[tuple[Monitor, ScreenValue]]): """ Restore given monitors setup (position, mode, orientation, scale, etc.). The function will also try to re-attach / turn on / wake monitors if needed. @@ -223,7 +228,7 @@ def restoreSetup(setup: List[Tuple[Monitor, ScreenValue]]): :param setup: monitors info dictionary as returned by saveSetup() """ - arrangement: dict[str, dict[str, Optional[Union[str, int, Position, Point, Size]]]] = {} + arrangement: dict[str, dict[str, str | int | Position | Point | Size | None]] = {} for monData in setup: monitor, monDict = monData if not monitor.isAttached: @@ -258,7 +263,7 @@ class BaseMonitor(ABC): @property @abstractmethod - def size(self) -> Optional[Size]: + def size(self) -> Size | None: """ Get the dimensions of the monitor as a size struct (width, height) @@ -271,7 +276,7 @@ def size(self) -> Optional[Size]: @property @abstractmethod - def workarea(self) -> Optional[Rect]: + def workarea(self) -> Rect | None: """ Get dimensions of the "usable by applications" area (screen size minus docks, taskbars and so on), as a rect struct (x, y, right, bottom) @@ -284,7 +289,7 @@ def workarea(self) -> Optional[Rect]: @property @abstractmethod - def position(self) -> Optional[Point]: + def position(self) -> Point | None: """ Get monitor position coordinates as a point struct (x, y) @@ -295,7 +300,7 @@ def position(self) -> Optional[Point]: raise NotImplementedError @abstractmethod - def setPosition(self, relativePos: Union[int, Position, Point, Tuple[int, int]], relativeTo: Optional[str]): + def setPosition(self, relativePos: int | Position | Point | tuple[int, int], relativeTo: str | None): """ Change relative position of the current the monitor in relation to another existing monitor (e.g. primary monitor). @@ -320,7 +325,7 @@ def setPosition(self, relativePos: Union[int, Position, Point, Tuple[int, int]], @property @abstractmethod - def box(self) -> Optional[Box]: + def box(self) -> Box | None: """ Get monitor dimensions as a box struct (x, y, width, height) @@ -332,7 +337,7 @@ def box(self) -> Optional[Box]: @property @abstractmethod - def rect(self) -> Optional[Rect]: + def rect(self) -> Rect | None: """ Get monitor dimensions as a rect struct (x, y, right, bottom) @@ -344,7 +349,7 @@ def rect(self) -> Optional[Rect]: @property @abstractmethod - def scale(self) -> Optional[Tuple[float, float]]: + def scale(self) -> tuple[float, float] | None: """ Get scale for the monitor @@ -355,7 +360,7 @@ def scale(self) -> Optional[Tuple[float, float]]: raise NotImplementedError @abstractmethod - def setScale(self, scale: Tuple[float, float], applyGlobally: bool = True): + def setScale(self, scale: tuple[float, float], applyGlobally: bool = True): """ Change scale for the monitor @@ -368,7 +373,7 @@ def setScale(self, scale: Tuple[float, float], applyGlobally: bool = True): @property @abstractmethod - def dpi(self) -> Optional[Tuple[float, float]]: + def dpi(self) -> tuple[float, float] | None: """ Get the dpi (dots/pixels per inch) value for the monitor @@ -380,7 +385,7 @@ def dpi(self) -> Optional[Tuple[float, float]]: @property @abstractmethod - def orientation(self) -> Optional[Union[int, Orientation]]: + def orientation(self) -> int | Orientation | None: """ Get current orientation for the monitor identified by name (or primary if empty) @@ -395,7 +400,7 @@ def orientation(self) -> Optional[Union[int, Orientation]]: raise NotImplementedError @abstractmethod - def setOrientation(self, orientation: Optional[Union[int, Orientation]]): + def setOrientation(self, orientation: int | Orientation | None): """ Change orientation for the monitor identified by name (or primary if empty) @@ -411,7 +416,7 @@ def setOrientation(self, orientation: Optional[Union[int, Orientation]]): @property @abstractmethod - def frequency(self) -> Optional[float]: + def frequency(self) -> float | None: """ Get current refresh rate of monitor. @@ -424,7 +429,7 @@ def frequency(self) -> Optional[float]: @property @abstractmethod - def colordepth(self) -> Optional[int]: + def colordepth(self) -> int | None: """ Get the colordepth (bits per pixel to describe color) value for the monitor @@ -436,7 +441,7 @@ def colordepth(self) -> Optional[int]: @property @abstractmethod - def brightness(self) -> Optional[int]: + def brightness(self) -> int | None: """ Get the brightness of monitor. The return value is normalized to 0-100 (as a percentage) @@ -445,7 +450,7 @@ def brightness(self) -> Optional[int]: raise NotImplementedError @abstractmethod - def setBrightness(self, brightness: Optional[int]): + def setBrightness(self, brightness: int | None): """ Change the brightness of monitor. The input parameter must be defined as a percentage (0-100) @@ -455,7 +460,7 @@ def setBrightness(self, brightness: Optional[int]): @property @abstractmethod - def contrast(self) -> Optional[int]: + def contrast(self) -> int | None: """ Get the contrast of monitor. The return value is normalized to 0-100 (as a percentage) @@ -466,7 +471,7 @@ def contrast(self) -> Optional[int]: raise NotImplementedError @abstractmethod - def setContrast(self, contrast: Optional[int]): + def setContrast(self, contrast: int | None): """ Change the contrast of monitor. The input parameter must be defined as a percentage (0-100) @@ -480,7 +485,7 @@ def setContrast(self, contrast: Optional[int]): @property @abstractmethod - def mode(self) -> Optional[DisplayMode]: + def mode(self) -> DisplayMode | None: """ Get the current monitor mode (width, height, refresh-rate) for the monitor @@ -489,7 +494,7 @@ def mode(self) -> Optional[DisplayMode]: raise NotImplementedError @abstractmethod - def setMode(self, mode: Optional[DisplayMode]): + def setMode(self, mode: DisplayMode | None): """ Change current monitor mode (resolution and/or refresh-rate) for the monitor @@ -501,7 +506,7 @@ def setMode(self, mode: Optional[DisplayMode]): @property @abstractmethod - def defaultMode(self) -> Optional[DisplayMode]: + def defaultMode(self) -> DisplayMode | None: """ Get the preferred mode for the monitor @@ -571,7 +576,7 @@ def turnOff(self): @property @abstractmethod - def isOn(self) -> Optional[bool]: + def isOn(self) -> bool | None: """ Check if monitor is on @@ -602,7 +607,7 @@ def suspend(self): @property @abstractmethod - def isSuspended(self) -> Optional[bool]: + def isSuspended(self) -> bool | None: """ Check if monitor is in standby mode @@ -642,7 +647,7 @@ def detach(self, permanent: bool = False): @property @abstractmethod - def isAttached(self) -> Optional[bool]: + def isAttached(self) -> bool | None: """ Check if monitor is attached (not necessarily ON) to system @@ -652,26 +657,22 @@ def isAttached(self) -> Optional[bool]: _updateRequested = False -_plugListeners: List[Callable[[List[str], dict[str, ScreenValue]], None]] = [] -_changeListeners: List[Callable[[List[str], dict[str, ScreenValue]], None]] = [] +_plugListeners: list[Callable[[list[str], dict[str, ScreenValue]], None]] = [] +_changeListeners: list[Callable[[list[str], dict[str, ScreenValue]], None]] = [] _kill = threading.Event() _interval = 0.5 class _UpdateScreens(threading.Thread): - def __init__(self, kill: threading.Event, interval: float = 0.5): + def __init__(self, kill: threading.Event, interval: float = 0.5) -> None: threading.Thread.__init__(self) self._kill = kill self._interval = interval self._screens: dict[str, ScreenValue] = {} if sys.platform == "linux": - import Xlib.display - from Xlib.ext import randr - from Xlib.protocol.rq import Struct - from Xlib.xobject.drawable import Window as XWindow - self._monitorsData: List[Tuple[Xlib.display.Display, Struct, XWindow,randr.GetScreenResourcesCurrent, + self._monitorsData: list[tuple[Xlib.display.Display, Struct, XWindow,randr.GetScreenResourcesCurrent, randr.MonitorInfo, str, int, randr.GetOutputInfo, int, randr.GetCrtcInfo]] = [] self._screens, self._monitorsData = _getAllMonitorsDictThread() @@ -758,7 +759,7 @@ def getMonitorsData(self, handle): return self._monitorsData -_updateScreens: Optional[_UpdateScreens] = None +_updateScreens: _UpdateScreens | None = None def enableUpdateInfo(): @@ -794,7 +795,7 @@ def disableUpdateInfo(): _killUpdateScreens() -def plugListenerRegister(monitorCountChanged: Callable[[List[str], dict[str, ScreenValue]], None]): +def plugListenerRegister(monitorCountChanged: Callable[[list[str], dict[str, ScreenValue]], None]): """ Use this only if you need to keep track of monitor that can be dynamically plugged or unplugged in a multi-monitor setup. @@ -815,7 +816,7 @@ def plugListenerRegister(monitorCountChanged: Callable[[List[str], dict[str, Scr _startUpdateScreens() -def plugListenerUnregister(monitorCountChanged: Callable[[List[str], dict[str, ScreenValue]], None]): +def plugListenerUnregister(monitorCountChanged: Callable[[list[str], dict[str, ScreenValue]], None]): """ Use this function to un-register your custom callback. The callback will not be invoked anymore in case the number of monitor changes. @@ -834,7 +835,7 @@ def plugListenerUnregister(monitorCountChanged: Callable[[List[str], dict[str, S _killUpdateScreens() -def changeListenerRegister(monitorPropsChanged: Callable[[List[str], dict[str, ScreenValue]], None]): +def changeListenerRegister(monitorPropsChanged: Callable[[list[str], dict[str, ScreenValue]], None]): """ Use this only if you need to keep track of monitor properties changes (position, size, refresh-rate, etc.) in a multi-monitor setup. @@ -855,7 +856,7 @@ def changeListenerRegister(monitorPropsChanged: Callable[[List[str], dict[str, S _startUpdateScreens() -def changeListenerUnregister(monitorPropsChanged: Callable[[List[str], dict[str, ScreenValue]], None]): +def changeListenerUnregister(monitorPropsChanged: Callable[[list[str], dict[str, ScreenValue]], None]): """ Use this function to un-register your custom callback. The callback will not be invoked anymore in case the monitor properties change. @@ -916,7 +917,7 @@ def isUpdateInfoEnabled() -> bool: return _updateRequested -def isPlugListenerRegistered(monitorCountChanged: Callable[[List[str], dict[str, ScreenValue]], None]): +def isPlugListenerRegistered(monitorCountChanged: Callable[[list[str], dict[str, ScreenValue]], None]): """ Check if callback is already registered to be invoked when monitor plugged count change @@ -926,7 +927,7 @@ def isPlugListenerRegistered(monitorCountChanged: Callable[[List[str], dict[str, return monitorCountChanged in _plugListeners -def isChangeListenerRegistered(monitorPropsChanged: Callable[[List[str], dict[str, ScreenValue]], None]): +def isChangeListenerRegistered(monitorPropsChanged: Callable[[list[str], dict[str, ScreenValue]], None]): """ Check if callback is already registered to be invoked when monitor properties change @@ -956,7 +957,7 @@ def updateWatchdogInterval(interval: float): _interval = interval -def _getRelativePosition(monitor, relativeTo) -> Tuple[int, int]: +def _getRelativePosition(monitor, relativeTo) -> tuple[int, int]: relPos = monitor["relativePos"] if relPos == Position.PRIMARY: x = y = 0 diff --git a/src/pymonctl/_pymonctl_linux.py b/src/pymonctl/_pymonctl_linux.py index afc00fd..129ce2a 100644 --- a/src/pymonctl/_pymonctl_linux.py +++ b/src/pymonctl/_pymonctl_linux.py @@ -1,5 +1,4 @@ #!/usr/bin/python -# -*- coding: utf-8 -*- from __future__ import annotations import sys @@ -11,20 +10,22 @@ import subprocess import threading -from typing import Optional, List, Union, Tuple, NamedTuple +from typing import NamedTuple, TYPE_CHECKING import Xlib.display import Xlib.X import Xlib.protocol import Xlib.xobject -from Xlib.protocol.rq import Struct -from Xlib.xobject.drawable import Window as XWindow from Xlib.ext import randr from ._main import BaseMonitor, _pointInBox, _getRelativePosition, getMonitorsData, isWatchdogEnabled, \ DisplayMode, ScreenValue, Box, Rect, Point, Size, Position, Orientation from ewmhlib import defaultEwmhRoot, getProperty, getPropertyValue, getRoots, Props +if TYPE_CHECKING: + from Xlib.protocol.rq import Struct + from Xlib.xobject.drawable import Window as XWindow + def _getAllMonitors() -> list[LinuxMonitor]: return [LinuxMonitor(monitor.crtcs[0]) for monitor in _XgetMonitors()] @@ -41,12 +42,12 @@ def _getAllMonitorsDict() -> dict[str, ScreenValue]: return monitorsDict -def _getAllMonitorsDictThread() -> (Tuple[dict[str, ScreenValue], - List[Tuple[Xlib.display.Display, Struct, XWindow, randr.GetScreenResourcesCurrent, +def _getAllMonitorsDictThread() -> (tuple[dict[str, ScreenValue], + list[tuple[Xlib.display.Display, Struct, XWindow, randr.GetScreenResourcesCurrent, randr.MonitorInfo, str, int, randr.GetOutputInfo, int, randr.GetCrtcInfo]]]): # display connections seem to fail when shared amongst threads and/or queried too quickly in parallel monitorsDict: dict[str, ScreenValue] = {} - monitorsData: List[Tuple[Xlib.display.Display, Struct, XWindow, randr.GetScreenResourcesCurrent, + monitorsData: list[tuple[Xlib.display.Display, Struct, XWindow, randr.GetScreenResourcesCurrent, randr.MonitorInfo, str, int, randr.GetOutputInfo, int, randr.GetCrtcInfo]] = [] for monitorData in _getMonitorsData(): display, screen, root, res, monitor, monName, output, outputInfo, crtc, crtcInfo = monitorData @@ -59,7 +60,7 @@ def _buildMonitorsDict(display, screen, root, res, monitor, monName, output, out is_primary = monitor.primary == 1 x, y, w, h = monitor.x, monitor.y, monitor.width_in_pixels, monitor.height_in_pixels - wa: List[int] = getPropertyValue(getProperty(window=root, prop=Props.Root.WORKAREA, display=display), + wa: list[int] = getPropertyValue(getProperty(window=root, prop=Props.Root.WORKAREA, display=display), display=display) # Thanks to odknt (https://github.com/odknt) for his HELP!!! if isinstance(wa, list) and len(wa) >= 4: @@ -105,7 +106,7 @@ def _getMonitorsCount() -> int: return len(_XgetMonitors()) -def _findMonitor(x: int, y: int) -> List[LinuxMonitor]: +def _findMonitor(x: int, y: int) -> list[LinuxMonitor]: monitors = [] for monitor in _XgetMonitors(): if _pointInBox(x, y, monitor.x, monitor.y, monitor.width_in_pixels, monitor.height_in_pixels): @@ -117,7 +118,7 @@ def _getPrimary() -> LinuxMonitor: return LinuxMonitor() -def _arrangeMonitors(arrangement: dict[str, dict[str, Optional[Union[str, int, Position, Point, Size]]]]): +def _arrangeMonitors(arrangement: dict[str, dict[str, str | int | Position | Point | Size | None]]): monitors = _XgetMonitorsDict() setAsPrimary = "" @@ -131,7 +132,7 @@ def _arrangeMonitors(arrangement: dict[str, dict[str, Optional[Union[str, int, P elif relPos == Position.PRIMARY or relPos == (0, 0) or relPos == Point(0, 0): setAsPrimary = monName - newArrangement: dict[str, dict[str, Union[int, bool]]] = {} + newArrangement: dict[str, dict[str, int | bool]] = {} newPos: dict[str, dict[str, int]] = {} xOffset = yOffset = 0 @@ -151,7 +152,7 @@ def _arrangeMonitors(arrangement: dict[str, dict[str, Optional[Union[str, int, P for monName in arrangement.keys(): arrInfo = arrangement[monName] - relativePos: Union[Position, int, Point, Tuple[int, int]] = arrInfo["relativePos"] + relativePos: Position | int | Point | tuple[int, int] = arrInfo["relativePos"] targetMonInfo = monitors[monName]["monitor"] setPrimary = not setAsPrimary and targetMonInfo.primary == 1 @@ -215,7 +216,7 @@ def _getMousePos() -> Point: class LinuxMonitor(BaseMonitor): - def __init__(self, handle: Optional[int] = None): + def __init__(self, handle: int | None = None) -> None: """ Class to access all methods and functions to get info and manage monitors plugged to the system. @@ -231,7 +232,7 @@ def __init__(self, handle: Optional[int] = None): raise ValueError @property - def size(self) -> Optional[Size]: + def size(self) -> Size | None: monitors = _XgetMonitors(self.name) if monitors: monitor = monitors[0] @@ -239,9 +240,9 @@ def size(self) -> Optional[Size]: return None @property - def workarea(self) -> Optional[Rect]: + def workarea(self) -> Rect | None: # https://askubuntu.com/questions/1124149/how-to-get-taskbar-size-and-position-with-python - wa: List[int] = getPropertyValue( + wa: list[int] = getPropertyValue( getProperty(window=self.root, prop=Props.Root.WORKAREA, display=self.display), display=self.display) if wa: wx, wy, wr, wb = wa[0], wa[1], wa[2], wa[3] @@ -249,16 +250,16 @@ def workarea(self) -> Optional[Rect]: return None @property - def position(self) -> Optional[Point]: + def position(self) -> Point | None: monitors = _XgetMonitors(self.name) if monitors: monitor = monitors[0] return Point(monitor.x, monitor.y) return None - def setPosition(self, relativePos: Union[int, Position, Point, Tuple[int, int]], relativeTo: Optional[str]): + def setPosition(self, relativePos: int | Position | Point | tuple[int, int], relativeTo: str | None): # https://askubuntu.com/questions/1193940/setting-monitor-scaling-to-200-with-xrandr - arrangement: dict[str, dict[str, Optional[Union[str, int, Position, Point, Tuple[int, int]]]]] = {} + arrangement: dict[str, dict[str, str | int | Position | Point | tuple[int, int] | None]] = {} monitors: dict[str, dict[str, randr.MonitorInfo]] = _XgetMonitorsDict() monKeys = list(monitors.keys()) if relativePos == Position.PRIMARY: @@ -295,7 +296,7 @@ def setPosition(self, relativePos: Union[int, Position, Point, Tuple[int, int]], _arrangeMonitors(arrangement) # type: ignore[arg-type] @property - def box(self) -> Optional[Box]: + def box(self) -> Box | None: monitors = _XgetMonitors(self.name) if monitors: monitor = monitors[0] @@ -303,7 +304,7 @@ def box(self) -> Optional[Box]: return None @property - def rect(self) -> Optional[Rect]: + def rect(self) -> Rect | None: monitors = _XgetMonitors(self.name) if monitors: monitor = monitors[0] @@ -311,10 +312,10 @@ def rect(self) -> Optional[Rect]: return None @property - def scale(self) -> Optional[Tuple[float, float]]: + def scale(self) -> tuple[float, float] | None: return _scale(self.name) - def setScale(self, scale: Optional[Tuple[float, float]], applyGlobally: bool = True): + def setScale(self, scale: tuple[float, float] | None, applyGlobally: bool = True): # https://askubuntu.com/questions/1193940/setting-monitor-scaling-to-200-with-xrandr # https://wiki.archlinux.org/title/HiDPI#GNOME cmd = "" @@ -335,7 +336,7 @@ def setScale(self, scale: Optional[Tuple[float, float]], applyGlobally: bool = T if cmd: _, _ = _runProc(cmd) - def _buildScaleCmd(self, scale: Tuple[float, float]) -> str: + def _buildScaleCmd(self, scale: tuple[float, float]) -> str: # https://unix.stackexchange.com/questions/596887/how-to-scale-the-resolution-display-of-the-desktop-and-or-applications scaleX, scaleY = scale cmd = "" @@ -372,11 +373,11 @@ def _buildScaleCmd(self, scale: Tuple[float, float]) -> str: return cmd @property - def dpi(self) -> Optional[Tuple[float, float]]: + def dpi(self) -> tuple[float, float] | None: monitors = _XgetMonitors(self.name) if monitors: monitor = monitors[0] - x, y, w, h = monitor.x, monitor.y, monitor.width_in_pixels, monitor.height_in_pixels + _x, _y, w, h = monitor.x, monitor.y, monitor.width_in_pixels, monitor.height_in_pixels if monitor.width_in_millimeters != 0 and monitor.height_in_millimeters != 0: dpiX, dpiY = round((w * 25.4) / monitor.width_in_millimeters), round((h * 25.4) / monitor.height_in_millimeters) else: @@ -385,7 +386,7 @@ def dpi(self) -> Optional[Tuple[float, float]]: return None @property - def orientation(self) -> Optional[Union[int, Orientation]]: + def orientation(self) -> int | Orientation | None: monitorData = getMonitorsData(self.handle) if monitorData: display, screen, root, res, monitor, monName, output, outputInfo, crtc, crtcInfo = monitorData[0] @@ -394,7 +395,7 @@ def orientation(self) -> Optional[Union[int, Orientation]]: return Orientation(int(math.log(crtcInfo.rotation, 2))) return None - def setOrientation(self, orientation: Optional[Union[int, Orientation]]): + def setOrientation(self, orientation: int | Orientation | None): if orientation is not None and orientation in (Orientation.NORMAL, Orientation.INVERTED, Orientation.LEFT, Orientation.RIGHT): global _rotations direction = _rotations[orientation] @@ -402,7 +403,7 @@ def setOrientation(self, orientation: Optional[Union[int, Orientation]]): _, _ = _runProc(cmd) @property - def frequency(self) -> Optional[float]: + def frequency(self) -> float | None: monitorData = getMonitorsData(self.handle) if monitorData: display, screen, root, res, monitor, monName, output, outputInfo, crtc, crtcInfo = monitorData[0] @@ -420,7 +421,7 @@ def colordepth(self) -> int: return int(self.screen.root_depth) @property - def brightness(self) -> Optional[int]: + def brightness(self) -> int | None: # https://manerosss.wordpress.com/2017/05/16/brightness-linux-xrandr/ value = None cmd = 'xrandr --verbose | grep %s -A 10 | grep "Brightness" | grep -o "[0-9].*"' % self.name @@ -432,7 +433,7 @@ def brightness(self) -> Optional[int]: pass return value - def setBrightness(self, brightness: Optional[int]): + def setBrightness(self, brightness: int | None): if brightness is not None and 0 <= brightness <= 100: value = brightness / 100 if 0 <= value <= 1: @@ -440,7 +441,7 @@ def setBrightness(self, brightness: Optional[int]): _, _ = _runProc(cmd) @property - def contrast(self) -> Optional[int]: + def contrast(self) -> int | None: value = None cmd = 'xrandr --verbose | grep %s -A 10 | grep "Gamma" | grep -o "[0-9].*"' % self.name code, ret = _runProc(cmd) @@ -452,7 +453,7 @@ def contrast(self) -> Optional[int]: pass return value - def setContrast(self, contrast: Optional[int]): + def setContrast(self, contrast: int | None): if contrast is not None and 0<= contrast <= 100: value = contrast / 100 if 0 <= value <= 1: @@ -462,7 +463,7 @@ def setContrast(self, contrast: Optional[int]): _, _ = _runProc(cmd) @property - def mode(self) -> Optional[DisplayMode]: + def mode(self) -> DisplayMode | None: monitorData = getMonitorsData(self.handle) if monitorData: display, screen, root, res, monitor, monName, output, outputInfo, crtc, crtcInfo = monitorData[0] @@ -475,7 +476,7 @@ def mode(self) -> Optional[DisplayMode]: return DisplayMode(resMode.width, resMode.height, freq) return None - def setMode(self, mode: Optional[DisplayMode]): + def setMode(self, mode: DisplayMode | None): # https://stackoverflow.com/questions/12706631/x11-change-resolution-and-make-window-fullscreen # randr.set_screen_size(defaultEwmhRoot.root, mode.width, mode.height, 0, 0) # randr.set_screen_config(defaultEwmhRoot.root, size_id, 0, 0, round(mode.frequency), 0) @@ -485,7 +486,7 @@ def setMode(self, mode: Optional[DisplayMode]): _, _ = _runProc(cmd) @property - def defaultMode(self) -> Optional[DisplayMode]: + def defaultMode(self) -> DisplayMode | None: # Assuming first mode is default (perhaps not the best way) monitorData = getMonitorsData(self.handle) if monitorData: @@ -506,7 +507,7 @@ def setDefaultMode(self): @property def allModes(self) -> list[DisplayMode]: - modes: List[DisplayMode] = [] + modes: list[DisplayMode] = [] monitorData = getMonitorsData(self.handle) if monitorData: display, screen, root, res, monitor, monName, output, outputInfo, crtc, crtcInfo = monitorData[0] @@ -562,11 +563,11 @@ def turnOff(self): _, _ = _runProc(cmd) @property - def isOn(self) -> Optional[bool]: + def isOn(self) -> bool | None: # https://stackoverflow.com/questions/3433203/how-to-determine-if-lcd-monitor-is-turned-on-from-linux-command-line cmd = "xrandr --listactivemonitors" code, ret = _runProc(cmd) - res: Optional[bool] = None + res: bool | None = None if ret: res = self.name in ret isSuspended = self.isSuspended @@ -579,7 +580,7 @@ def suspend(self): _, _ = _runProc(cmd) @property - def isSuspended(self) -> Optional[bool]: + def isSuspended(self) -> bool | None: cmd = 'xset -q | grep " Monitor is "' code, ret = _runProc(cmd) if ret: @@ -618,7 +619,7 @@ def isAttached(self) -> bool: return bool(monitor) -def _buildCommand(arrangement: dict[str, dict[str, Union[int, bool]]], xOffset: int, yOffset: int): +def _buildCommand(arrangement: dict[str, dict[str, int | bool]], xOffset: int, yOffset: int): cmd = "xrandr" for monName in arrangement.keys(): arrInfo = arrangement[monName] @@ -632,10 +633,10 @@ def _buildCommand(arrangement: dict[str, dict[str, Union[int, bool]]], xOffset: return cmd -def _GNOME_isScalingGlobal() -> Optional[bool]: +def _GNOME_isScalingGlobal() -> bool | None: cmd = '''gsettings get org.gnome.mutter experimental-features''' try: - proc = subprocess.run(cmd, text=True, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + proc = subprocess.run(cmd, text=True, shell=True, capture_output=True) if "wayland" in os.environ.get('XDG_SESSION_TYPE', '').lower(): return bool("scale-monitor-framebuffer" not in proc.stdout) else: @@ -653,7 +654,7 @@ def _GNOME_setGlobalScaling(setGlobal=True): cmd = "" if "wayland" in os.environ.get('XDG_SESSION_TYPE', '').lower(): try: - proc = subprocess.run("grep -sl mutter /proc/*/maps", text=True, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + proc = subprocess.run("grep -sl mutter /proc/*/maps", text=True, shell=True, capture_output=True) if "/maps" in proc.stdout: cmd = '''gsettings set org.gnome.mutter experimental-features "['scale-monitor-framebuffer']"''' except Exception: @@ -664,7 +665,7 @@ def _GNOME_setGlobalScaling(setGlobal=True): _, _ = _runProc(cmd) -def _GNOME_getScalingFactor() -> Optional[int]: +def _GNOME_getScalingFactor() -> int | None: cmd = '''gsettings get org.gnome.settings-daemon.plugins.xsettings overrides''' code, ret = _runProc(cmd) if "WindowScalingFactor" in ret: @@ -774,7 +775,7 @@ def _GNOME_getScalingFactor() -> Optional[int]: # interface.ApplyMonitorsConfig(serial, 1, monConfig, {}) -def _scale(name: str) -> Optional[Tuple[float, float]]: +def _scale(name: str) -> tuple[float, float] | None: if "gnome" in os.environ.get('XDG_CURRENT_DESKTOP', '').lower() and _GNOME_isScalingGlobal(): value = _GNOME_getScalingFactor() if value is not None: @@ -788,7 +789,7 @@ def _scale(name: str) -> Optional[Tuple[float, float]]: if ret: try: res = ret.split(" ") - lines: List[str] = list(filter(None, res)) + lines: list[str] = list(filter(None, res)) a, b = lines[0].split("x") w = int(a) h = int(b) @@ -815,7 +816,7 @@ def _scale(name: str) -> Optional[Tuple[float, float]]: def _runProc(cmd: str): try: # Some commands will take some time to be executed and return required value - proc = subprocess.run(cmd, text=True, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) #, timeout=3) + proc = subprocess.run(cmd, text=True, shell=True, capture_output=True) #, timeout=3) return proc.returncode, proc.stdout except Exception: pass @@ -831,16 +832,16 @@ class _Monitor(NamedTuple): height_in_pixels: int width_in_millimeters: int height_in_millimeters: int - crtcs: List[int] + crtcs: list[int] -def _getMonitorsData(handle: Optional[int] = None) -> ( - List[Tuple[Xlib.display.Display, Struct, XWindow, randr.GetScreenResourcesCurrent, +def _getMonitorsData(handle: int | None = None) -> ( + list[tuple[Xlib.display.Display, Struct, XWindow, randr.GetScreenResourcesCurrent, randr.MonitorInfo, str, int, randr.GetOutputInfo, int, randr.GetCrtcInfo]]): - monitors: List[Tuple[Xlib.display.Display, Struct, XWindow, randr.GetScreenResourcesCurrent, + monitors: list[tuple[Xlib.display.Display, Struct, XWindow, randr.GetScreenResourcesCurrent, randr.MonitorInfo, str, int, randr.GetOutputInfo, int, randr.GetCrtcInfo]] = [] stopSearching = False - roots: List[Tuple[Xlib.display.Display, Struct, XWindow]] = getRoots() + roots: list[tuple[Xlib.display.Display, Struct, XWindow]] = getRoots() for rootData in roots: display, screen, root = rootData try: @@ -880,7 +881,7 @@ def _XgetAllMonitors(name: str = ""): monitors.append((display, screen, root, monitor, monName)) else: stopSearching = False - roots: List[Tuple[Xlib.display.Display, Struct, XWindow]] = getRoots() + roots: list[tuple[Xlib.display.Display, Struct, XWindow]] = getRoots() for rootData in roots: display, screen, root = rootData try: @@ -904,7 +905,7 @@ def _XgetAllMonitors(name: str = ""): def _RgetAllMonitors(): # Check if this works in actual Cinnamon - monitors: List[_Monitor] = [] + monitors: list[_Monitor] = [] outputDict = {} for outputData in _XgetAllOutputs(): display, screen, root, output, outputInfo = outputData @@ -946,9 +947,9 @@ def _RgetMonitorsInfo(activeOnly: bool = True): def _XgetAllOutputs(name: str = ""): - outputs: List[Tuple[Xlib.display.Display, Xlib.protocol.rq.Struct, Xlib.xobject.drawable.Window, + outputs: list[tuple[Xlib.display.Display, Xlib.protocol.rq.Struct, Xlib.xobject.drawable.Window, int, randr.GetOutputInfo]] = [] - roots: List[Tuple[Xlib.display.Display, Struct, XWindow]] = getRoots() + roots: list[tuple[Xlib.display.Display, Struct, XWindow]] = getRoots() for rootData in roots: display, screen, root = rootData res = randr.get_screen_resources_current(root) @@ -996,7 +997,7 @@ def _XgetMonitorsDict(): return monitors -def _XgetMonitorData(handle: Optional[int] = None) -> Optional[Tuple[Xlib.display.Display, Struct, XWindow, randr.MonitorInfo, int, str]]: +def _XgetMonitorData(handle: int | None = None) -> tuple[Xlib.display.Display, Struct, XWindow, randr.MonitorInfo, int, str] | None: for monitorData in _XgetAllMonitors(): display, screen, root, monitor, monName = monitorData output = monitor.crtcs[0] diff --git a/src/pymonctl/_pymonctl_macos.py b/src/pymonctl/_pymonctl_macos.py index 4b90719..d0c1ef8 100644 --- a/src/pymonctl/_pymonctl_macos.py +++ b/src/pymonctl/_pymonctl_macos.py @@ -1,11 +1,9 @@ #!/usr/bin/python -# -*- coding: utf-8 -*- # Incomplete type stubs for pyobjc # mypy: disable_error_code = no-any-return from __future__ import annotations import ctypes -from ctypes import util import sys assert sys.platform == "darwin" @@ -13,7 +11,7 @@ import subprocess import threading -from typing import Optional, List, Union, cast, Tuple +from typing import cast import AppKit import Quartz @@ -73,7 +71,7 @@ def _getMonitorsCount() -> int: return cnt -def _findMonitor(x: int, y: int) -> List[MacOSMonitor]: +def _findMonitor(x: int, y: int) -> list[MacOSMonitor]: v, ids, cnt = CG.CGGetDisplaysWithPoint((x, y), 10, None, None) return [MacOSMonitor(displayId) for displayId in ids] @@ -82,7 +80,7 @@ def _getPrimary() -> MacOSMonitor: return MacOSMonitor() -def _arrangeMonitors(arrangement: dict[str, dict[str, Optional[Union[str, int, Position, Point, Size]]]]): +def _arrangeMonitors(arrangement: dict[str, dict[str, str | int | Position | Point | Size | None]]): monitors = _NSgetAllMonitorsDict() primaryPresent = False @@ -114,7 +112,7 @@ def _arrangeMonitors(arrangement: dict[str, dict[str, Optional[Union[str, int, P for monName in arrangement.keys(): - relativePos: Union[Position, int, Point, Tuple[int, int]] = arrangement[monName]["relativePos"] + relativePos: Position | int | Point | tuple[int, int] = arrangement[monName]["relativePos"] if monName != setAsPrimary: @@ -177,7 +175,7 @@ def _getMousePos(flipValues: bool = False) -> Point: class MacOSMonitor(BaseMonitor): - def __init__(self, handle: Optional[int] = None): + def __init__(self, handle: int | None = None) -> None: """ Class to access all methods and functions to get info and manage monitors plugged to the system. @@ -200,15 +198,15 @@ def __init__(self, handle: Optional[int] = None): break if self.screen is not None: self.name = _getName(self.handle, self.screen) - self._ds: Optional[ctypes.CDLL] = None + self._ds: ctypes.CDLL | None = None self._useDS = True - self._cd: Optional[ctypes.CDLL] = None + self._cd: ctypes.CDLL | None = None self._useCD = True - self._iokit: Optional[ctypes.CDLL] = None + self._iokit: ctypes.CDLL | None = None self._useIOBrightness = True self._useIOOrientation = True - self._cf: Optional[ctypes.CDLL] = None - self._ioservice: Optional[int] = None + self._cf: ctypes.CDLL | None = None + self._ioservice: int | None = None # In some versions / systems, IOKit may fail # v = platform.mac_ver()[0].split(".") # self._ver = float(v[0] + "." + v[1]) @@ -216,27 +214,27 @@ def __init__(self, handle: Optional[int] = None): raise ValueError @property - def size(self, ) -> Optional[Size]: + def size(self, ) -> Size | None: size = self.screen.frame().size res = Size(int(size.width), int(size.height)) return res @property - def workarea(self) -> Optional[Rect]: + def workarea(self) -> Rect | None: wa = self.screen.visibleFrame() wx, wy, wr, wb = int(wa.origin.x), int(wa.origin.y), int(wa.size.width), int(wa.size.height) res = Rect(wx, wy, wr, wb) return res @property - def position(self) -> Optional[Point]: + def position(self) -> Point | None: origin = self.screen.frame().origin res = Point(int(origin.x), int(origin.y)) return res - def setPosition(self, relativePos: Union[int, Position, Point, Tuple[int, int]], relativeTo: Optional[str]): + def setPosition(self, relativePos: int | Position | Point | tuple[int, int], relativeTo: str | None): # https://apple.stackexchange.com/questions/249447/change-display-arrangement-in-os-x-macos-programmatically - arrangement: dict[str, dict[str, Optional[Union[str, int, Position, Point, Size]]]] = {} + arrangement: dict[str, dict[str, str | int | Position | Point | Size | None]] = {} monitors = _NSgetAllMonitorsDict() monKeys = list(monitors.keys()) if relativePos == Position.PRIMARY or relativePos == (0, 0): @@ -275,23 +273,23 @@ def setPosition(self, relativePos: Union[int, Position, Point, Tuple[int, int]], _arrangeMonitors(arrangement) @property - def box(self) -> Optional[Box]: + def box(self) -> Box | None: frame = self.screen.frame() res = Box(int(frame.origin.x), int(frame.origin.y), int(frame.size.width), int(frame.size.height)) return res @property - def rect(self) -> Optional[Rect]: + def rect(self) -> Rect | None: frame = self.screen.frame() res = Rect(int(frame.origin.x), int(frame.origin.y), int(frame.origin.x) + int(frame.size.width), int(frame.origin.y) + int(frame.size.height)) return res @property - def scale(self) -> Optional[Tuple[float, float]]: + def scale(self) -> tuple[float, float] | None: return _scale(self.handle) - def setScale(self, scale: Tuple[float, float], applyGlobally: bool = True): + def setScale(self, scale: tuple[float, float], applyGlobally: bool = True): # https://www.eizoglobal.com/support/compatibility/dpi_scaling_settings_mac_os_x/ if scale is not None and isinstance(scale, tuple) and scale[0] >= 100 and scale[1] >= 100: @@ -346,20 +344,20 @@ def filterValue(itemIn): CG.CGDisplaySetDisplayMode(self.handle, targetMode, None) @property - def dpi(self) -> Optional[Tuple[float, float]]: + def dpi(self) -> tuple[float, float] | None: desc = self.screen.deviceDescription() dpi = desc[Quartz.NSDeviceResolution].sizeValue() dpiX, dpiY = int(dpi.width), int(dpi.height) return dpiX, dpiY @property - def orientation(self) -> Optional[Union[int, Orientation]]: + def orientation(self) -> int | Orientation | None: orientation = int(Quartz.CGDisplayRotation(self.handle) / 90) if orientation in (Orientation.NORMAL, Orientation.INVERTED, Orientation.LEFT, Orientation.RIGHT): return Orientation(orientation) return None - def setOrientation(self, orientation: Optional[Union[int, Orientation]]): + def setOrientation(self, orientation: int | Orientation | None): if (self._useIOOrientation and orientation and orientation in (Orientation.NORMAL, Orientation.INVERTED, Orientation.LEFT, Orientation.RIGHT)): if self._iokit is None: @@ -388,17 +386,17 @@ def setOrientation(self, orientation: Optional[Union[int, Orientation]]): self._useIOOrientation = False @property - def frequency(self) -> Optional[float]: + def frequency(self) -> float | None: freq = Quartz.CGDisplayModeGetRefreshRate(Quartz.CGDisplayCopyDisplayMode(self.handle)) return freq @property - def colordepth(self) -> Optional[int]: + def colordepth(self) -> int | None: depth = Quartz.CGDisplayBitsPerPixel(self.handle) return depth @property - def brightness(self) -> Optional[int]: + def brightness(self) -> int | None: res = None ret = 1 if self._useDS: @@ -452,7 +450,7 @@ def brightness(self) -> Optional[int]: return int(res * 100) return None - def setBrightness(self, brightness: Optional[int]): + def setBrightness(self, brightness: int | None): # https://stackoverflow.com/questions/46885603/is-there-a-programmatic-way-to-check-if-brightness-is-at-max-or-min-value-on-osx if brightness is not None and 0 < brightness < 100: ret = 1 @@ -500,7 +498,7 @@ def setBrightness(self, brightness: Optional[int]): self._useIOBrightness = False @property - def contrast(self) -> Optional[int]: + def contrast(self) -> int | None: # https://searchcode.com/file/2207916/pyobjc-framework-Quartz/PyObjCTest/test_cgdirectdisplay.py/ contrast = None try: @@ -512,7 +510,7 @@ def contrast(self) -> Optional[int]: pass return contrast - def setContrast(self, contrast: Optional[int]): + def setContrast(self, contrast: int | None): # https://searchcode.com/file/2207916/pyobjc-framework-Quartz/PyObjCTest/test_cgdirectdisplay.py/ if contrast is not None and 0 <= contrast <= 100: ret, redMin, redMax, redGamma, greenMin, greenMax, greenGamma, blueMin, blueMax, blueGamma = ( @@ -540,14 +538,14 @@ def setContrast(self, contrast: Optional[int]): ) @property - def mode(self) -> Optional[DisplayMode]: + def mode(self) -> DisplayMode | None: mode = Quartz.CGDisplayCopyDisplayMode(self.handle) w = Quartz.CGDisplayModeGetWidth(mode) h = Quartz.CGDisplayModeGetHeight(mode) r = Quartz.CGDisplayModeGetRefreshRate(mode) return DisplayMode(w, h, r) - def setMode(self, mode: Optional[DisplayMode]): + def setMode(self, mode: DisplayMode | None): # https://stackoverflow.com/questions/10596489/programmatically-change-resolution-os-x # https://searchcode.com/file/2207916/pyobjc-framework-Quartz/PyObjCTest/test_cgdirectdisplay.py/ if mode is not None: @@ -565,8 +563,8 @@ def setMode(self, mode: Optional[DisplayMode]): break @property - def defaultMode(self) -> Optional[DisplayMode]: - res: Optional[DisplayMode] = None + def defaultMode(self) -> DisplayMode | None: + res: DisplayMode | None = None modes = _CGgetAllModes(self.handle) for mode in modes: if bin(Quartz.CGDisplayModeGetIOFlags(mode))[-3] == '1': @@ -585,8 +583,8 @@ def setDefaultMode(self): break @property - def allModes(self) -> List[DisplayMode]: - modes: List[DisplayMode] = [] + def allModes(self) -> list[DisplayMode]: + modes: list[DisplayMode] = [] for mode in _CGgetAllModes(self.handle): w = Quartz.CGDisplayModeGetWidth(mode) h = Quartz.CGDisplayModeGetHeight(mode) @@ -607,7 +605,7 @@ def setPrimary(self): def turnOn(self): cmd = "caffeinate -u -t 2" try: - _ = subprocess.run(cmd, text=True, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=1) + _ = subprocess.run(cmd, text=True, shell=True, capture_output=True, timeout=1) except Exception: pass @@ -615,19 +613,19 @@ def turnOff(self): self.suspend() @property - def isOn(self) -> Optional[bool]: + def isOn(self) -> bool | None: return bool(CG.CGDisplayIsActive(self.handle) == 1) def suspend(self): # Also injecting: Control–Shift–Media_Eject cmd = "pmset displaysleepnow" try: - _ = subprocess.run(cmd, text=True, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=1) + _ = subprocess.run(cmd, text=True, shell=True, capture_output=True, timeout=1) except Exception: pass @property - def isSuspended(self) -> Optional[bool]: + def isSuspended(self) -> bool | None: return bool(CG.CGDisplayIsAsleep(self.handle) == 1) def attach(self): @@ -638,11 +636,11 @@ def detach(self, permanent: bool = False): pass @property - def isAttached(self) -> Optional[bool]: + def isAttached(self) -> bool | None: return bool(CG.CGDisplayIsOnline(self.handle) == 1) -def _getName(displayId: int, screen: Optional[AppKit.NSScreen] = None): +def _getName(displayId: int, screen: AppKit.NSScreen | None = None): if not screen: for scr in AppKit.NSScreen.screens(): desc = scr.deviceDescription() @@ -650,7 +648,7 @@ def _getName(displayId: int, screen: Optional[AppKit.NSScreen] = None): screen = scr break try: - scrName = cast(AppKit.NSScreen, screen).localizedName() + "_" + str(displayId) + scrName = cast("AppKit.NSScreen", screen).localizedName() + "_" + str(displayId) except Exception: # In older macOS, screen doesn't have localizedName() method scrName = "Display" + "_" + str(displayId) @@ -780,7 +778,7 @@ class _CFString(ctypes.Structure): kIOMasterPortDefault = ctypes.c_void_p.in_dll(iokit, "kIOMasterPortDefault") iterator = ctypes.c_void_p() - ret = iokit.IOServiceGetMatchingServices( + iokit.IOServiceGetMatchingServices( kIOMasterPortDefault, iokit.IOServiceMatching(b'IODisplayConnect'), ctypes.byref(iterator) diff --git a/src/pymonctl/_pymonctl_win.py b/src/pymonctl/_pymonctl_win.py index 88f0713..4f1a873 100644 --- a/src/pymonctl/_pymonctl_win.py +++ b/src/pymonctl/_pymonctl_win.py @@ -1,5 +1,4 @@ #!/usr/bin/python -# -*- coding: utf-8 -*- from __future__ import annotations import sys @@ -12,7 +11,6 @@ import ctypes.wintypes import pywintypes -from typing import Optional, List, Union, Tuple, cast import win32api import win32gui import win32con @@ -100,7 +98,7 @@ def _getMonitorsCount() -> int: return len(win32api.EnumDisplayMonitors()) -def _findMonitor(x: int, y: int) -> List[Win32Monitor]: +def _findMonitor(x: int, y: int) -> list[Win32Monitor]: # Watch this: started to fail when repeatedly and quickly invoking it in Python 3.10 (it was ok in 3.9) hMon = win32api.MonitorFromPoint((x, y), win32con.MONITOR_DEFAULTTONEAREST) if hMon and hasattr(hMon, "handle"): @@ -115,7 +113,7 @@ def _getPrimary() -> Win32Monitor: return Win32Monitor() -def _arrangeMonitors(arrangement: dict[str, dict[str, Optional[Union[str, int, Position, Point, Size]]]]): +def _arrangeMonitors(arrangement: dict[str, dict[str, str | int | Position | Point | Size | None]]): # https://stackoverflow.com/questions/35814309/winapi-changedisplaysettingsex-does-not-work # https://stackoverflow.com/questions/195267/use-windows-api-from-c-sharp-to-set-primary-monitor monitors = _win32getAllMonitorsDict() @@ -149,7 +147,7 @@ def _arrangeMonitors(arrangement: dict[str, dict[str, Optional[Union[str, int, P if monName != setAsPrimary: - relativePos: Union[Position, int, Point, Tuple[int, int]] = arrangement[monName]["relativePos"] + relativePos: Position | int | Point | tuple[int, int] = arrangement[monName]["relativePos"] if isinstance(relativePos, Position) or isinstance(relativePos, int): @@ -194,7 +192,7 @@ def _getMousePos() -> Point: class Win32Monitor(BaseMonitor): - def __init__(self, handle: Optional[int] = None): + def __init__(self, handle: int | None = None) -> None: """ Class to access all methods and functions to get info and manage monitors plugged to the system. @@ -213,10 +211,10 @@ def __init__(self, handle: Optional[int] = None): self.name = monitorInfo.get("Device", "") else: raise ValueError - self._hasVCPSupport: Optional[bool] = None - self._hasVCPPowerSupport: Optional[bool] = None - self._sourceAdapterId: Optional[_LUID] = None - self._sourceId: Optional[int] = None + self._hasVCPSupport: bool | None = None + self._hasVCPPowerSupport: bool | None = None + self._sourceAdapterId: _LUID | None = None + self._sourceId: int | None = None self._isWindows11 = False try: self._isWindows11 = bool(int(platform.win32_ver()[1].rsplit(".", 1)[1]) >= 22000) @@ -224,7 +222,7 @@ def __init__(self, handle: Optional[int] = None): self._isWindows11 = False @property - def size(self) -> Optional[Size]: + def size(self) -> Size | None: monitorInfo = _getMonitorInfo(self.handle) if monitorInfo: x, y, r, b = monitorInfo["Monitor"] @@ -233,7 +231,7 @@ def size(self) -> Optional[Size]: return None @property - def workarea(self) -> Optional[Rect]: + def workarea(self) -> Rect | None: monitorInfo = _getMonitorInfo(self.handle) if monitorInfo: wx, wy, wr, wb = monitorInfo["Work"] @@ -242,7 +240,7 @@ def workarea(self) -> Optional[Rect]: return None @property - def position(self) -> Optional[Point]: + def position(self) -> Point | None: monitorInfo = _getMonitorInfo(self.handle) if monitorInfo: x, y, r, b = monitorInfo["Monitor"] @@ -250,10 +248,10 @@ def position(self) -> Optional[Point]: return Point(x, y) return None - def setPosition(self, relativePos: Union[int, Position, Point, Tuple[int, int]], relativeTo: Optional[str]): + def setPosition(self, relativePos: int | Position | Point | tuple[int, int], relativeTo: str | None): # https://stackoverflow.com/questions/35814309/winapi-changedisplaysettingsex-does-not-work # https://stackoverflow.com/questions/195267/use-windows-api-from-c-sharp-to-set-primary-monitor - arrangement: dict[str, dict[str, Optional[Union[str, int, Position, Point, Size]]]] = {} + arrangement: dict[str, dict[str, str | int | Position | Point | Size | None]] = {} monitors = _win32getAllMonitorsDict() monKeys = list(monitors.keys()) if relativePos == Position.PRIMARY or relativePos == (0, 0): @@ -293,7 +291,7 @@ def setPosition(self, relativePos: Union[int, Position, Point, Tuple[int, int]], _arrangeMonitors(arrangement) @property - def box(self) -> Optional[Box]: + def box(self) -> Box | None: monitorInfo = _getMonitorInfo(self.handle) if monitorInfo: x, y, r, b = monitorInfo["Monitor"] @@ -302,7 +300,7 @@ def box(self) -> Optional[Box]: return None @property - def rect(self) -> Optional[Rect]: + def rect(self) -> Rect | None: monitorInfo = _getMonitorInfo(self.handle) if monitorInfo: x, y, r, b = monitorInfo["Monitor"] @@ -311,7 +309,7 @@ def rect(self) -> Optional[Rect]: return None @property - def scale(self) -> Optional[Tuple[float, float]]: + def scale(self) -> tuple[float, float] | None: pScale = ctypes.c_uint() ctypes.windll.shcore.GetScaleFactorForMonitor(self.handle, ctypes.byref(pScale)) # import wmi @@ -321,7 +319,7 @@ def scale(self) -> Optional[Tuple[float, float]]: # print(item) return float(pScale.value), float(pScale.value) - def _getPaths(self) -> Tuple[Optional[_LUID], Optional[int]]: + def _getPaths(self) -> tuple[_LUID | None, int | None]: flags = _QDC_ONLY_ACTIVE_PATHS numPathArrayElements = ctypes.c_uint32() @@ -359,7 +357,7 @@ def _getPaths(self) -> Tuple[Optional[_LUID], Optional[int]]: i += 1 return None, None - def setScale(self, scale: Optional[Tuple[float, float]], applyGlobally: bool = True): + def setScale(self, scale: tuple[float, float] | None, applyGlobally: bool = True): if scale is not None and isinstance(scale, tuple) and scale[0] >= 100 and scale[1] >= 100: # https://github.com/lihas/windows-DPI-scaling-sample/blob/master/DPIHelper/DpiHelper.cpp @@ -409,14 +407,14 @@ def setScale(self, scale: Optional[Tuple[float, float]], applyGlobally: bool = T ctypes.windll.user32.DisplayConfigSetDeviceInfo(ctypes.byref(setScaleData)) @property - def dpi(self) -> Optional[Tuple[float, float]]: + def dpi(self) -> tuple[float, float] | None: dpiX = ctypes.c_uint() dpiY = ctypes.c_uint() ctypes.windll.shcore.GetDpiForMonitor(self.handle, 0, ctypes.byref(dpiX), ctypes.byref(dpiY)) return dpiX.value, dpiY.value @property - def orientation(self) -> Optional[Union[int, Orientation]]: + def orientation(self) -> int | Orientation | None: # Settings content: http://timgolden.me.uk/pywin32-docs/PyDEVMODE.html settings = None try: @@ -430,7 +428,7 @@ def orientation(self) -> Optional[Union[int, Orientation]]: return Orientation(settings.DisplayOrientation) return None - def setOrientation(self, orientation: Optional[Union[int, Orientation]]): + def setOrientation(self, orientation: int | Orientation | None): if orientation is not None and orientation in (Orientation.NORMAL, Orientation.INVERTED, Orientation.LEFT, Orientation.RIGHT): # In most cases an empty struct is required, but in this case we need to retrieve Display Settings first try: @@ -449,7 +447,7 @@ def setOrientation(self, orientation: Optional[Union[int, Orientation]]): # win32api.ChangeDisplaySettingsEx() @property - def frequency(self) -> Optional[float]: + def frequency(self) -> float | None: settings = None try: settings = win32api.EnumDisplaySettings(self.name, win32con.ENUM_CURRENT_SETTINGS) @@ -464,7 +462,7 @@ def frequency(self) -> Optional[float]: refreshRate = frequency @property - def colordepth(self) -> Optional[int]: + def colordepth(self) -> int | None: settings = None try: settings = win32api.EnumDisplaySettings(self.name, win32con.ENUM_CURRENT_SETTINGS) @@ -478,7 +476,7 @@ def colordepth(self) -> Optional[int]: return None @property - def brightness(self) -> Optional[int]: + def brightness(self) -> int | None: minBright = ctypes.c_uint() currBright = ctypes.c_uint() maxBright = ctypes.c_uint() @@ -491,7 +489,7 @@ def brightness(self) -> Optional[int]: return currBright.value return None - def setBrightness(self, brightness: Optional[int]): + def setBrightness(self, brightness: int | None): if brightness is not None and 0 <= brightness <= 100: minBright = ctypes.c_uint() currBright = ctypes.c_uint() @@ -508,7 +506,7 @@ def setBrightness(self, brightness: Optional[int]): ctypes.windll.dxva2.DestroyPhysicalMonitor(hDevice) @property - def contrast(self) -> Optional[int]: + def contrast(self) -> int | None: minCont = ctypes.c_uint() currCont = ctypes.c_uint() maxCont = ctypes.c_uint() @@ -521,7 +519,7 @@ def contrast(self) -> Optional[int]: return currCont.value return None - def setContrast(self, contrast: Optional[int]): + def setContrast(self, contrast: int | None): if contrast is not None and 0 <= contrast <= 100: minCont = ctypes.c_uint() currCont = ctypes.c_uint() @@ -538,7 +536,7 @@ def setContrast(self, contrast: Optional[int]): ctypes.windll.dxva2.DestroyPhysicalMonitor(hDevice) @property - def mode(self) -> Optional[DisplayMode]: + def mode(self) -> DisplayMode | None: settings = None try: settings = win32api.EnumDisplaySettings(self.name, win32con.ENUM_CURRENT_SETTINGS) @@ -551,7 +549,7 @@ def mode(self) -> Optional[DisplayMode]: return DisplayMode(settings.PelsWidth, settings.PelsHeight, settings.DisplayFrequency) return None - def setMode(self, mode: Optional[DisplayMode]): + def setMode(self, mode: DisplayMode | None): if mode is not None: # devmode = win32api.EnumDisplaySettings(self.name, win32con.ENUM_CURRENT_SETTINGS) devmode = pywintypes.DEVMODEType() @@ -563,7 +561,7 @@ def setMode(self, mode: Optional[DisplayMode]): # win32api.ChangeDisplaySettingsEx() @property - def defaultMode(self) -> Optional[DisplayMode]: + def defaultMode(self) -> DisplayMode | None: settings = win32api.EnumDisplaySettings(self.name, win32con.ENUM_REGISTRY_SETTINGS) return DisplayMode(settings.PelsWidth, settings.PelsHeight, settings.DisplayFrequency) @@ -574,7 +572,7 @@ def setDefaultMode(self): @property def allModes(self) -> list[DisplayMode]: - modes: List[DisplayMode] = [] + modes: list[DisplayMode] = [] i = 0 while True: try: @@ -658,8 +656,8 @@ def turnOff(self): 2, win32con.SMTO_ABORTIFHUNG, 100) @property - def isOn(self) -> Optional[bool]: - ret: Optional[bool] = None + def isOn(self) -> bool | None: + ret: bool | None = None if self._hasVCPSupport is None: self._hasVCPSupport = _win32hasVCPSupport(self.handle) self._hasVCPPowerSupport = _win32hasVCPPowerSupport(self.handle) @@ -705,8 +703,8 @@ def suspend(self): 1, win32con.SMTO_ABORTIFHUNG, 100) @property - def isSuspended(self) -> Optional[bool]: - ret: Optional[bool] = None + def isSuspended(self) -> bool | None: + ret: bool | None = None if self._hasVCPSupport is None: self._hasVCPSupport = _win32hasVCPSupport(self.handle) self._hasVCPPowerSupport = _win32hasVCPPowerSupport(self.handle) @@ -747,7 +745,7 @@ def detach(self, permanent: bool = False): _findNewHandles() @property - def isAttached(self) -> Optional[bool]: + def isAttached(self) -> bool | None: # Settings content: http://timgolden.me.uk/pywin32-docs/PyDEVMODE.html # This seems to return invalid values (2 instead of 3) in some cases (monitor 1 affected by monitor 3!?!?!?!) # dev = win32api.EnumDisplayDevices(self.name, 0, 0) @@ -916,7 +914,7 @@ def _eventLoop(kill: threading.Event, interval: float): class NotificationWindow: - def __init__(self): + def __init__(self) -> None: hinst = win32api.GetModuleHandle(None) wndclass = win32gui.WNDCLASS() wndclass.hInstance = hinst # type: ignore[misc] diff --git a/src/pymonctl/_structs.py b/src/pymonctl/_structs.py index 99fd77d..945d598 100644 --- a/src/pymonctl/_structs.py +++ b/src/pymonctl/_structs.py @@ -1,10 +1,9 @@ #!/usr/bin/python -# -*- coding: utf-8 -*- from __future__ import annotations import sys from enum import IntEnum -from typing import NamedTuple, Tuple, TypedDict +from typing import NamedTuple, TypedDict class Box(NamedTuple): @@ -57,8 +56,8 @@ class ScreenValue(TypedDict): position: Point size: Size workarea: Rect - scale: Tuple[float, float] - dpi: Tuple[int, int] + scale: tuple[float, float] + dpi: tuple[int, int] orientation: int frequency: float colordepth: int diff --git a/tests/test_pymonctl.py b/tests/test_pymonctl.py index 43987fd..327794f 100644 --- a/tests/test_pymonctl.py +++ b/tests/test_pymonctl.py @@ -1,9 +1,8 @@ #!/usr/bin/python -# -*- coding: utf-8 -*- from __future__ import annotations import time -from typing import Union, Optional, List, Tuple, cast +from typing import cast import pymonctl as pmc @@ -11,14 +10,14 @@ _TIMELAP = 5 -def pluggedCB(names: List[str], info: dict[str, pmc.ScreenValue]): +def pluggedCB(names: list[str], info: dict[str, pmc.ScreenValue]) -> None: print("MONITOR (UN)PLUGGED!!!") print(names) print(info) print() -def changedCB(names: List[str], info: dict[str, pmc.ScreenValue]): +def changedCB(names: list[str], info: dict[str, pmc.ScreenValue]) -> None: print("MONITOR CHANGED!!!") print(names) print(info) @@ -26,23 +25,26 @@ def changedCB(names: List[str], info: dict[str, pmc.ScreenValue]): print("MONITORS COUNT:", pmc.getMonitorsCount()) -print("PRIMARY MONITOR:", pmc.getPrimary().name) +try: + print("PRIMARY MONITOR:", pmc.getPrimary().name) +except ValueError: + print("PRIMARY MONITOR: None") print() monDict = pmc.getAllMonitorsDict() for mon in monDict: print(monDict[mon]) print() -monitorsPlugged: List[pmc.Monitor] = pmc.getAllMonitors() -initArrangement: List[Tuple[pmc.Monitor, pmc.ScreenValue]] = [] -initDict: dict[str, dict[str, Optional[Union[str, int, pmc.Position, pmc.Point, pmc.Size]]]] = {} -setAsPrimary: Optional[pmc.Monitor] = None +monitorsPlugged: list[pmc.Monitor] = pmc.getAllMonitors() +initArrangement: list[tuple[pmc.Monitor, pmc.ScreenValue]] = [] +initDict: dict[str, dict[str, str | int | pmc.Position | pmc.Point | pmc.Size | None]] = {} +setAsPrimary: pmc.Monitor | None = None try: initArrangement = pmc.saveSetup() print("INITIAL POSITIONS:", initArrangement) except Exception: for monitor in monitorsPlugged: - initDict[monitor.name] = {"relativePos": cast(pmc.Point, monitor.position)} + initDict[monitor.name] = {"relativePos": cast("pmc.Point", monitor.position)} if monitor.isPrimary: setAsPrimary = monitor print("INITIAL POSITIONS:", initDict) @@ -231,7 +233,7 @@ def changedCB(names: List[str], info: dict[str, pmc.ScreenValue]): print() print("CHANGE ARRANGEMENT: MONITOR 2 AS PRIMARY, REST OF MONITORS AT LEFT_BOTTOM") - arrangement: dict[str, dict[str, Union[str, int, pmc.Position, pmc.Point, pmc.Size, None]]] = { + arrangement: dict[str, dict[str, str | int | pmc.Position | pmc.Point | pmc.Size | None]] = { str(mon2.name): {"relativePos": pmc.Position.PRIMARY, "relativeTo": ""} } relativeTo = mon2.name diff --git a/uv.lock b/uv.lock index 7d716dc..f20c6c0 100644 --- a/uv.lock +++ b/uv.lock @@ -797,6 +797,7 @@ dev = [ { name = "myst-parser", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { 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 = "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" }, @@ -820,6 +821,7 @@ dev = [ { name = "ewmhlib" }, { name = "mypy", specifier = ">=0.990,<2" }, { name = "myst-parser" }, + { name = "ruff", specifier = ">=0.15.16" }, { name = "types-python-xlib", specifier = ">=0.32" }, { name = "types-pywin32", specifier = "<312.0.0.20260609" }, ] @@ -8423,6 +8425,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"