|
| 1 | +""" |
| 2 | +Run i3status modules and display its outputs in py3status. |
| 3 | +
|
| 4 | +Configuration parameters: |
| 5 | + format: display format for this module (default '{format_module}') |
| 6 | + format_module_separator: show separator if more than one (default ' ') |
| 7 | + general: specify settings for i3status general section |
| 8 | + (default {'colors': True, 'interval': 5}) |
| 9 | + modules: specify a list of i3status modules and settings plus |
| 10 | + option format_module with {output} placeholder (default []) |
| 11 | +
|
| 12 | +Format placeholders: |
| 13 | + {format_module} format for i3status modules |
| 14 | +
|
| 15 | +Examples: |
| 16 | +``` |
| 17 | +# add i3status modules |
| 18 | +# See `man i3status` for a full list of i3status configuration options. |
| 19 | +# Not all of i3status configuration options will be supported or usable. |
| 20 | +i3status { |
| 21 | + format_module_separator = "\?color=#666&show \| " |
| 22 | + general = {'colors': True, 'interval': 5} |
| 23 | + modules = [ |
| 24 | + { |
| 25 | + "name": "ipv6", |
| 26 | + "format_down": "", |
| 27 | + "format_module": "\?if=output [\?color=darkgrey&show IPv6] {output}", |
| 28 | + }, |
| 29 | + { |
| 30 | + "name": "wireless _first_", |
| 31 | + "format_up": "(%quality at %essid) %ip", |
| 32 | + "format_down": "", |
| 33 | + "format_module": "\?if=output [\?color=darkgrey&show Wireless] {output}", |
| 34 | + }, |
| 35 | + { |
| 36 | + "name": "ethernet _first_", |
| 37 | + "format_up": "%ip (%speed)", |
| 38 | + "format_down": "", |
| 39 | + "format_module": "\?if=output [\?color=darkgrey&show Ethernet] {output}", |
| 40 | + }, |
| 41 | + { |
| 42 | + "name": "battery all", |
| 43 | + "format_module": "\?if=output [\?color=darkgrey&show Battery] {output}", |
| 44 | + }, |
| 45 | + { |
| 46 | + "name": "disk /", |
| 47 | + "format": "%avail", |
| 48 | + "format_module": "[\?color=darkgrey&show Disk] {output}", |
| 49 | + }, |
| 50 | + { |
| 51 | + "name": "load", |
| 52 | + "format": "%1min", |
| 53 | + "format_module": "[\?color=darkgrey&show Load] {output}", |
| 54 | + }, |
| 55 | + { |
| 56 | + "name": "memory", |
| 57 | + "format": "%percentage_used", |
| 58 | + "format_module": "[\?color=darkgrey&show Memory] {output}", |
| 59 | + }, |
| 60 | + { |
| 61 | + "name": "tztime local", |
| 62 | + "format": "%Y-%m-%d %H:%M:%S", |
| 63 | + "format_module": "[\?color=darkgrey&show Time] {output}", |
| 64 | + }, |
| 65 | + ] |
| 66 | +} |
| 67 | +``` |
| 68 | +
|
| 69 | +@author lasers |
| 70 | +
|
| 71 | +SAMPLE OUTPUT |
| 72 | +[ |
| 73 | + {'full_text': 'W: ( 56% at lasers 5G)', 'color': '#00ff00'}, |
| 74 | + {'full_text': ' | ', 'color': '#666'}, |
| 75 | + {'full_text': 'E: down', 'color': '#ff0000'}, |
| 76 | +] |
| 77 | +
|
| 78 | +disk_tztime |
| 79 | +] |
| 80 | + {'full_text': '1.2 TiB'} |
| 81 | + {'full_text': ' | ', 'color': '#666'}, |
| 82 | + {'full_text': '2026-01-02 07:40:51 CST'} |
| 83 | +[ |
| 84 | +""" |
| 85 | + |
| 86 | +import json |
| 87 | +from contextlib import suppress |
| 88 | +from pathlib import Path |
| 89 | +from subprocess import PIPE, STDOUT, Popen |
| 90 | +from tempfile import NamedTemporaryFile |
| 91 | +from threading import Thread |
| 92 | + |
| 93 | +MODULES = [ |
| 94 | + {"name": "ipv6", "format_down": ""}, |
| 95 | + {"name": "wireless _first_", "format_down": ""}, |
| 96 | + {"name": "ethernet _first_", "format_down": ""}, |
| 97 | + {"name": "battery all", "format_down": ""}, |
| 98 | + {"name": "load", "format": "%1min"}, |
| 99 | + {"name": "memory", "format": "%used"}, |
| 100 | + {"name": "tztime local", "format": "%Y-%m-%d %H:%M:%S"}, |
| 101 | +] |
| 102 | +SEPARATOR = r"\?color=#666&show \| " |
| 103 | +STRING_NOT_INSTALLED = "i3status not installed" |
| 104 | + |
| 105 | + |
| 106 | +class Py3status: |
| 107 | + """ """ |
| 108 | + |
| 109 | + # available configuration parameters |
| 110 | + format = "{format_module}" |
| 111 | + format_module_separator = " " |
| 112 | + general = {"colors": True, "interval": 5} |
| 113 | + modules = [] |
| 114 | + |
| 115 | + def post_config_hook(self): |
| 116 | + if not self.py3.check_commands("i3status"): |
| 117 | + raise Exception(STRING_NOT_INSTALLED) |
| 118 | + if not self.modules: |
| 119 | + (self.modules, self.format_module_separator) = (MODULES, SEPARATOR) |
| 120 | + for module in self.modules: |
| 121 | + if not isinstance(module, dict) or not module.get("name"): |
| 122 | + raise Exception("invalid modules") |
| 123 | + module.setdefault("format_module", "{output}") |
| 124 | + |
| 125 | + self._write_i3status_config() |
| 126 | + self.i3status_command = ["i3status", "-c", self.tmpfile_name] |
| 127 | + self.error = None |
| 128 | + self.process = None |
| 129 | + self.running = True |
| 130 | + self.items = [] |
| 131 | + self.line = "" |
| 132 | + |
| 133 | + self.t = Thread(target=self._start_loop) |
| 134 | + self.t.daemon = True |
| 135 | + self.t.start() |
| 136 | + |
| 137 | + def _write_i3status_config(self): |
| 138 | + def _format(value): |
| 139 | + if isinstance(value, bool): |
| 140 | + return f"{value}".lower() |
| 141 | + if isinstance(value, (int, float)): |
| 142 | + return f"{value}" |
| 143 | + return f'"{value}"' |
| 144 | + |
| 145 | + # fmt: off |
| 146 | + try: |
| 147 | + # python 3.12+ |
| 148 | + tmpfile = NamedTemporaryFile(mode="w", encoding="utf-8", prefix="py3status-i3status_", |
| 149 | + suffix=".conf", delete=False, delete_on_close=False) |
| 150 | + except TypeError: |
| 151 | + tmpfile = NamedTemporaryFile(mode="w", encoding="utf-8", prefix="py3status-i3status_", |
| 152 | + suffix=".conf", delete=False) |
| 153 | + self.tmpfile_name = tmpfile.name |
| 154 | + # fmt: on |
| 155 | + |
| 156 | + lines = [] |
| 157 | + general_settings = dict(self.general or {}) |
| 158 | + general_settings.setdefault("output_format", "i3bar") |
| 159 | + lines.append("general {\n") |
| 160 | + for key, value in general_settings.items(): |
| 161 | + lines.append(f" {key} = {_format(value)}\n") |
| 162 | + lines.append("}\n\n") |
| 163 | + for module in self.modules: |
| 164 | + lines.append(f'order += "{module["name"]}"\n') |
| 165 | + lines.append(f'{module["name"]} {{\n') |
| 166 | + for key, value in module.items(): |
| 167 | + if key in ("name", "format_module"): |
| 168 | + continue |
| 169 | + lines.append(f" {key} = {_format(value)}\n") |
| 170 | + lines.append("}\n\n") |
| 171 | + tmpfile.write("".join(lines)) |
| 172 | + tmpfile.flush() |
| 173 | + tmpfile.close() |
| 174 | + |
| 175 | + def _parse_i3status_line(self, line): |
| 176 | + if not line or line in ["[", "]"] or line.startswith("{\"version\""): |
| 177 | + return None |
| 178 | + if line.startswith(","): |
| 179 | + line = line[1:] |
| 180 | + try: |
| 181 | + return json.loads(line) |
| 182 | + except ValueError: |
| 183 | + return None |
| 184 | + |
| 185 | + def _format_modules(self): |
| 186 | + new_items = [] |
| 187 | + for item, module in zip(self.items, self.modules): |
| 188 | + if not item.get("full_text"): |
| 189 | + continue |
| 190 | + item.pop("name", None) |
| 191 | + item.pop("instance", None) |
| 192 | + new_items.append(self.py3.safe_format(module["format_module"], {"output": item})) |
| 193 | + return new_items |
| 194 | + |
| 195 | + def _cleanup(self): |
| 196 | + self.running = False |
| 197 | + if self.process and self.process.poll() is None: |
| 198 | + self.process.terminate() |
| 199 | + with suppress(FileNotFoundError): |
| 200 | + Path(self.tmpfile_name).unlink() |
| 201 | + self.py3.update() |
| 202 | + |
| 203 | + def _start_loop(self): |
| 204 | + try: |
| 205 | + self.process = Popen(self.i3status_command, stdout=PIPE, stderr=STDOUT, text=True) |
| 206 | + while self.running: |
| 207 | + line = self.process.stdout.readline() |
| 208 | + if not line: |
| 209 | + if self.process.poll() is not None: |
| 210 | + raise Exception(self.line) |
| 211 | + continue |
| 212 | + line = line.strip() |
| 213 | + items = self._parse_i3status_line(line) |
| 214 | + if items is None: |
| 215 | + self.line = line |
| 216 | + continue |
| 217 | + if items == self.items: |
| 218 | + continue |
| 219 | + self.items = items |
| 220 | + self.py3.update() |
| 221 | + except Exception as err: |
| 222 | + self.error = " ".join(format(err).split()[1:]) |
| 223 | + finally: |
| 224 | + self._cleanup() |
| 225 | + |
| 226 | + def i3status(self): |
| 227 | + if self.error: |
| 228 | + self.py3.error(self.error, self.py3.CACHE_FOREVER) |
| 229 | + |
| 230 | + format_module_separator = self.py3.safe_format(self.format_module_separator) |
| 231 | + format_module = self.py3.composite_join(format_module_separator, self._format_modules()) |
| 232 | + |
| 233 | + return { |
| 234 | + "cached_until": self.py3.CACHE_FOREVER, |
| 235 | + "full_text": self.py3.safe_format(self.format, {"format_module": format_module}), |
| 236 | + } |
| 237 | + |
| 238 | + def kill(self): |
| 239 | + self._cleanup() |
| 240 | + |
| 241 | + |
| 242 | +if __name__ == "__main__": |
| 243 | + """ |
| 244 | + Run module in test mode. |
| 245 | + """ |
| 246 | + from py3status.module_test import module_test |
| 247 | + |
| 248 | + module_test(Py3status) |
0 commit comments