diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..965f214 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +__pycache__/ +*.pyc +.venv/ +dist/ + +# Beads / Dolt / Claude tooling +.beads/ +.claude/ +.dolt/ +*.db +.beads-credential-key +AGENTS.md +CLAUDE.md diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index f453224..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1 +0,0 @@ -include i3.py README.md diff --git a/README.md b/README.md index 9fed447..74d9bcb 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,6 @@ Install ------- pip install i3-py - # OR/AND (for Python 2.x) - pip2 install i3-py Run examples @@ -35,7 +33,7 @@ Usage The basics ---------- -The communication with i3-wm is through sockets. There are 7 types of messages: +The communication with i3-wm is through sockets. There are 13 types of messages: - command (0) - get_workspaces (1) @@ -44,14 +42,26 @@ The communication with i3-wm is through sockets. There are 7 types of messages: - get_tree (4) - get_marks (5) - get_bar_config (6) + - get_version (7) + - get_binding_modes (8) + - get_config (9) + - send_tick (10) + - sync (11) + - get_binding_state (12) You can control i3-wm with _command_ messages. Other message types return information about i3-wm without changing its behaviour. -_Subscribe_ offers 2 event types (read: _changes_) to subscribe for: +_Subscribe_ offers 8 event types (read: _changes_) to subscribe for: - workspace (0) - output (1) + - mode (2) + - window (3) + - barconfig_update (4) + - binding (5) + - shutdown (6) + - tick (7) There are various ways to do this with i3.py. Let's start with... @@ -288,10 +298,10 @@ References: - [i3-wm's user guide](http://i3wm.org/docs/userguide.html) contains lots of commands that you can use with i3-py. -i3-py was tested with Python 3.2.2 and 2.7.2. +i3-py requires Python 3.10 or later. Dependencies: -- i3-wm -- Python +- i3-wm (4.14+) +- Python (3.10+) diff --git a/examples/ipc.py b/examples/ipc.py index 433de79..6011631 100755 --- a/examples/ipc.py +++ b/examples/ipc.py @@ -55,7 +55,7 @@ def parse(): return args -def main(socket, type, timeout, message): +def main(socket, msg_type, timeout, message): """ Excepts arguments and evaluates them. """ @@ -68,14 +68,14 @@ def main(socket, type, timeout, message): try: i3.default_socket(i3.Socket(path=socket, timeout=timeout)) except i3.ConnectionError: - print("Couldn't connect to socket at '%s'." % socket) + print(f"Couldn't connect to socket at '{socket}'.") return False # Format input - if type in i3.EVENT_TYPES: - event_type = type + if msg_type in i3.EVENT_TYPES: + event_type = msg_type event = message - type = 'subscribe' - elif type == 'subscribe': + msg_type = 'subscribe' + elif msg_type == 'subscribe': message = message.split(' ') message_len = len(message) if message_len >= 1: @@ -85,13 +85,13 @@ def main(socket, type, timeout, message): else: event = '' else: - # Let if fail + # Let it fail event_type = '' try: - if type == 'subscribe': + if msg_type == 'subscribe': i3.subscribe(event_type, event) else: - output = i3.msg(type, message) + output = i3.msg(msg_type, message) print(output) except i3.i3Exception as i3error: print(i3error) @@ -101,3 +101,4 @@ def main(socket, type, timeout, message): args = parse() main(args.socket, args.type, args.timeout, args.message) + diff --git a/examples/winmenu.py b/examples/winmenu.py index df78180..969be45 100755 --- a/examples/winmenu.py +++ b/examples/winmenu.py @@ -42,10 +42,11 @@ def win_menu(clients, l=10): """ dmenu = subprocess.Popen(['/usr/bin/dmenu','-i','-l', str(l)], stdin=subprocess.PIPE, - stdout=subprocess.PIPE) + stdout=subprocess.PIPE, + text=True) menu_str = '\n'.join(sorted(clients.keys())) # Popen.communicate returns a tuple stdout, stderr - win_str = dmenu.communicate(menu_str.encode('utf-8'))[0].decode('utf-8').rstrip() + win_str = dmenu.communicate(menu_str)[0].rstrip() return clients.get(win_str, None) if __name__ == '__main__': diff --git a/examples/wsbar.py b/examples/wsbar.py index 826fc8a..617136c 100755 --- a/examples/wsbar.py +++ b/examples/wsbar.py @@ -25,7 +25,7 @@ import i3 -class Colors(object): +class Colors: """ A class for easier managing of bar's colors. Attributes (hexadecimal color values): @@ -64,7 +64,7 @@ def get_color(self, workspace, output): return self.inactive -class i3wsbar(object): +class i3wsbar: """ A workspace bar; display a list of workspaces using a given bar application. Defaults to dzen2. @@ -105,7 +105,7 @@ def __init__(self, colors=None, font=None, bar_cmd=None, bar_args=None): outputs = self.socket.get('get_outputs') self.display(self.format(workspaces, outputs)) # Subscribe to an event - callback = lambda data, event, _: self.change(data, event) + callback = lambda event, data, _: self.change(event, data) self.subscription = i3.Subscription(callback, 'workspace') def change(self, event, workspaces): @@ -144,11 +144,7 @@ def display(self, bar_text): Displays a text on the bar by piping it to the bar application. """ bar_text += '\n' - try: - bar_text = bar_text.encode() - except AttributeError: - pass # already a byte string - self.bar.stdin.write(bar_text) + self.bar.stdin.write(bar_text.encode()) def quit(self): """ diff --git a/i3.py b/i3.py index 48555d2..6056e9b 100644 --- a/i3.py +++ b/i3.py @@ -24,13 +24,12 @@ import struct import threading import time - -ModuleType = type(sys) +import types __author__ = 'Jure Ziberna' -__version__ = '0.6.5' -__date__ = '2012-06-20' +__version__ = '0.7.0' +__date__ = '2026-04-06' __license__ = 'GNU GPL 3' @@ -42,11 +41,23 @@ 'get_tree', 'get_marks', 'get_bar_config', + 'get_version', + 'get_binding_modes', + 'get_config', + 'send_tick', + 'sync', + 'get_binding_state', ] EVENT_TYPES = [ 'workspace', 'output', + 'mode', + 'window', + 'barconfig_update', + 'binding', + 'shutdown', + 'tick', ] @@ -58,16 +69,14 @@ class MessageTypeError(i3Exception): Raised when message type isn't available. See i3.MSG_TYPES. """ def __init__(self, type): - msg = "Message type '%s' isn't available" % type - super(MessageTypeError, self).__init__(msg) + super().__init__(f"Message type '{type}' isn't available") class EventTypeError(i3Exception): """ Raised when even type isn't available. See i3.EVENT_TYPES. """ def __init__(self, type): - msg = "Event type '%s' isn't available" % type - super(EventTypeError, self).__init__(msg) + super().__init__(f"Event type '{type}' isn't available") class MessageError(i3Exception): """ @@ -81,8 +90,7 @@ class ConnectionError(i3Exception): Raised when a socket couldn't connect to the window manager. """ def __init__(self, socket_path): - msg = "Could not connect to socket at '%s'" % socket_path - super(ConnectionError, self).__init__(msg) + super().__init__(f"Could not connect to socket at '{socket_path}'") def parse_msg_type(msg_type): @@ -120,7 +128,7 @@ def parse_event_type(event_type): raise EventTypeError(event_type) -class Socket(object): +class Socket: """ Socket for communicating with the i3 window manager. Optional arguments: @@ -146,17 +154,23 @@ def __init__(self, path=None, timeout=None, chunk_size=None, self.chunk_size = chunk_size if magic_string: self.magic_string = magic_string - # Socket initialization and connection - self.initialize() - self.connect() # Struct format initialization, length of magic string is in bytes self.struct_header = '<%dsII' % len(self.magic_string.encode('utf-8')) self.struct_header_size = struct.calcsize(self.struct_header) + # Socket initialization and connection + self.initialize() + try: + self.connect() + except ConnectionError: + self.socket.close() + raise def initialize(self): """ - Initializes the socket. + Initializes the socket, closing any existing one first. """ + if hasattr(self, 'socket'): + self.socket.close() self.socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) self.socket.settimeout(self.timeout) @@ -170,7 +184,7 @@ def connect(self, path=None): path = self.path try: self.socket.connect(path) - except socket.error: + except OSError: raise ConnectionError(path) def get(self, msg_type, payload=''): @@ -224,16 +238,11 @@ def pack(self, msg_type, payload): Packs the given message type and payload. Turns the resulting message into a byte string. """ - msg_magic = self.magic_string - # Get the byte count instead of number of characters - msg_length = len(payload.encode('utf-8')) + payload_bytes = payload.encode('utf-8') msg_type = parse_msg_type(msg_type) - # "struct.pack" returns byte string, decoding it for concatenation - msg_length = struct.pack('I', msg_length).decode('utf-8') - msg_type = struct.pack('I', msg_type).decode('utf-8') - message = '%s%s%s%s' % (msg_magic, msg_length, msg_type, payload) - # Encoding the message back to byte string - return message.encode('utf-8') + header = struct.pack(self.struct_header, self.magic_string.encode('utf-8'), + len(payload_bytes), msg_type) + return header + payload_bytes def unpack(self, data): """ @@ -267,7 +276,7 @@ def connected(self): try: self.get('command') return True - except socket.error: + except OSError: return False def close(self): @@ -291,7 +300,11 @@ class Subscription(threading.Thread): subscribed = False type_translation = { 'workspace': 'get_workspaces', - 'output': 'get_outputs' + 'output': 'get_outputs', + 'mode': 'get_binding_modes', + 'window': 'get_tree', + 'barconfig_update': 'get_bar_config', + 'binding': 'get_binding_state', } def __init__(self, callback, event_type, event=None, event_socket=None, @@ -322,7 +335,7 @@ def run(self): """ try: self.listen() - except socket.error: + except OSError: self.close() def listen(self): @@ -339,8 +352,11 @@ def listen(self): if not event: # skip an iteration if event is None continue if not self.event or ('change' in event and event['change'] == self.event): - msg_type = self.type_translation[self.event_type] - data = self.data_socket.get(msg_type) + msg_type = self.type_translation.get(self.event_type) + if msg_type: + data = self.data_socket.get(msg_type) + else: + data = None else: data = None self.callback(event, data, self) @@ -512,7 +528,7 @@ def filter(tree=None, function=None, **conditions): try: if function(tree): return [tree] - except (KeyError, IndexError): + except (KeyError, IndexError, TypeError): pass else: for key, value in conditions.items(): @@ -528,7 +544,7 @@ def filter(tree=None, function=None, **conditions): return matches -class i3(ModuleType): +class i3(types.ModuleType): """ i3.py is a Python module for communicating with the i3 window manager. """ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7312d1e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,36 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "i3-py" +version = "0.7.0" +description = "Tools for i3 users and developers" +readme = "README.md" +license = "GPL-3.0-or-later" +requires-python = ">=3.10" +authors = [ + { name = "Jure Ziberna", email = "jure@ziberna.org" }, +] +classifiers = [ + "Development Status :: 4 - Beta", + "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", + "Operating System :: POSIX", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Desktop Environment :: Window Managers", +] + +[project.urls] +Homepage = "https://github.com/ziberna/i3-py" +Documentation = "https://github.com/ziberna/i3-py/blob/master/README.md" + +[tool.hatch.build.targets.wheel] +packages = ["."] +include = ["i3.py"] + +[tool.hatch.build.targets.sdist] +include = ["i3.py", "README.md", "examples/"] diff --git a/setup.py b/setup.py deleted file mode 100644 index 110c2d9..0000000 --- a/setup.py +++ /dev/null @@ -1,20 +0,0 @@ -from distutils.core import setup - -long_description = """ -Documentation: https://github.com/ziberna/i3-py/blob/master/README.md - -Examples: https://github.com/ziberna/i3-py/tree/master/examples - -""" - -setup( - name='i3-py', - description='tools for i3 users and developers', - long_description=long_description, - author='Jure Ziberna', - author_email='jure@ziberna.org', - url='https://github.com/ziberna/i3-py', - version='0.6.5', - license='GNU GPL 3', - py_modules=['i3'] -) diff --git a/test.py b/test.py index 37cede6..9d16356 100644 --- a/test.py +++ b/test.py @@ -1,7 +1,5 @@ import i3 import unittest -import platform -py3 = platform.python_version_tuple() > ('3',) class ParseTest(unittest.TestCase): def setUp(self): @@ -41,7 +39,6 @@ def test_event_type_error(self): self.assertRaises(i3.EventTypeError, i3.parse_event_type, str(val)) def test_msg_error(self): - """If i3.yada doesn't pass, see http://bugs.i3wm.org/report/ticket/693""" self.assertRaises(i3.MessageError, i3.focus) # missing argument self.assertRaises(i3.MessageError, i3.yada) # doesn't exist self.assertRaises(i3.MessageError, i3.meh, 'some', 'args') @@ -56,7 +53,9 @@ def connect(): return i3.Socket('/nil/2971.socket') self.assertRaises(i3.ConnectionError, connect) - def test_response(self, socket=i3.default_socket()): + def test_response(self, socket=None): + if socket is None: + socket = i3.default_socket() workspaces = socket.get('get_workspaces') self.assertIsNotNone(workspaces) for workspace in workspaces: @@ -73,8 +72,7 @@ def test_multiple_sockets(self): def test_pack(self): packed = i3.default_socket().pack(0, "haha") - if py3: - self.assertIsInstance(packed, bytes) + self.assertIsInstance(packed, bytes) class GeneralTest(unittest.TestCase): @@ -100,6 +98,7 @@ def test_container(self): '[con_id="123" title="abc"]'] self.assertTrue(container in output) + @unittest.skip("requires an xterm window to be open") def test_criteria(self): self.assertTrue(i3.focus(clasS='xterm')) @@ -117,8 +116,8 @@ def test_filter2(self): parent_count += 1 self.assertGreater(parent_count, 0) + @unittest.skip("requires a Wikipedia tab to be open in a browser") def test_filter_function_wikipedia(self): - """You have to have a Wikipedia tab opened in a browser.""" func = lambda node: 'Wikipedia' in node['name'] nodes = i3.filter(function=func) self.assertTrue(nodes != []) @@ -126,8 +125,5 @@ def test_filter_function_wikipedia(self): self.assertTrue('free encyclopedia' in node['name']) if __name__ == '__main__': - test_suits = [] - for Test in [ParseTest, SocketTest, GeneralTest]: - test_suits.append(unittest.TestLoader().loadTestsFromTestCase(Test)) - unittest.TextTestRunner(verbosity=2).run(unittest.TestSuite(test_suits)) + unittest.main(verbosity=2)