hyprland-socket 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,29 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ lint:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+ - uses: astral-sh/setup-uv@v6
15
+ - run: uv sync
16
+ - run: uv run ruff check src/ tests/
17
+ - run: uv run ruff format --check src/ tests/
18
+
19
+ test:
20
+ runs-on: ubuntu-latest
21
+ strategy:
22
+ matrix:
23
+ python-version: ["3.12", "3.13"]
24
+ steps:
25
+ - uses: actions/checkout@v4
26
+ - uses: astral-sh/setup-uv@v6
27
+ - run: uv python install ${{ matrix.python-version }}
28
+ - run: uv sync --python ${{ matrix.python-version }}
29
+ - run: uv run pytest tests/ -v
@@ -0,0 +1,18 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ permissions:
8
+ id-token: write
9
+
10
+ jobs:
11
+ publish:
12
+ runs-on: ubuntu-latest
13
+ environment: pypi
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ - uses: astral-sh/setup-uv@v6
17
+ - run: uv build
18
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,7 @@
1
+ .idea/
2
+ __pycache__/
3
+ *.pyc
4
+ dist/
5
+ *.egg-info/
6
+ .venv/
7
+ .pytest_cache/
@@ -0,0 +1,18 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ivo Šmerek
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
6
+ associated documentation files (the "Software"), to deal in the Software without restriction, including
7
+ without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8
+ copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
9
+ following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be included in all copies or substantial
12
+ portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
15
+ LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
16
+ EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
17
+ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
18
+ USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,125 @@
1
+ Metadata-Version: 2.4
2
+ Name: hyprland-socket
3
+ Version: 0.1.0
4
+ Summary: Typed Python library for Hyprland IPC via Unix sockets
5
+ Project-URL: Repository, https://github.com/BlueManCZ/hyprland-socket
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Keywords: compositor,hyprland,ipc,wayland
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: POSIX :: Linux
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Topic :: Desktop Environment
15
+ Requires-Python: >=3.12
16
+ Description-Content-Type: text/markdown
17
+
18
+ # hyprland-socket
19
+
20
+ Typed Python library for [Hyprland](https://hyprland.org/) IPC via Unix sockets.
21
+
22
+ Covers both read and write operations — querying state, applying settings live,
23
+ batch commands, and monitoring events.
24
+
25
+ ## Installation
26
+
27
+ ```
28
+ pip install hyprland-socket
29
+ ```
30
+
31
+ ## Usage
32
+
33
+ ### Query state
34
+
35
+ ```python
36
+ import hyprland_socket
37
+
38
+ # Check if Hyprland is running
39
+ if hyprland_socket.is_running():
40
+ # Read monitors
41
+ for mon in hyprland_socket.get_monitors():
42
+ print(f"{mon.name}: {mon.width}x{mon.height} @ {mon.refresh_rate}Hz")
43
+
44
+ # Read a live option
45
+ option = hyprland_socket.getoption("general:gaps_in")
46
+ print(option)
47
+
48
+ # Read keybinds
49
+ for bind in hyprland_socket.get_binds():
50
+ print(f"{bind.key} -> {bind.dispatcher} {bind.arg}")
51
+ ```
52
+
53
+ ### Apply settings
54
+
55
+ ```python
56
+ import hyprland_socket
57
+
58
+ # Set a single option
59
+ hyprland_socket.keyword("general:gaps_in", 5)
60
+
61
+ # Batch multiple settings (single IPC call)
62
+ hyprland_socket.keyword_batch([
63
+ ("general:gaps_in", "5"),
64
+ ("general:gaps_out", "10"),
65
+ ("decoration:rounding", "8"),
66
+ ])
67
+
68
+ # Reload config from disk
69
+ hyprland_socket.reload()
70
+ ```
71
+
72
+ ### Monitor events
73
+
74
+ ```python
75
+ import hyprland_socket
76
+
77
+ # Blocking iterator over compositor events
78
+ for event in hyprland_socket.events():
79
+ print(f"{event.name}: {event.data}")
80
+ # e.g. "workspace: 2", "monitoradded: DP-3"
81
+ ```
82
+
83
+ For integration with GTK/GLib event loops, use the raw socket:
84
+
85
+ ```python
86
+ sock = hyprland_socket.connect_event_socket()
87
+ fd = sock.fileno()
88
+ # Use GLib.io_add_watch(fd, ...) or similar
89
+ ```
90
+
91
+ ## Error handling
92
+
93
+ All functions raise typed exceptions instead of returning `None`:
94
+
95
+ ```python
96
+ from hyprland_socket import ConnectionError, CommandError
97
+
98
+ try:
99
+ hyprland_socket.keyword("invalid:option", "value")
100
+ except ConnectionError:
101
+ print("Hyprland is not running")
102
+ except CommandError as e:
103
+ print(f"Rejected: {e}")
104
+ ```
105
+
106
+ ## Models
107
+
108
+ | Function | Returns |
109
+ |---|---|
110
+ | `get_monitors()` | `list[Monitor]` |
111
+ | `get_binds()` | `list[Bind]` |
112
+ | `get_animations()` | `tuple[list[Animation], list[dict]]` |
113
+ | `getoption(key)` | `dict` |
114
+
115
+ All models are mutable dataclasses with a `from_dict()` classmethod for
116
+ construction from Hyprland's JSON responses.
117
+
118
+ ## Requirements
119
+
120
+ - Python >= 3.12
121
+ - A running Hyprland session (the `HYPRLAND_INSTANCE_SIGNATURE` environment variable must be set)
122
+
123
+ ## License
124
+
125
+ MIT
@@ -0,0 +1,108 @@
1
+ # hyprland-socket
2
+
3
+ Typed Python library for [Hyprland](https://hyprland.org/) IPC via Unix sockets.
4
+
5
+ Covers both read and write operations — querying state, applying settings live,
6
+ batch commands, and monitoring events.
7
+
8
+ ## Installation
9
+
10
+ ```
11
+ pip install hyprland-socket
12
+ ```
13
+
14
+ ## Usage
15
+
16
+ ### Query state
17
+
18
+ ```python
19
+ import hyprland_socket
20
+
21
+ # Check if Hyprland is running
22
+ if hyprland_socket.is_running():
23
+ # Read monitors
24
+ for mon in hyprland_socket.get_monitors():
25
+ print(f"{mon.name}: {mon.width}x{mon.height} @ {mon.refresh_rate}Hz")
26
+
27
+ # Read a live option
28
+ option = hyprland_socket.getoption("general:gaps_in")
29
+ print(option)
30
+
31
+ # Read keybinds
32
+ for bind in hyprland_socket.get_binds():
33
+ print(f"{bind.key} -> {bind.dispatcher} {bind.arg}")
34
+ ```
35
+
36
+ ### Apply settings
37
+
38
+ ```python
39
+ import hyprland_socket
40
+
41
+ # Set a single option
42
+ hyprland_socket.keyword("general:gaps_in", 5)
43
+
44
+ # Batch multiple settings (single IPC call)
45
+ hyprland_socket.keyword_batch([
46
+ ("general:gaps_in", "5"),
47
+ ("general:gaps_out", "10"),
48
+ ("decoration:rounding", "8"),
49
+ ])
50
+
51
+ # Reload config from disk
52
+ hyprland_socket.reload()
53
+ ```
54
+
55
+ ### Monitor events
56
+
57
+ ```python
58
+ import hyprland_socket
59
+
60
+ # Blocking iterator over compositor events
61
+ for event in hyprland_socket.events():
62
+ print(f"{event.name}: {event.data}")
63
+ # e.g. "workspace: 2", "monitoradded: DP-3"
64
+ ```
65
+
66
+ For integration with GTK/GLib event loops, use the raw socket:
67
+
68
+ ```python
69
+ sock = hyprland_socket.connect_event_socket()
70
+ fd = sock.fileno()
71
+ # Use GLib.io_add_watch(fd, ...) or similar
72
+ ```
73
+
74
+ ## Error handling
75
+
76
+ All functions raise typed exceptions instead of returning `None`:
77
+
78
+ ```python
79
+ from hyprland_socket import ConnectionError, CommandError
80
+
81
+ try:
82
+ hyprland_socket.keyword("invalid:option", "value")
83
+ except ConnectionError:
84
+ print("Hyprland is not running")
85
+ except CommandError as e:
86
+ print(f"Rejected: {e}")
87
+ ```
88
+
89
+ ## Models
90
+
91
+ | Function | Returns |
92
+ |---|---|
93
+ | `get_monitors()` | `list[Monitor]` |
94
+ | `get_binds()` | `list[Bind]` |
95
+ | `get_animations()` | `tuple[list[Animation], list[dict]]` |
96
+ | `getoption(key)` | `dict` |
97
+
98
+ All models are mutable dataclasses with a `from_dict()` classmethod for
99
+ construction from Hyprland's JSON responses.
100
+
101
+ ## Requirements
102
+
103
+ - Python >= 3.12
104
+ - A running Hyprland session (the `HYPRLAND_INSTANCE_SIGNATURE` environment variable must be set)
105
+
106
+ ## License
107
+
108
+ MIT
@@ -0,0 +1,26 @@
1
+ [project]
2
+ name = "hyprland-socket"
3
+ version = "0.1.0"
4
+ description = "Typed Python library for Hyprland IPC via Unix sockets"
5
+ requires-python = ">=3.12"
6
+ license = "MIT"
7
+ readme = "README.md"
8
+ keywords = ["hyprland", "ipc", "wayland", "compositor"]
9
+ classifiers = [
10
+ "Development Status :: 3 - Alpha",
11
+ "License :: OSI Approved :: MIT License",
12
+ "Operating System :: POSIX :: Linux",
13
+ "Programming Language :: Python :: 3.12",
14
+ "Programming Language :: Python :: 3.13",
15
+ "Topic :: Desktop Environment",
16
+ ]
17
+
18
+ [project.urls]
19
+ Repository = "https://github.com/BlueManCZ/hyprland-socket"
20
+
21
+ [dependency-groups]
22
+ dev = ["pytest>=9.0", "ruff>=0.11"]
23
+
24
+ [build-system]
25
+ requires = ["hatchling"]
26
+ build-backend = "hatchling.build"
@@ -0,0 +1,35 @@
1
+ """hyprland-socket — Typed Python library for Hyprland IPC."""
2
+
3
+ from .commands import (
4
+ get_animations,
5
+ get_binds,
6
+ get_monitors,
7
+ getoption,
8
+ is_running,
9
+ keyword,
10
+ keyword_batch,
11
+ reload,
12
+ )
13
+ from .errors import CommandError, ConnectionError, HyprlandError
14
+ from .events import Event, connect_event_socket, events
15
+ from .models import Animation, Bind, Monitor
16
+
17
+ __all__ = [
18
+ "Animation",
19
+ "Bind",
20
+ "CommandError",
21
+ "ConnectionError",
22
+ "Event",
23
+ "HyprlandError",
24
+ "Monitor",
25
+ "connect_event_socket",
26
+ "events",
27
+ "get_animations",
28
+ "get_binds",
29
+ "get_monitors",
30
+ "getoption",
31
+ "is_running",
32
+ "keyword",
33
+ "keyword_batch",
34
+ "reload",
35
+ ]
@@ -0,0 +1,48 @@
1
+ """Low-level Unix socket communication with Hyprland."""
2
+
3
+ import os
4
+ import socket
5
+
6
+ from .errors import ConnectionError
7
+
8
+
9
+ def _socket_path() -> str:
10
+ """Return the Hyprland command socket path."""
11
+ runtime = os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}")
12
+ sig = os.environ["HYPRLAND_INSTANCE_SIGNATURE"]
13
+ return f"{runtime}/hypr/{sig}/.socket.sock"
14
+
15
+
16
+ def _event_socket_path() -> str:
17
+ """Return the Hyprland event socket path (socket2)."""
18
+ runtime = os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}")
19
+ sig = os.environ["HYPRLAND_INSTANCE_SIGNATURE"]
20
+ return f"{runtime}/hypr/{sig}/.socket2.sock"
21
+
22
+
23
+ def _send(command: str, timeout: float = 2.0) -> str:
24
+ """Send a command to Hyprland's Unix socket and return the response.
25
+
26
+ Opens a fresh connection for each command and closes immediately
27
+ after reading — Hyprland processes connections synchronously and
28
+ an unclosed socket will freeze the compositor.
29
+
30
+ Raises ConnectionError if the socket is unreachable.
31
+ """
32
+ try:
33
+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
34
+ sock.settimeout(timeout)
35
+ sock.connect(_socket_path())
36
+ try:
37
+ sock.sendall(command.encode())
38
+ chunks = []
39
+ while True:
40
+ chunk = sock.recv(8192)
41
+ if not chunk:
42
+ break
43
+ chunks.append(chunk)
44
+ return b"".join(chunks).decode()
45
+ finally:
46
+ sock.close()
47
+ except Exception as e:
48
+ raise ConnectionError(f"Cannot reach Hyprland socket: {e}") from e
@@ -0,0 +1,90 @@
1
+ """High-level command functions for Hyprland IPC."""
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+ from ._socket import _send
7
+ from .errors import CommandError, ConnectionError
8
+ from .models import Animation, Bind, Monitor
9
+
10
+
11
+ def _query_json(command: str) -> Any:
12
+ """Send a JSON query and return parsed result."""
13
+ response = _send(f"j/{command}")
14
+ try:
15
+ return json.loads(response)
16
+ except (json.JSONDecodeError, ValueError) as e:
17
+ raise CommandError(f"Invalid JSON response for '{command}': {e}") from e
18
+
19
+
20
+ def getoption(key: str) -> dict:
21
+ """Read a live option value from Hyprland.
22
+
23
+ Raises ConnectionError or CommandError on failure.
24
+ """
25
+ return _query_json(f"getoption {key}")
26
+
27
+
28
+ def keyword(key: str, value: Any) -> None:
29
+ """Apply a setting live to the running compositor.
30
+
31
+ Raises CommandError if Hyprland rejects the command.
32
+ """
33
+ if isinstance(value, bool):
34
+ value = int(value)
35
+ response = _send(f"/keyword {key} {value}")
36
+ output = response.strip().lower()
37
+ if output != "ok" and output != "":
38
+ raise CommandError(f"keyword '{key} {value}' rejected: {response.strip()}")
39
+
40
+
41
+ def keyword_batch(commands: list[tuple[str, str]]) -> None:
42
+ """Apply multiple keyword settings in a single batch call.
43
+
44
+ Raises CommandError on failure.
45
+ """
46
+ if not commands:
47
+ return
48
+ batch = ";".join(f"keyword {key} {value}" for key, value in commands)
49
+ _send(f"[[BATCH]]{batch}", timeout=5.0)
50
+
51
+
52
+ def reload() -> None:
53
+ """Tell Hyprland to reload its config.
54
+
55
+ Raises ConnectionError if unreachable.
56
+ """
57
+ _send("/reload")
58
+
59
+
60
+ def get_binds() -> list[Bind]:
61
+ """Read all keybinds from Hyprland."""
62
+ data = _query_json("binds")
63
+ return [Bind.from_dict(b) for b in data]
64
+
65
+
66
+ def get_monitors() -> list[Monitor]:
67
+ """Read all monitors from Hyprland."""
68
+ data = _query_json("monitors")
69
+ return [Monitor.from_dict(m) for m in data]
70
+
71
+
72
+ def get_animations() -> tuple[list[Animation], list[dict]]:
73
+ """Read all animations and bezier curves from Hyprland.
74
+
75
+ Returns (animations_list, curves_list).
76
+ """
77
+ data = _query_json("animations")
78
+ if not isinstance(data, list) or len(data) != 2:
79
+ raise CommandError(f"Unexpected animations response format: {type(data)}")
80
+ animations = [Animation.from_dict(a) for a in data[0]]
81
+ return animations, data[1]
82
+
83
+
84
+ def is_running() -> bool:
85
+ """Check if a Hyprland instance is reachable."""
86
+ try:
87
+ getoption("general:gaps_in")
88
+ return True
89
+ except (ConnectionError, CommandError):
90
+ return False
@@ -0,0 +1,13 @@
1
+ """Exception hierarchy for Hyprland IPC errors."""
2
+
3
+
4
+ class HyprlandError(Exception):
5
+ """Base exception for all Hyprland IPC errors."""
6
+
7
+
8
+ class ConnectionError(HyprlandError):
9
+ """Cannot reach the Hyprland socket."""
10
+
11
+
12
+ class CommandError(HyprlandError):
13
+ """Hyprland rejected a command."""
@@ -0,0 +1,63 @@
1
+ """Hyprland event monitoring via socket2."""
2
+
3
+ import socket
4
+ from collections.abc import Iterator
5
+ from dataclasses import dataclass
6
+
7
+ from ._socket import _event_socket_path
8
+ from .errors import ConnectionError
9
+
10
+
11
+ @dataclass
12
+ class Event:
13
+ name: str
14
+ data: str
15
+
16
+
17
+ def connect_event_socket(timeout: float | None = None) -> socket.socket:
18
+ """Connect to Hyprland's event socket and return the raw socket.
19
+
20
+ The caller owns the socket and must close it. The raw fd can be
21
+ used with external event loops (e.g. GLib.io_add_watch).
22
+ """
23
+ try:
24
+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
25
+ if timeout is not None:
26
+ sock.settimeout(timeout)
27
+ sock.connect(_event_socket_path())
28
+ return sock
29
+ except Exception as e:
30
+ raise ConnectionError(f"Cannot reach Hyprland event socket: {e}") from e
31
+
32
+
33
+ def _parse_event_line(line: str) -> Event | None:
34
+ """Parse a single event line into an Event."""
35
+ line = line.strip()
36
+ if not line:
37
+ return None
38
+ if ">>" in line:
39
+ name, data = line.split(">>", 1)
40
+ return Event(name=name, data=data)
41
+ return Event(name=line, data="")
42
+
43
+
44
+ def events(timeout: float | None = None) -> Iterator[Event]:
45
+ """Yield events from Hyprland's event socket. Blocking iterator."""
46
+ sock = connect_event_socket(timeout)
47
+ try:
48
+ buf = ""
49
+ while True:
50
+ try:
51
+ chunk = sock.recv(4096)
52
+ except TimeoutError:
53
+ return
54
+ if not chunk:
55
+ break
56
+ buf += chunk.decode()
57
+ while "\n" in buf:
58
+ line, buf = buf.split("\n", 1)
59
+ event = _parse_event_line(line)
60
+ if event is not None:
61
+ yield event
62
+ finally:
63
+ sock.close()
@@ -0,0 +1,96 @@
1
+ """Typed dataclasses for Hyprland IPC responses."""
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+
6
+ @dataclass
7
+ class Monitor:
8
+ name: str
9
+ make: str
10
+ model: str
11
+ width: int
12
+ height: int
13
+ refresh_rate: float
14
+ x: int
15
+ y: int
16
+ scale: float
17
+ transform: int = 0
18
+ focused: bool = False
19
+ current_format: str = ""
20
+ available_modes: list[str] = field(default_factory=list)
21
+ bitdepth: str | None = None
22
+ vrr: str | None = None
23
+ cm: str | None = None
24
+
25
+ @classmethod
26
+ def from_dict(cls, data: dict) -> "Monitor":
27
+ # Infer bitdepth from pixel format
28
+ fmt = data.get("currentFormat", "")
29
+ bitdepth = None
30
+ if "2101010" in fmt or "16161616" in fmt:
31
+ bitdepth = "10"
32
+
33
+ # Color management preset
34
+ cm_raw = data.get("colorManagementPreset")
35
+ cm = cm_raw if cm_raw and cm_raw not in ("default", "srgb") else None
36
+
37
+ return cls(
38
+ name=data["name"],
39
+ make=data.get("make", ""),
40
+ model=data.get("model", ""),
41
+ width=data["width"],
42
+ height=data["height"],
43
+ refresh_rate=data["refreshRate"],
44
+ x=data["x"],
45
+ y=data["y"],
46
+ scale=data["scale"],
47
+ transform=data.get("transform", 0),
48
+ focused=data.get("focused", False),
49
+ current_format=data.get("currentFormat", ""),
50
+ available_modes=[
51
+ m
52
+ if isinstance(m, str)
53
+ else f"{m['width']}x{m['height']}@{m['refreshRate']:.2f}Hz"
54
+ for m in data.get("availableModes", [])
55
+ ],
56
+ bitdepth=bitdepth,
57
+ cm=cm,
58
+ )
59
+
60
+
61
+ @dataclass
62
+ class Bind:
63
+ modmask: int
64
+ key: str
65
+ dispatcher: str
66
+ arg: str
67
+
68
+ @classmethod
69
+ def from_dict(cls, data: dict) -> "Bind":
70
+ return cls(
71
+ modmask=data["modmask"],
72
+ key=data["key"],
73
+ dispatcher=data["dispatcher"],
74
+ arg=data["arg"],
75
+ )
76
+
77
+
78
+ @dataclass
79
+ class Animation:
80
+ name: str
81
+ overridden: bool
82
+ enabled: bool
83
+ speed: float
84
+ bezier: str
85
+ style: str = ""
86
+
87
+ @classmethod
88
+ def from_dict(cls, data: dict) -> "Animation":
89
+ return cls(
90
+ name=data["name"],
91
+ overridden=data["overridden"],
92
+ enabled=data["enabled"],
93
+ speed=data["speed"],
94
+ bezier=data["bezier"],
95
+ style=data.get("style", ""),
96
+ )
File without changes
@@ -0,0 +1,32 @@
1
+ """Tests for event parsing."""
2
+
3
+ from hyprland_socket.events import Event, _parse_event_line
4
+
5
+
6
+ class TestParseEventLine:
7
+ def test_basic_event(self):
8
+ event = _parse_event_line("workspace>>2")
9
+ assert event == Event(name="workspace", data="2")
10
+
11
+ def test_monitor_added(self):
12
+ event = _parse_event_line("monitoradded>>DP-3")
13
+ assert event == Event(name="monitoradded", data="DP-3")
14
+
15
+ def test_event_with_comma_data(self):
16
+ event = _parse_event_line("openwindow>>80abc,2,kitty,Alacritty")
17
+ assert event.name == "openwindow"
18
+ assert event.data == "80abc,2,kitty,Alacritty"
19
+
20
+ def test_empty_data(self):
21
+ event = _parse_event_line("configreloaded>>")
22
+ assert event == Event(name="configreloaded", data="")
23
+
24
+ def test_empty_line(self):
25
+ assert _parse_event_line("") is None
26
+
27
+ def test_whitespace_only(self):
28
+ assert _parse_event_line(" ") is None
29
+
30
+ def test_no_separator(self):
31
+ event = _parse_event_line("someevent")
32
+ assert event == Event(name="someevent", data="")
@@ -0,0 +1,149 @@
1
+ """Tests for model construction from Hyprland JSON dicts."""
2
+
3
+ from hyprland_socket.models import Animation, Bind, Monitor
4
+
5
+
6
+ class TestMonitorFromDict:
7
+ SAMPLE = {
8
+ "name": "DP-2",
9
+ "make": "Samsung",
10
+ "model": "Odyssey G9",
11
+ "width": 3440,
12
+ "height": 1440,
13
+ "refreshRate": 99.98,
14
+ "x": 0,
15
+ "y": 0,
16
+ "scale": 1.6,
17
+ "transform": 0,
18
+ "focused": True,
19
+ "currentFormat": "XRGB8888",
20
+ "availableModes": ["3440x1440@99.98Hz", "2560x1440@60.00Hz"],
21
+ }
22
+
23
+ def test_basic_fields(self):
24
+ m = Monitor.from_dict(self.SAMPLE)
25
+ assert m.name == "DP-2"
26
+ assert m.width == 3440
27
+ assert m.height == 1440
28
+ assert m.refresh_rate == 99.98
29
+ assert m.scale == 1.6
30
+
31
+ def test_optional_fields(self):
32
+ m = Monitor.from_dict(self.SAMPLE)
33
+ assert m.focused is True
34
+ assert m.current_format == "XRGB8888"
35
+ assert m.transform == 0
36
+
37
+ def test_available_modes(self):
38
+ m = Monitor.from_dict(self.SAMPLE)
39
+ assert m.available_modes == ["3440x1440@99.98Hz", "2560x1440@60.00Hz"]
40
+
41
+ def test_missing_optional_fields(self):
42
+ minimal = {
43
+ "name": "eDP-1",
44
+ "width": 1920,
45
+ "height": 1080,
46
+ "refreshRate": 60.0,
47
+ "x": 0,
48
+ "y": 0,
49
+ "scale": 1.0,
50
+ }
51
+ m = Monitor.from_dict(minimal)
52
+ assert m.make == ""
53
+ assert m.model == ""
54
+ assert m.transform == 0
55
+ assert m.focused is False
56
+ assert m.current_format == ""
57
+ assert m.available_modes == []
58
+ assert m.bitdepth is None
59
+ assert m.vrr is None
60
+ assert m.cm is None
61
+
62
+ def test_bitdepth_inferred_from_format(self):
63
+ data = {**self.SAMPLE, "currentFormat": "XRGB2101010"}
64
+ m = Monitor.from_dict(data)
65
+ assert m.bitdepth == "10"
66
+
67
+ def test_bitdepth_none_for_8bit(self):
68
+ data = {**self.SAMPLE, "currentFormat": "XRGB8888"}
69
+ m = Monitor.from_dict(data)
70
+ assert m.bitdepth is None
71
+
72
+ def test_cm_from_preset(self):
73
+ data = {**self.SAMPLE, "colorManagementPreset": "hdr"}
74
+ m = Monitor.from_dict(data)
75
+ assert m.cm == "hdr"
76
+
77
+ def test_cm_none_for_default(self):
78
+ data = {**self.SAMPLE, "colorManagementPreset": "srgb"}
79
+ m = Monitor.from_dict(data)
80
+ assert m.cm is None
81
+
82
+ def test_available_modes_as_dicts(self):
83
+ data = {
84
+ **self.SAMPLE,
85
+ "availableModes": [
86
+ {"width": 1920, "height": 1080, "refreshRate": 60.0},
87
+ ],
88
+ }
89
+ m = Monitor.from_dict(data)
90
+ assert m.available_modes == ["1920x1080@60.00Hz"]
91
+
92
+
93
+ class TestBindFromDict:
94
+ def test_basic(self):
95
+ b = Bind.from_dict(
96
+ {
97
+ "modmask": 64,
98
+ "key": "Q",
99
+ "dispatcher": "killactive",
100
+ "arg": "",
101
+ }
102
+ )
103
+ assert b.modmask == 64
104
+ assert b.key == "Q"
105
+ assert b.dispatcher == "killactive"
106
+ assert b.arg == ""
107
+
108
+ def test_with_arg(self):
109
+ b = Bind.from_dict(
110
+ {
111
+ "modmask": 64,
112
+ "key": "1",
113
+ "dispatcher": "workspace",
114
+ "arg": "1",
115
+ }
116
+ )
117
+ assert b.arg == "1"
118
+
119
+
120
+ class TestAnimationFromDict:
121
+ def test_basic(self):
122
+ a = Animation.from_dict(
123
+ {
124
+ "name": "windows",
125
+ "overridden": True,
126
+ "enabled": True,
127
+ "speed": 6.0,
128
+ "bezier": "default",
129
+ "style": "slide",
130
+ }
131
+ )
132
+ assert a.name == "windows"
133
+ assert a.overridden is True
134
+ assert a.enabled is True
135
+ assert a.speed == 6.0
136
+ assert a.bezier == "default"
137
+ assert a.style == "slide"
138
+
139
+ def test_missing_style(self):
140
+ a = Animation.from_dict(
141
+ {
142
+ "name": "fade",
143
+ "overridden": False,
144
+ "enabled": True,
145
+ "speed": 7.0,
146
+ "bezier": "default",
147
+ }
148
+ )
149
+ assert a.style == ""
@@ -0,0 +1,108 @@
1
+ version = 1
2
+ revision = 3
3
+ requires-python = ">=3.12"
4
+
5
+ [[package]]
6
+ name = "colorama"
7
+ version = "0.4.6"
8
+ source = { registry = "https://pypi.org/simple" }
9
+ sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
10
+ wheels = [
11
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
12
+ ]
13
+
14
+ [[package]]
15
+ name = "hyprland-socket"
16
+ version = "0.1.0"
17
+ source = { editable = "." }
18
+
19
+ [package.dev-dependencies]
20
+ dev = [
21
+ { name = "pytest" },
22
+ { name = "ruff" },
23
+ ]
24
+
25
+ [package.metadata]
26
+
27
+ [package.metadata.requires-dev]
28
+ dev = [
29
+ { name = "pytest", specifier = ">=9.0" },
30
+ { name = "ruff", specifier = ">=0.11" },
31
+ ]
32
+
33
+ [[package]]
34
+ name = "iniconfig"
35
+ version = "2.3.0"
36
+ source = { registry = "https://pypi.org/simple" }
37
+ sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
38
+ wheels = [
39
+ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
40
+ ]
41
+
42
+ [[package]]
43
+ name = "packaging"
44
+ version = "26.0"
45
+ source = { registry = "https://pypi.org/simple" }
46
+ sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
47
+ wheels = [
48
+ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
49
+ ]
50
+
51
+ [[package]]
52
+ name = "pluggy"
53
+ version = "1.6.0"
54
+ source = { registry = "https://pypi.org/simple" }
55
+ sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
56
+ wheels = [
57
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
58
+ ]
59
+
60
+ [[package]]
61
+ name = "pygments"
62
+ version = "2.19.2"
63
+ source = { registry = "https://pypi.org/simple" }
64
+ sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
65
+ wheels = [
66
+ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
67
+ ]
68
+
69
+ [[package]]
70
+ name = "pytest"
71
+ version = "9.0.2"
72
+ source = { registry = "https://pypi.org/simple" }
73
+ dependencies = [
74
+ { name = "colorama", marker = "sys_platform == 'win32'" },
75
+ { name = "iniconfig" },
76
+ { name = "packaging" },
77
+ { name = "pluggy" },
78
+ { name = "pygments" },
79
+ ]
80
+ sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
81
+ wheels = [
82
+ { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
83
+ ]
84
+
85
+ [[package]]
86
+ name = "ruff"
87
+ version = "0.15.6"
88
+ source = { registry = "https://pypi.org/simple" }
89
+ sdist = { url = "https://files.pythonhosted.org/packages/51/df/f8629c19c5318601d3121e230f74cbee7a3732339c52b21daa2b82ef9c7d/ruff-0.15.6.tar.gz", hash = "sha256:8394c7bb153a4e3811a4ecdacd4a8e6a4fa8097028119160dffecdcdf9b56ae4", size = 4597916, upload-time = "2026-03-12T23:05:47.51Z" }
90
+ wheels = [
91
+ { url = "https://files.pythonhosted.org/packages/9e/2f/4e03a7e5ce99b517e98d3b4951f411de2b0fa8348d39cf446671adcce9a2/ruff-0.15.6-py3-none-linux_armv6l.whl", hash = "sha256:7c98c3b16407b2cf3d0f2b80c80187384bc92c6774d85fefa913ecd941256fff", size = 10508953, upload-time = "2026-03-12T23:05:17.246Z" },
92
+ { url = "https://files.pythonhosted.org/packages/70/60/55bcdc3e9f80bcf39edf0cd272da6fa511a3d94d5a0dd9e0adf76ceebdb4/ruff-0.15.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ee7dcfaad8b282a284df4aa6ddc2741b3f4a18b0555d626805555a820ea181c3", size = 10942257, upload-time = "2026-03-12T23:05:23.076Z" },
93
+ { url = "https://files.pythonhosted.org/packages/e7/f9/005c29bd1726c0f492bfa215e95154cf480574140cb5f867c797c18c790b/ruff-0.15.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3bd9967851a25f038fc8b9ae88a7fbd1b609f30349231dffaa37b6804923c4bb", size = 10322683, upload-time = "2026-03-12T23:05:33.738Z" },
94
+ { url = "https://files.pythonhosted.org/packages/5f/74/2f861f5fd7cbb2146bddb5501450300ce41562da36d21868c69b7a828169/ruff-0.15.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13f4594b04e42cd24a41da653886b04d2ff87adbf57497ed4f728b0e8a4866f8", size = 10660986, upload-time = "2026-03-12T23:05:53.245Z" },
95
+ { url = "https://files.pythonhosted.org/packages/c1/a1/309f2364a424eccb763cdafc49df843c282609f47fe53aa83f38272389e0/ruff-0.15.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e2ed8aea2f3fe57886d3f00ea5b8aae5bf68d5e195f487f037a955ff9fbaac9e", size = 10332177, upload-time = "2026-03-12T23:05:56.145Z" },
96
+ { url = "https://files.pythonhosted.org/packages/30/41/7ebf1d32658b4bab20f8ac80972fb19cd4e2c6b78552be263a680edc55ac/ruff-0.15.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:70789d3e7830b848b548aae96766431c0dc01a6c78c13381f423bf7076c66d15", size = 11170783, upload-time = "2026-03-12T23:06:01.742Z" },
97
+ { url = "https://files.pythonhosted.org/packages/76/be/6d488f6adca047df82cd62c304638bcb00821c36bd4881cfca221561fdfc/ruff-0.15.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:542aaf1de3154cea088ced5a819ce872611256ffe2498e750bbae5247a8114e9", size = 12044201, upload-time = "2026-03-12T23:05:28.697Z" },
98
+ { url = "https://files.pythonhosted.org/packages/71/68/e6f125df4af7e6d0b498f8d373274794bc5156b324e8ab4bf5c1b4fc0ec7/ruff-0.15.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c22e6f02c16cfac3888aa636e9eba857254d15bbacc9906c9689fdecb1953ab", size = 11421561, upload-time = "2026-03-12T23:05:31.236Z" },
99
+ { url = "https://files.pythonhosted.org/packages/f1/9f/f85ef5fd01a52e0b472b26dc1b4bd228b8f6f0435975442ffa4741278703/ruff-0.15.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98893c4c0aadc8e448cfa315bd0cc343a5323d740fe5f28ef8a3f9e21b381f7e", size = 11310928, upload-time = "2026-03-12T23:05:45.288Z" },
100
+ { url = "https://files.pythonhosted.org/packages/8c/26/b75f8c421f5654304b89471ed384ae8c7f42b4dff58fa6ce1626d7f2b59a/ruff-0.15.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:70d263770d234912374493e8cc1e7385c5d49376e41dfa51c5c3453169dc581c", size = 11235186, upload-time = "2026-03-12T23:05:50.677Z" },
101
+ { url = "https://files.pythonhosted.org/packages/fc/d4/d5a6d065962ff7a68a86c9b4f5500f7d101a0792078de636526c0edd40da/ruff-0.15.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:55a1ad63c5a6e54b1f21b7514dfadc0c7fb40093fa22e95143cf3f64ebdcd512", size = 10635231, upload-time = "2026-03-12T23:05:37.044Z" },
102
+ { url = "https://files.pythonhosted.org/packages/d6/56/7c3acf3d50910375349016cf33de24be021532042afbed87942858992491/ruff-0.15.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8dc473ba093c5ec238bb1e7429ee676dca24643c471e11fbaa8a857925b061c0", size = 10340357, upload-time = "2026-03-12T23:06:04.748Z" },
103
+ { url = "https://files.pythonhosted.org/packages/06/54/6faa39e9c1033ff6a3b6e76b5df536931cd30caf64988e112bbf91ef5ce5/ruff-0.15.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:85b042377c2a5561131767974617006f99f7e13c63c111b998f29fc1e58a4cfb", size = 10860583, upload-time = "2026-03-12T23:05:58.978Z" },
104
+ { url = "https://files.pythonhosted.org/packages/cb/1e/509a201b843b4dfb0b32acdedf68d951d3377988cae43949ba4c4133a96a/ruff-0.15.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:cef49e30bc5a86a6a92098a7fbf6e467a234d90b63305d6f3ec01225a9d092e0", size = 11410976, upload-time = "2026-03-12T23:05:39.955Z" },
105
+ { url = "https://files.pythonhosted.org/packages/6c/25/3fc9114abf979a41673ce877c08016f8e660ad6cf508c3957f537d2e9fa9/ruff-0.15.6-py3-none-win32.whl", hash = "sha256:bbf67d39832404812a2d23020dda68fee7f18ce15654e96fb1d3ad21a5fe436c", size = 10616872, upload-time = "2026-03-12T23:05:42.451Z" },
106
+ { url = "https://files.pythonhosted.org/packages/89/7a/09ece68445ceac348df06e08bf75db72d0e8427765b96c9c0ffabc1be1d9/ruff-0.15.6-py3-none-win_amd64.whl", hash = "sha256:aee25bc84c2f1007ecb5037dff75cef00414fdf17c23f07dc13e577883dca406", size = 11787271, upload-time = "2026-03-12T23:05:20.168Z" },
107
+ { url = "https://files.pythonhosted.org/packages/7f/d0/578c47dd68152ddddddf31cd7fc67dc30b7cdf639a86275fda821b0d9d98/ruff-0.15.6-py3-none-win_arm64.whl", hash = "sha256:c34de3dd0b0ba203be50ae70f5910b17188556630e2178fd7d79fc030eb0d837", size = 11060497, upload-time = "2026-03-12T23:05:25.968Z" },
108
+ ]