From 657ea075beaf5bbd822faab34eb08327cae4b362 Mon Sep 17 00:00:00 2001 From: Martastain Date: Fri, 17 Jul 2026 22:07:57 +0200 Subject: [PATCH 01/17] chore: uv --- pyproject.toml | 9 +++++++++ uv.lock | 42 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 pyproject.toml create mode 100644 uv.lock diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..25f4371 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,9 @@ +[project] +name = "promexp" +version = "2.0.0" +description = "Simple Prometheus metrics exporter for broadcasters" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "psutil>=7.2.2", +] diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..3e32e5b --- /dev/null +++ b/uv.lock @@ -0,0 +1,42 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "promexp" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "psutil" }, +] + +[package.metadata] +requires-dist = [{ name = "psutil", specifier = ">=7.2.2" }] + +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, + { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, + { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, + { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] From d2a371dc133f6ef592f686c1798ceb0274a0ef75 Mon Sep 17 00:00:00 2001 From: Martastain Date: Fri, 17 Jul 2026 22:08:09 +0200 Subject: [PATCH 02/17] chore: remove requirements --- requirements.txt | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 requirements.txt diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 0ff2354..0000000 --- a/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -nxtools -psutil -vial-http From c1b641ce9c4e27e828abf2d6ff354b1f80e480f6 Mon Sep 17 00:00:00 2001 From: Martastain Date: Fri, 17 Jul 2026 22:08:46 +0200 Subject: [PATCH 03/17] feat: add custom logger --- promexp/logger.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 promexp/logger.py diff --git a/promexp/logger.py b/promexp/logger.py new file mode 100644 index 0000000..ba19084 --- /dev/null +++ b/promexp/logger.py @@ -0,0 +1,29 @@ +import logging +import sys +import traceback + +class CustomLogger(logging.Logger): + def goodnews(self, msg, *args, **kwargs): + # Translate goodnews to INFO level + self.info(msg, *args, **kwargs) + +# Register CustomLogger as the default logger class +logging.setLoggerClass(CustomLogger) + +# Configure base logging +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + stream=sys.stderr +) + +logger = logging.getLogger("promexp") +logger.show_time = True # Backward compatibility for logging.show_time = True + +def log_traceback(message="Exception!", logger=logger): + tb = traceback.format_exc() + indented_tb = "\n".join(" " + line for line in tb.splitlines()) + msg = f"{message}\n\n{indented_tb}" + logger.error(msg) + return msg From 80e0ca097f9fb26b71bd462d61ff05d719006ede Mon Sep 17 00:00:00 2001 From: Martastain Date: Fri, 17 Jul 2026 22:09:01 +0200 Subject: [PATCH 04/17] refactor: remove legacy dependencies --- promexp.py | 54 +++++++++-- promexp/providers/casparcg/__init__.py | 122 ++++++++++++++++++++++--- 2 files changed, 156 insertions(+), 20 deletions(-) diff --git a/promexp.py b/promexp.py index 0dab345..9080e19 100755 --- a/promexp.py +++ b/promexp.py @@ -5,9 +5,7 @@ import socket from promexp import Promexp -from vial import Vial - -from nxtools import logging +from promexp.logger import logger as logging settings = { "host" : "", @@ -39,12 +37,52 @@ logger=logging, ) -class Server(Vial): - def handle(self, request): - if request.method == "GET" and request.path == "/metrics": - return self.response.text(promexp.render()) - return self.response.text(f"Use /metrics GET request", status=400) +import http.server + +class MetricsHandler(http.server.BaseHTTPRequestHandler): + def log_message(self, format, *args): + logging.debug(f"{self.address_string()} - - {format % args}") + + def do_GET(self): + if self.path == "/metrics": + try: + content = promexp.render() + encoded = content.encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "text/plain; charset=utf-8") + self.send_header("Content-Length", str(len(encoded))) + self.end_headers() + self.wfile.write(encoded) + except Exception: + logging.error("Error rendering metrics") + self.send_response(500) + self.send_header("Content-Type", "text/plain; charset=utf-8") + self.end_headers() + self.wfile.write(b"Internal Server Error") + else: + self.send_response(400) + self.send_header("Content-Type", "text/plain; charset=utf-8") + self.end_headers() + self.wfile.write(b"Use /metrics GET request") + + +class Server: + def __init__(self, app_name="promexp", logger=None): + self.logger = logger or logging + + def serve(self, host: str, port: int): + self.logger.info(f"Starting HTTP server at {host if host else '*'}:{port}") + server_address = (host, port) + httpd = http.server.HTTPServer(server_address, MetricsHandler) + try: + httpd.serve_forever() + except KeyboardInterrupt: + print() + self.logger.info("Keyboard interrupt. Shutting down...") + httpd.server_close() + if __name__ == "__main__": server = Server("promexp", logger=logging) server.serve("", 9731) + diff --git a/promexp/providers/casparcg/__init__.py b/promexp/providers/casparcg/__init__.py index 7cad04d..08868e3 100644 --- a/promexp/providers/casparcg/__init__.py +++ b/promexp/providers/casparcg/__init__.py @@ -2,24 +2,58 @@ import math import threading import fractions -import telnetlib import socket -from nxtools.caspar import CasparCG as NXCaspar -from nxtools import log_traceback, logging - +from ...logger import log_traceback, logger as logging from .osc_server import OSCServer from ...provider import BaseProvider -class CasparCG(NXCaspar): - """CasparCG which fails to connect silently""" - verbose = False +class CasparResponse: + """Caspar query response object""" + def __init__(self, code: int, data: str): + self.code = code + self.data = data + + @property + def response(self) -> int: + """AMCP response code""" + return self.code - def connect(self, **kwargs): + @property + def is_error(self) -> bool: + """Returns True if query failed""" + return self.code >= 400 + + @property + def is_success(self) -> bool: + """Returns True if query succeeded""" + return self.code < 400 + + def __repr__(self): + if self.is_success: + return "" + return f"" + + def __len__(self): + return int(self.is_success) + + +class CasparCG: + """CasparCG client object implemented with raw sockets""" + def __init__(self, host: str = "localhost", port: int = 5250, timeout: float = 2): + self.host = host + self.port = port + self.timeout = timeout + self.connection = None + self.rfile = None + self.verbose = False + + def connect(self, **kwargs) -> bool: """Create connection to CasparCG Server""" try: - self.connection = telnetlib.Telnet(self.host, self.port, timeout=self.timeout) + self.connection = socket.create_connection((self.host, self.port), timeout=self.timeout) + self.rfile = self.connection.makefile("rb") except ConnectionRefusedError: if self.verbose: logging.error("CasparCG: Connection refused") @@ -38,12 +72,76 @@ def connect(self, **kwargs): def disconnect(self): if self.connection: - logging.warning("CasparCG: Disconnected") - self.connection = False + if self.verbose: + logging.warning("CasparCG: Disconnected") + try: + self.rfile.close() + except Exception: + pass + try: + self.connection.close() + except Exception: + pass + self.connection = None + self.rfile = None @property def is_connected(self): - return self.connection != False + return self.connection is not None + + def query(self, query: str, **kwargs) -> CasparResponse: + """Send an AMCP command""" + if not self.is_connected: + if not self.connect(**kwargs): + return CasparResponse(500, "Unable to connect CasparCG server") + + query = query.strip() + if kwargs.get("verbose", self.verbose): + if not query.startswith("INFO"): + logging.debug(f"Executing AMCP: {query}") + + query_bytes = bytes(query.encode("utf-8")) + b"\r\n" + + try: + self.connection.sendall(query_bytes) + status_line = self.rfile.readline() + if not status_line: + raise ConnectionResetError("Connection closed by peer") + result = status_line.strip() + except ConnectionResetError: + self.disconnect() + return CasparResponse(500, "Connection reset by peer") + except Exception: + log_traceback("Query failed") + self.disconnect() + return CasparResponse(500, "Query failed") + + result_str = result.decode("utf-8") + + if not result_str: + return CasparResponse(500, "No result") + + try: + code_str = result_str[:3] + if code_str == "202": + return CasparResponse(202, "No result") + + elif code_str in ["201", "200"]: + stat = int(code_str) + data_line = self.rfile.readline() + if not data_line: + raise ConnectionResetError("Connection closed while reading data") + data_str = data_line.decode("utf-8").strip() + return CasparResponse(stat, data_str) + + elif result_str[0] in ["3", "4", "5"]: + stat = int(code_str) + return CasparResponse(stat, result_str) + + except Exception: + return CasparResponse(500, f"Malformed result: {result_str}") + return CasparResponse(500, f"Unexpected result: {result_str}") + From accf9bf5e41c670bed61db8342d91174bf8c359c Mon Sep 17 00:00:00 2001 From: Martastain Date: Fri, 17 Jul 2026 22:30:56 +0200 Subject: [PATCH 05/17] refactor: change entrypoint --- promexp.py | 88 ------------------------------------------------------ 1 file changed, 88 deletions(-) delete mode 100755 promexp.py diff --git a/promexp.py b/promexp.py deleted file mode 100755 index 9080e19..0000000 --- a/promexp.py +++ /dev/null @@ -1,88 +0,0 @@ -#!/usr/bin/env python3 - -import os -import json -import socket - -from promexp import Promexp -from promexp.logger import logger as logging - -settings = { - "host" : "", - "port" : 9731, - "hostname" : True, - "tags" : {}, - "prefix" : "nebula", - "provider_settings" : {} -} - -logging.show_time = True - -settings_path = "settings.json" -if os.path.exists(settings_path): - try: - settings.update(json.load(open(settings_path))) - except Exception: - logging.error("Unable to parse {settings_path}. Using defaults.") - -if settings["hostname"] is True: - settings["tags"]["hostname"] = socket.gethostname() -elif type(settings["hostname"]) == str: - settings["tags"]["hostname"] = settings["hostname"] - -promexp = Promexp( - prefix=settings["prefix"], - tags=settings["tags"], - provider_settings=settings["provider_settings"], - logger=logging, -) - -import http.server - -class MetricsHandler(http.server.BaseHTTPRequestHandler): - def log_message(self, format, *args): - logging.debug(f"{self.address_string()} - - {format % args}") - - def do_GET(self): - if self.path == "/metrics": - try: - content = promexp.render() - encoded = content.encode("utf-8") - self.send_response(200) - self.send_header("Content-Type", "text/plain; charset=utf-8") - self.send_header("Content-Length", str(len(encoded))) - self.end_headers() - self.wfile.write(encoded) - except Exception: - logging.error("Error rendering metrics") - self.send_response(500) - self.send_header("Content-Type", "text/plain; charset=utf-8") - self.end_headers() - self.wfile.write(b"Internal Server Error") - else: - self.send_response(400) - self.send_header("Content-Type", "text/plain; charset=utf-8") - self.end_headers() - self.wfile.write(b"Use /metrics GET request") - - -class Server: - def __init__(self, app_name="promexp", logger=None): - self.logger = logger or logging - - def serve(self, host: str, port: int): - self.logger.info(f"Starting HTTP server at {host if host else '*'}:{port}") - server_address = (host, port) - httpd = http.server.HTTPServer(server_address, MetricsHandler) - try: - httpd.serve_forever() - except KeyboardInterrupt: - print() - self.logger.info("Keyboard interrupt. Shutting down...") - httpd.server_close() - - -if __name__ == "__main__": - server = Server("promexp", logger=logging) - server.serve("", 9731) - From 0f4b49b128a3c44d6f20fa9830cf7f20e0477271 Mon Sep 17 00:00:00 2001 From: Martastain Date: Fri, 17 Jul 2026 22:31:22 +0200 Subject: [PATCH 06/17] feat: new entrypoint --- promexp/__main__.py | 88 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100755 promexp/__main__.py diff --git a/promexp/__main__.py b/promexp/__main__.py new file mode 100755 index 0000000..9080e19 --- /dev/null +++ b/promexp/__main__.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 + +import os +import json +import socket + +from promexp import Promexp +from promexp.logger import logger as logging + +settings = { + "host" : "", + "port" : 9731, + "hostname" : True, + "tags" : {}, + "prefix" : "nebula", + "provider_settings" : {} +} + +logging.show_time = True + +settings_path = "settings.json" +if os.path.exists(settings_path): + try: + settings.update(json.load(open(settings_path))) + except Exception: + logging.error("Unable to parse {settings_path}. Using defaults.") + +if settings["hostname"] is True: + settings["tags"]["hostname"] = socket.gethostname() +elif type(settings["hostname"]) == str: + settings["tags"]["hostname"] = settings["hostname"] + +promexp = Promexp( + prefix=settings["prefix"], + tags=settings["tags"], + provider_settings=settings["provider_settings"], + logger=logging, +) + +import http.server + +class MetricsHandler(http.server.BaseHTTPRequestHandler): + def log_message(self, format, *args): + logging.debug(f"{self.address_string()} - - {format % args}") + + def do_GET(self): + if self.path == "/metrics": + try: + content = promexp.render() + encoded = content.encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "text/plain; charset=utf-8") + self.send_header("Content-Length", str(len(encoded))) + self.end_headers() + self.wfile.write(encoded) + except Exception: + logging.error("Error rendering metrics") + self.send_response(500) + self.send_header("Content-Type", "text/plain; charset=utf-8") + self.end_headers() + self.wfile.write(b"Internal Server Error") + else: + self.send_response(400) + self.send_header("Content-Type", "text/plain; charset=utf-8") + self.end_headers() + self.wfile.write(b"Use /metrics GET request") + + +class Server: + def __init__(self, app_name="promexp", logger=None): + self.logger = logger or logging + + def serve(self, host: str, port: int): + self.logger.info(f"Starting HTTP server at {host if host else '*'}:{port}") + server_address = (host, port) + httpd = http.server.HTTPServer(server_address, MetricsHandler) + try: + httpd.serve_forever() + except KeyboardInterrupt: + print() + self.logger.info("Keyboard interrupt. Shutting down...") + httpd.server_close() + + +if __name__ == "__main__": + server = Server("promexp", logger=logging) + server.serve("", 9731) + From 39d075106b60b66fca612c8d146f58f5c0f8d9bf Mon Sep 17 00:00:00 2001 From: Martastain Date: Fri, 17 Jul 2026 22:31:42 +0200 Subject: [PATCH 07/17] feat: docker build --- Dockerfile | 14 ++++++++++++++ Makefile | 10 ++++++++++ 2 files changed, 24 insertions(+) create mode 100644 Dockerfile create mode 100644 Makefile diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7588d1e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.13-slim-trixie +# RUN apt-get update && apt-get install -y \ +# python3-pip \ +# python-is-python3 \ +# && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +COPY pyproject.toml . +RUN pip install --no-cache-dir --break-system-packages -e . +COPY promexp /app/promexp + +CMD ["python", "-m", "promexp"] + + diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..db97e0b --- /dev/null +++ b/Makefile @@ -0,0 +1,10 @@ +IMAGE_NAME=nebulabroadcast/promexp:latest + +run: build + docker run -it --rm \ + -p 9731:9731 \ + -v ./settings.json:/app/settings.json \ + $(IMAGE_NAME) + +build: + docker build -t $(IMAGE_NAME) . From 5b510091bd252550ff73374f10013da2a0f92b0b Mon Sep 17 00:00:00 2001 From: Martastain Date: Sat, 18 Jul 2026 00:03:45 +0200 Subject: [PATCH 08/17] chore: add typer --- pyproject.toml | 62 +++++++++ uv.lock | 354 ++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 414 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 25f4371..be8df11 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,4 +6,66 @@ readme = "README.md" requires-python = ">=3.12" dependencies = [ "psutil>=7.2.2", + "typer>=0.27.0", ] + + +[dependency-groups] +dev = [ + "mypy >=1.19", + "pytest >=9.0", + "ruff >=0.14.13", +] + +# +# Tools +# + +[tool.ruff] +fix = true +line-length = 88 + +[tool.ruff.format] +indent-style = "space" +line-ending = "auto" + +[tool.ruff.lint] +select = ["ALL"] + +ignore = [ + "BLE", # FIXME: blind exceptions. I don't like them either, but they are useful in some cases + "COM812", # OK: messes with formatter + "D", # FIXME: docstrings. Naaah. + "EM", # OK: String literals in exception messages (why not?) + "FBT", # Boolean positional argument (WTF?) + "FIX002", # FIXME: TODOs + "ISC001", # OK: Incompatible with formatting + "PLR2004", # OK: Magic numbers (just a few) + "PTH", # FIXME: path-like objects + "S101", # OK: I like asserts. They make mypy happy + "S104", # OK: bind to all interfaces (We need this) + "S110", # OK: except: pass + "S608", # FIXME: sql injections. let's wait for template strings + "SIM105", # OK: contextlib.suppress is ugly + "TD002", # Author name in TODOs + "TD003", # Issue link in TODOs + "TRY003", # OK: Avoid specicying long messages outside of exceptions + "ANN", # FIXME: Any + "ERA", # Commented out code + "RUF012", # FIXME ClassVar +] + +[tool.mypy] +plugins = ["pydantic.mypy"] + +strict = true +follow_imports = "silent" +warn_redundant_casts = true +warn_unused_ignores = true +disallow_any_generics = true +check_untyped_defs = true +no_implicit_reexport = true +disallow_untyped_defs = true +exclude = "tests/|.venv/" + + diff --git a/uv.lock b/uv.lock index 3e32e5b..4a4ba4f 100644 --- a/uv.lock +++ b/uv.lock @@ -1,17 +1,271 @@ version = 1 revision = 3 requires-python = ">=3.12" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version < '3.15'", +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "ast-serialize" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/ad/0d70a3a2d6e01968d985415259e8ec7ad3f777903f9b1c1f3c8c44642c60/ast_serialize-0.6.0.tar.gz", hash = "sha256:aadd3ffcf4858c9726bf3515f7b199c7eadbe504f96028e4a87172c0da65a8fe", size = 61489, upload-time = "2026-06-30T20:02:55.555Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/12/3e5f575f156555547c250a8b0d1347517a3a20fc7f4492e9703a69d4f45e/ast_serialize-0.6.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:a7520b672827885bafeae7501f684d14d47d17e5f45256f9df547686cca52264", size = 1177640, upload-time = "2026-06-30T20:02:06.708Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a4/921a9e27951627983b0f368859ea00f8330a551dc0bf4c2fdcb11855a98b/ast_serialize-0.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a14191beec7e0c078d2fc1f6edc0aee88bcd4db9f18e1bc9f8052b559c22dddc", size = 1168111, upload-time = "2026-06-30T20:02:08.366Z" }, + { url = "https://files.pythonhosted.org/packages/00/69/950cf404de7b8782cf95e5c1237e25e2aa46177b287f39f9eeddf481fd6f/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32ef62ec34cf6be20ad77d4799556638fbdf187f3ae10698dfb20ef9f2c89516", size = 1227656, upload-time = "2026-06-30T20:02:09.843Z" }, + { url = "https://files.pythonhosted.org/packages/4c/a8/46f8f6a6479d9d2273980957bb091a506c55f5b95d3c029ee58518a78407/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:13b7769970a39983b0adf2f38917b1cd3b8946f76df045756c3d741bc689f089", size = 1227706, upload-time = "2026-06-30T20:02:11.367Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b9/9ac415bda0a40e49eab8fea3b2741c19c98bb84d57d62c4cfc6230eb67be/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f7a408601bb3edaefb3bc67a4c01f5235e3253653b6a5729a2ee2382b35341c", size = 1431705, upload-time = "2026-06-30T20:02:12.737Z" }, + { url = "https://files.pythonhosted.org/packages/e5/06/8807115d441444879f7561b5eede5ac18fc80392f11826d61ccf31f503b1/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8670bfa51208a2c0c8d138928e40e998fab158f9200d53bb80c088b5b8eda7b8", size = 1249533, upload-time = "2026-06-30T20:02:14.571Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c0/c2ba82ef9618650357d9421a1fdb27ffec862a7f57e8e2de82a3ccd11e12/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4826809eb8597a8cd59fd924b6d7c285b8969a1e0007e2cb652cab62376270f", size = 1252619, upload-time = "2026-06-30T20:02:16.219Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a7/fa31d52dd4102cede29fb9634e98d214129b2783b4f95528c6dc6a8f6587/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:577a6c189068686869f5f1ddc38363f3ae1808a4753b577266f9202071a7bb66", size = 1242983, upload-time = "2026-06-30T20:02:17.813Z" }, + { url = "https://files.pythonhosted.org/packages/b1/20/ddf742b5ad3c4bafd3466f2265037cfd99bc1b9a5ee46a5d58c90d523242/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:085de7f62dc9cc247eb01e965a362707d1d90b1d89a82c5bf78301a60a3c417b", size = 1296148, upload-time = "2026-06-30T20:02:19.146Z" }, + { url = "https://files.pythonhosted.org/packages/24/cb/9f6f217cce8b3b632c5568b478d195a35e79dce4dbe309438cb89ba6ea4f/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9f8a8b78b13173de6a9ec22111d9be674874cd5bdccda04f14ae5ebc2bef403a", size = 1403826, upload-time = "2026-06-30T20:02:20.696Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f8/9d16d4f0107a183924425cc0e7618d8bf76f96b45afa9ff19f924ed1ad57/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f2ff3baffc3a29c1f15bc9098aa0c09763410262d5e6cef42116f7356c184554", size = 1502943, upload-time = "2026-06-30T20:02:22.034Z" }, + { url = "https://files.pythonhosted.org/packages/80/dd/bbc1c38756350dddf7e24acae1c9482ef42051c267417e019aecc1ed4075/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0067b25fce104eaae5b88383de9ab803faeb671831e14ca698b771b356e2600f", size = 1497632, upload-time = "2026-06-30T20:02:23.517Z" }, + { url = "https://files.pythonhosted.org/packages/42/7e/9daffefcf5b97e6bb4c3e0b3c024c1aee9722f23d3cf7cd2ff80d6fb4a40/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c617417f9cbb0cb144f6283c3cbe0d2e0f01beaf9f608f662b21191058a626ec", size = 1448858, upload-time = "2026-06-30T20:02:24.889Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1f/f9baaab81a677ea0af7d2458cac2f94ebcc85958f8a3c15ba9d9e5dab653/ast_serialize-0.6.0-cp314-cp314t-win32.whl", hash = "sha256:5337cb256dcea3df9288205213d1601581536526b8f4da44b6974f1180f3252a", size = 1052600, upload-time = "2026-06-30T20:02:26.263Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1f/41b535866519512d8cf6669cb2cff7823b7672bb6279c0333b4ff89d7d9f/ast_serialize-0.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2d947e45cafc4b09bd7528917fa84c517654a43de173c79785574b7b3068ac24", size = 1095570, upload-time = "2026-06-30T20:02:27.639Z" }, + { url = "https://files.pythonhosted.org/packages/50/64/e472fe3e3a2d33d874b987e8518aedf24562919e3b6161a4fa1797e89c0f/ast_serialize-0.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6e15ec740436e1a0d62de848641abe5f3a2f89a7f94907d534795ac91bbacf14", size = 1067267, upload-time = "2026-06-30T20:02:28.949Z" }, + { url = "https://files.pythonhosted.org/packages/52/19/ac8348ae8711c9b5ae834634f635780cab62a0f5e6f988882e048b89c2ae/ast_serialize-0.6.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:093cb8bb91b720d8523580498d031791bb1bbaa048599c3d21085d380e11a596", size = 1185367, upload-time = "2026-06-30T20:02:30.427Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f6/ec7ec652c51db77c2f61d8573338e13e4704303265ccc658cb4031d9f354/ast_serialize-0.6.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:e61580a69faf47e3689795367ed211f2a10fd741478cc0f36a0f128793360aad", size = 1178657, upload-time = "2026-06-30T20:02:31.964Z" }, + { url = "https://files.pythonhosted.org/packages/6f/02/613a7534a41d0122f37d1e0c64aa8ac78bfb831f8c92f6db057a311abb3c/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:305802f2ce2a7c4e87835078ea85c58b586ddda8095b92fe2ead9364ae19c80a", size = 1238620, upload-time = "2026-06-30T20:02:33.664Z" }, + { url = "https://files.pythonhosted.org/packages/4d/21/087957bba486242afc52f49b2d9e21c9dad00289356cf9efe67084015a9d/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c7b8b8f0c42f752ea00b2b7d7c090b3f80d9c1c5c75cadf16423790a0cc74081", size = 1236075, upload-time = "2026-06-30T20:02:34.936Z" }, + { url = "https://files.pythonhosted.org/packages/82/04/78128bbb170071c2c72a210a181f1c00e11cc1cec60a8beef747b07f9201/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd5b91b9e6f2356ace3a556963b0cd783b395fbbb0bb17b4defc283415466e77", size = 1441348, upload-time = "2026-06-30T20:02:36.245Z" }, + { url = "https://files.pythonhosted.org/packages/64/64/62fb99d6faf199b4c3e5b08a07136e9a0d7664bb249c6de3670e5b63e9b6/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4d6ef91590258ada18909b9caea344dac4de2013906b035473cd674a43f4b790", size = 1258580, upload-time = "2026-06-30T20:02:37.53Z" }, + { url = "https://files.pythonhosted.org/packages/ca/87/b4d6c38e0ccd5e85dc54cecdf933a152c60b28fe5d993a6d8a72fa6d5896/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcbed41e9386059fc0261d602445ede0976c2ecec2939688bcbcb9ed0b6f28b7", size = 1261693, upload-time = "2026-06-30T20:02:39.123Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4b/3676ca2191f39bafb75f93f99b2f429ec464586158fece2165f3572805dc/ast_serialize-0.6.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:cdc4e6f930b9090c2f92c9036ad12ffb8e6e44d4a5ba06f1458a05d60f203f7b", size = 1252517, upload-time = "2026-06-30T20:02:40.511Z" }, + { url = "https://files.pythonhosted.org/packages/f3/58/494ef8c4b4acb2f4a265ac934caf45f792a08fe27d6b853de35ad991941a/ast_serialize-0.6.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:897ac47b5637be41c0c07061c8a912fafa967ef1dc73fa115e4bfa70882a093b", size = 1304843, upload-time = "2026-06-30T20:02:41.961Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f2/13736d920ab3d49bbee80ef1a277dd7b7aaf3b3545efd9d2a8114fe05525/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c4af9a1386166e40ed01464991806f89038a2d89782576c7774876fa77034e32", size = 1413698, upload-time = "2026-06-30T20:02:44.179Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5a/e046f3899e2acba4677d7427b76431443a1aa1a0e583dfb05b55b69d55cf/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:c901adbd750029b9ac4ad3d6aa56853e0ad4875119fbf52b7b8298afc223828b", size = 1512209, upload-time = "2026-06-30T20:02:45.584Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c7/e42aaca7bb2d22a7c06d5a8c7930086c5a334e93d716e6fa5e6647a4515f/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:3ae22a366b752ab4496191525b78b097b5b72d531752e3c1dd7e383a8f2c8a1a", size = 1508464, upload-time = "2026-06-30T20:02:46.942Z" }, + { url = "https://files.pythonhosted.org/packages/95/93/5524a3dc6c3f593de3228ed9cbef73afa047625b7000ec21b7f58e6eb4d4/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4ed29121da8b3fdc291002801a1de0f76248fa07dce89157a5f277842cf6126e", size = 1457164, upload-time = "2026-06-30T20:02:48.294Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c0/36a6ffb4d653cf621427b4c4928671f53ad800c453474de2b82564a44ad9/ast_serialize-0.6.0-cp39-abi3-pyemscripten_2026_0_wasm32.whl", hash = "sha256:b1dac4e09d341c1300ba69cdcbe62867b32a8c75d90db9bf4d083bec3b039f0b", size = 863014, upload-time = "2026-06-30T20:02:49.742Z" }, + { url = "https://files.pythonhosted.org/packages/09/c7/7d5ad8b49e1278e1c2a1e0274bd7850560b3f09313aa00c13bc8d5544792/ast_serialize-0.6.0-cp39-abi3-win32.whl", hash = "sha256:82c312a7844d2fdeb4d5c48bd3d215bf940dafd4704e1a9bcf252a99010a99b1", size = 1063165, upload-time = "2026-06-30T20:02:50.98Z" }, + { url = "https://files.pythonhosted.org/packages/47/ae/6710c14ecb276031cf10249f6adf5a59e2d3fdb3b5183bd59f70524067ee/ast_serialize-0.6.0-cp39-abi3-win_amd64.whl", hash = "sha256:113b58346f9ceb664352032770caca817d4a3c86f611c6088e6ef65ddaa70f0e", size = 1101444, upload-time = "2026-06-30T20:02:52.554Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/c53deb2cd0c9b0fb636d24d9f40924cf2e65028e6b20b10cd5c1eeb2c730/ast_serialize-0.6.0-cp39-abi3-win_arm64.whl", hash = "sha256:ccd132fe8db56f61fe743b1f644d01b8d65b83248a8da506f3132bda86d6ed5e", size = 1072965, upload-time = "2026-06-30T20:02:54.097Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +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" } +wheels = [ + { 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" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +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" } +wheels = [ + { 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" }, +] + +[[package]] +name = "librt" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/2f/3908645ddddab7120b46295e541ead308109fa48dbec7d67d7a778870d60/librt-0.13.0.tar.gz", hash = "sha256:1d2a610c14ac0d0750ee0a3ab8548e83155258387891caaca04def4bf7289781", size = 211402, upload-time = "2026-07-08T12:26:29.834Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/f4/b2933ddae222dac338476abb872641169a5cfed2c2bb5444a5b07b32b0c3/librt-0.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:30536798f4504c0fad0885b1d371b0539abb081e4570c9d7c641cb51141b49f0", size = 150990, upload-time = "2026-07-08T12:25:02.42Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/db98f744ca50e6efc9c95c70ee49b77aefac31f6a3fc7c83754a42d6a74f/librt-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:93d24ebb82aa4420b1409c389e7857bc35bd0b668007ac8172427d5c73cc8cc5", size = 155238, upload-time = "2026-07-08T12:25:03.681Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/a197e7bc72baf2c61ce7fdc6906a5054dc05bd8da0819aa894e4857bf87e/librt-0.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb8a1adce42d8b75485a5d56a9623a50bcab995b6079f1dac59fc44034dd93d9", size = 503073, upload-time = "2026-07-08T12:25:05.049Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e7/7887712e27da7c1ab80fcabb1de6eb24243964f6557cae530d4b70706dbd/librt-0.13.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0763ca2ab66058174f9dee426dc64f5e0a89c24a7df8d3fe3f1836c04e25de4b", size = 496528, upload-time = "2026-07-08T12:25:06.26Z" }, + { url = "https://files.pythonhosted.org/packages/94/f0/f2283385bb6b950b26a1410f4ce51ec27231e0b3a4b925c46366d218b198/librt-0.13.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b222493da6e7b6199db9bd79502436cf5a27da3c1f7fa83c7e285444fc93fd03", size = 531786, upload-time = "2026-07-08T12:25:07.658Z" }, + { url = "https://files.pythonhosted.org/packages/36/11/69ac3b54766ffba5fd7e5acebfb048d66dbe1f9f2d14516c2b3edc59cf87/librt-0.13.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fadc63331f4388c3dc90090448f682a7e9feafc11481391c1e94f2f907a3976e", size = 524393, upload-time = "2026-07-08T12:25:09.121Z" }, + { url = "https://files.pythonhosted.org/packages/61/5f/d72f95fd444a926a3c14b4e24979474116988dd57a45be242077c45d3c22/librt-0.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:70d9c62a4cffd9f23396cd5ef93fc5d11b31596b9b7d6306074abe3d5fcf09bd", size = 543026, upload-time = "2026-07-08T12:25:10.459Z" }, + { url = "https://files.pythonhosted.org/packages/c4/08/dcd9993ad192737a004ba263d549f8ea605b326b952e7d6205c7d4170b76/librt-0.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:66c0e7e6b02a155576df2c77ec933a70b72da726e248c494abf690923e624348", size = 546829, upload-time = "2026-07-08T12:25:11.716Z" }, + { url = "https://files.pythonhosted.org/packages/96/d5/6d9bb2f54e4109a956b7128836529653eb9d740f784bc47ed10a02c1000e/librt-0.13.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ac04bcd3328eb91d99dfedf6a60d9c1f15d3434e6f6daf922f0420f7d90b85c7", size = 535700, upload-time = "2026-07-08T12:25:13.144Z" }, + { url = "https://files.pythonhosted.org/packages/8c/f2/10946922503858a359492fa27f13e86228bde702116a740ac7b3cd185f24/librt-0.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:db327e7271e653c32040b85ae6188059c924b57d7e1e29f935523fa017cd4e82", size = 573566, upload-time = "2026-07-08T12:25:14.336Z" }, + { url = "https://files.pythonhosted.org/packages/48/a8/94f00e3c99479a18088af3685ea016c42f3c7d5d1964d8dbb40c08d7f1aa/librt-0.13.0-cp312-cp312-win32.whl", hash = "sha256:860bd1d8ba48456ce08feaf8d343a8aaeb2fa086f2bcaa2a923fa3f7a3ff9aa3", size = 106099, upload-time = "2026-07-08T12:25:16.159Z" }, + { url = "https://files.pythonhosted.org/packages/c9/7b/2da9c74c1ed25a89cc4e1c8e007ea2eb4a0f1fafa3e70d757fe3242c5c5c/librt-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:e54a315caf843c8d77e388cadc56ea9ded569935ee2d2347d7ea94992e5aa6fa", size = 126934, upload-time = "2026-07-08T12:25:17.275Z" }, + { url = "https://files.pythonhosted.org/packages/d0/65/aead61bbf3b5358593f9d4779d2a0e88eaf6ec191a6342dde36dd1df6371/librt-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:c718e99a0992127af84385378460db624103b559ab260435abcfe77a4e4ed1c1", size = 112236, upload-time = "2026-07-08T12:25:18.425Z" }, + { url = "https://files.pythonhosted.org/packages/67/3b/18e7b63255297a2bdc9c25c8d6d4ca8eca9f63aceb1252c0f7427ac7099e/librt-0.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a468951af16155824e88bdd8326ebe5bdb371f3ec0ac04642994b98201d914f3", size = 151027, upload-time = "2026-07-08T12:25:19.638Z" }, + { url = "https://files.pythonhosted.org/packages/4d/68/e2248452c00d1a03b45fee1752cdc8f790a476efd2402b75181da88a9e61/librt-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ae01d8512cc17079e53425635327dbf3f7ff57a42c00dec348bf79791c56444c", size = 155152, upload-time = "2026-07-08T12:25:20.851Z" }, + { url = "https://files.pythonhosted.org/packages/0e/16/52b1c99bf19057a062aac39c900cbb81499f6f75d6c537c14463d247ba78/librt-0.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32c26893cd085c1efe83219e78d866da23fb20a066101b8f68210004361d224c", size = 502499, upload-time = "2026-07-08T12:25:22.055Z" }, + { url = "https://files.pythonhosted.org/packages/9f/54/b811151805c795f55e0dedee6ec687b75f9982a8105d240ea3910737a77b/librt-0.13.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5929da1981a46bcf4b28b1b9499905f0ff58e2419da402a048234e9783acbc4b", size = 496108, upload-time = "2026-07-08T12:25:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/8f/f8/094d6b2bd93f3fdaa54db54cc788c4a365333bddad65ab02e04da0b1d004/librt-0.13.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94b85d664d777bab6c0d709416cb42938251fda9e221b79e3a2215d85df5f4f9", size = 531576, upload-time = "2026-07-08T12:25:24.648Z" }, + { url = "https://files.pythonhosted.org/packages/2e/40/541733d5755824f968f7ec39d78ffbd75d145964157ae5e69a09ec6d7326/librt-0.13.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:531b2df3e9fe96b1fcf73a6d165921e4656be5f58d631d384ebce344298368db", size = 524390, upload-time = "2026-07-08T12:25:25.898Z" }, + { url = "https://files.pythonhosted.org/packages/c6/b5/255673cfdbf5ba663339d36cd863c897289ab4337577e19f9405ce059f36/librt-0.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:109b84a9edf69ad89dc1f66358659e14a031baca95e3e5b0060bd903ede8efd6", size = 543053, upload-time = "2026-07-08T12:25:27.436Z" }, + { url = "https://files.pythonhosted.org/packages/9e/11/ab5005e9c9850710f21e354201bf090646349d3fabf5f951eaf70235729e/librt-0.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1304368a3e7ffc3e9db986796cc5326fdb5943a3567ecc137cff318e4240c0e7", size = 546387, upload-time = "2026-07-08T12:25:28.65Z" }, + { url = "https://files.pythonhosted.org/packages/a2/04/a5d7ce1d1df1afd15ca283dcdf7530ac073e12d69ae8c40879dda96f7868/librt-0.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e4f9b472e7d308d94b62c801982065661158c6ed02790d6c7ddb4337cea0f9c1", size = 535970, upload-time = "2026-07-08T12:25:30.171Z" }, + { url = "https://files.pythonhosted.org/packages/5a/76/927e267a6daa290174ac281b23c9804c8829b042ade9c6f24a065f540958/librt-0.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f836c37478f167a81200d8c8b2c920a22224564bed2c23d7aeec760965c367a", size = 573582, upload-time = "2026-07-08T12:25:31.507Z" }, + { url = "https://files.pythonhosted.org/packages/10/24/b6c5213efe39c19f9e13605644d0cf063b4ddaa33ac2e45b088e23a70e2e/librt-0.13.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:4000d961ff9598ac6ea603c6c836a5ed49bc205ade5fc378b998dfe1e2c36628", size = 82189, upload-time = "2026-07-08T12:25:32.675Z" }, + { url = "https://files.pythonhosted.org/packages/4c/00/d29736be177a906ac0b84a5b04b4fbfa22c776dc2f366de4172b0f968c08/librt-0.13.0-cp313-cp313-win32.whl", hash = "sha256:79e44cff71750d299d61a678e49995b0d5935a9cda238c2574daeca3ba536927", size = 106193, upload-time = "2026-07-08T12:25:33.692Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ac/aff6fb45393cb8912f39dfb156ef6b2d1cadb207ff465fc8f66141054be8/librt-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:54dab44a847d5ad1acd05c8a83fe518ae685516ecf4d3f7cc6e3df2a66767650", size = 126962, upload-time = "2026-07-08T12:25:34.769Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3a/d68cb2b334d53fd30fac81d3a489ce4ba0d9506f4df43fcf676b68352b19/librt-0.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:d4cb6fbfdf874340ab5e51450753c0f817b6958a3621125ee695bbc3de866566", size = 112127, upload-time = "2026-07-08T12:25:35.981Z" }, + { url = "https://files.pythonhosted.org/packages/7b/66/f49ae0d592bd45b6941e9a8bafcb6a87cddcd501ee7874707e767f01b585/librt-0.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:25218d94b1d2cbc0ba1d8a3f9dc9af578d9646e5ed16443a70cde1dfdcce6d71", size = 149818, upload-time = "2026-07-08T12:25:37.203Z" }, + { url = "https://files.pythonhosted.org/packages/3d/50/51c76d74014d04fb95b6506d286808984b78a2f7a41039094e6b2194ac48/librt-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f26629539d4893c2957a16c41bb058e1e135c1f150f6a2e25ed047f64cf3f5c6", size = 154071, upload-time = "2026-07-08T12:25:39.399Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fe/f19b0f5f82d5a1f2da736586bc840abd00ce07d6388136ae80b7333883fc/librt-0.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4517d47b2b8af26975a406fba7d314de9696d864252e0257c6ea90238cfe27f", size = 494168, upload-time = "2026-07-08T12:25:40.641Z" }, + { url = "https://files.pythonhosted.org/packages/94/bc/b8550c75775127fd31a5f20e8775997f7b527ad661fc8ddccd7497c064f7/librt-0.13.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f19e181de5b3a1148bb3420b8c4b0b0ea0fce6950099724ad151d6cea5acc180", size = 491054, upload-time = "2026-07-08T12:25:41.905Z" }, + { url = "https://files.pythonhosted.org/packages/30/14/4d0204867623df3f33f86efd3d3692ba5e01321443f4d6eab35a22697618/librt-0.13.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22034924f5b42d5a56371cf271771bfeaabf235a7a8b6264bef2d20013f786c6", size = 523006, upload-time = "2026-07-08T12:25:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/19/0a/c45fc9a260934696bace1ac5df1e148ac92bd71767aee3bf7cd7a4534f4c/librt-0.13.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7897db4e95e22468bdda33d8e012ceacd0182abf001e6389d763f0def6286b9", size = 515058, upload-time = "2026-07-08T12:25:44.541Z" }, + { url = "https://files.pythonhosted.org/packages/13/0a/50c5ce45b326854ef8fa6ae4c36cf5142e5c55315eaf9e51d0ae73ac4da3/librt-0.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1ce61b3746545029d4f5c17d6bd74b676254ad98433086c846ffb5e8fa73f007", size = 534025, upload-time = "2026-07-08T12:25:45.825Z" }, + { url = "https://files.pythonhosted.org/packages/89/2d/08c413c8f93fc13b8103624fce38e5caa86cd08cbbc8465870ab287af54b/librt-0.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:46c330e82565962c761dbce7941be2cff7db674ee807455a8d0cadc5f9b759b0", size = 540557, upload-time = "2026-07-08T12:25:47.059Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c1/93af71fb4a364952210051811dd4e40174e79656b050c89cacac18af3330/librt-0.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:375f5af8f99cbaa99dd293af986e3d57caabc9ba81a5d3f021603764854197a1", size = 523201, upload-time = "2026-07-08T12:25:48.392Z" }, + { url = "https://files.pythonhosted.org/packages/c1/6e/9766f07b676a4889d9f8bc2864e9ba5fff165653143ef4dda7df6aa34d16/librt-0.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9320d34c3376ae204b2cd176e8d4883a013934e0aef822f1aed9c536490c275d", size = 565740, upload-time = "2026-07-08T12:25:49.678Z" }, + { url = "https://files.pythonhosted.org/packages/a2/1e/664e3472ce2b6e10e9b83f29d4a36eb982ff6b5a169ae7567bba3a4c4ff5/librt-0.13.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:9af313c66157a69dc69ea0059a66961692250e0dc95af9c385a48ffb770a0d16", size = 81611, upload-time = "2026-07-08T12:25:50.857Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d4/8582a4d65e2234673685e07309d02c230b28a85724eb0acbf13f019b7f6e/librt-0.13.0-cp314-cp314-win32.whl", hash = "sha256:f2a7253458e34f33543551394ae4fe104b497ec2a65ac266074de64c1df82e37", size = 100106, upload-time = "2026-07-08T12:25:52.03Z" }, + { url = "https://files.pythonhosted.org/packages/63/ce/0cb99efe6086b46cd985dc26672166fae312a239690e75871f7fafbd3fc5/librt-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:a3dfe4edf10e8ed7e55b026a8bfc2c2a8704218b659cd4bffdf604fab966dc39", size = 121209, upload-time = "2026-07-08T12:25:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/26/85/4f3ccb083a3c9b0d42e223acdb3c3f507953324a59cdcab4826e8e2e3b89/librt-0.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:68a5faee4bba381cb93b5961f684a514cf0053cb92308ff9c792c2fea0b174c6", size = 106404, upload-time = "2026-07-08T12:25:54.253Z" }, + { url = "https://files.pythonhosted.org/packages/b2/77/333191499538c8e8189de7a4cba8e6f49ee949fd6d6e6324b21fd1522466/librt-0.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a38fb81d8376dfa2f8963b265fec07637802b0d01e2a127c19c66cb070fb24f5", size = 159231, upload-time = "2026-07-08T12:25:55.432Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9e/2aa83758f22c278b837a1d8025898434ce2b8bff36678d5330ecaef56dff/librt-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d4c8d9bd5abce34b2e75edb3bf37ab0f34e49b1f915a40ae8468eb7c85bc5b46", size = 161300, upload-time = "2026-07-08T12:25:56.585Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c0/86791e936553ca763d6b3c2fb4d31d596cd00e14fa631c283a40ba01559a/librt-0.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:387e2f1d27e89bffe0d3f520f0da0662c973fd607ca16c1808f8a5085419485e", size = 582056, upload-time = "2026-07-08T12:25:58.144Z" }, + { url = "https://files.pythonhosted.org/packages/a8/d3/a9ec15984a185e000c4d2a16ba28bd623124ad4c38a10974c7ff78e3a893/librt-0.13.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:4f6db193d2e5e0ed60359b9a5a682cd67205d0d3b1e459a867dd4b5c4e7eaa7a", size = 562758, upload-time = "2026-07-08T12:25:59.544Z" }, + { url = "https://files.pythonhosted.org/packages/3c/af/dbe36b78b19c06a55097f99305e4ea9458e2273e6ae16a3cbecaad7ee978/librt-0.13.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d38604854e8d22faadf683ec6c02bb0f886e2ba56ef981a1c36ee275f21ea22", size = 602095, upload-time = "2026-07-08T12:26:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a8/2966891b4dd2830f5203fbee92ac2c4947653a2390ba73dfa44244fad025/librt-0.13.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:371f7ce73026815dafd51c50ce38416e91428b28c4b2ec97cd39271164b0045c", size = 593452, upload-time = "2026-07-08T12:26:02.352Z" }, + { url = "https://files.pythonhosted.org/packages/61/f5/4df8bfc8405ecf8c0d525b4d69636f694bdd8620b313ec8b76e54a5926cc/librt-0.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3aaedf52171bee90860704c560bc798fe83b76247df47568e0197e9b13c735a0", size = 623729, upload-time = "2026-07-08T12:26:04.294Z" }, + { url = "https://files.pythonhosted.org/packages/d6/13/9ac202dffc8db06f75d06c08c2f9f6ff054be67d21272dcc078fa1cc0c57/librt-0.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:96bad8725a4f196a798366c25ce075d1f7543a4ec045ffc13e6a7ec095cdab04", size = 617077, upload-time = "2026-07-08T12:26:05.845Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f0/ebe38610716aee5cb28efd95089bb90192096179802779381e1c5dcf239c/librt-0.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:6bf6a559ffe4a93bbea6cf31ddf01a7fd9ba342ef51f27beb178e318b74acd61", size = 599561, upload-time = "2026-07-08T12:26:07.21Z" }, + { url = "https://files.pythonhosted.org/packages/4f/5c/c2e72e236fff7abc716d5b1753b8b8cd3ea85ac46fe17d2e7c51d4e1c723/librt-0.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:301067672387902c55f94b51d5022304b36c966ea9fe1f21caab99a9bef487c9", size = 645511, upload-time = "2026-07-08T12:26:08.562Z" }, + { url = "https://files.pythonhosted.org/packages/0c/99/6203ce619dee940d6bfbe099ec3fe4be00a68e9d60f70abf906cf124fe66/librt-0.13.0-cp314-cp314t-win32.whl", hash = "sha256:5fdcf34f86de8fb66d7dc7589f96ba91c4aa46671200d400e6fd6f109a483f18", size = 104357, upload-time = "2026-07-08T12:26:09.828Z" }, + { url = "https://files.pythonhosted.org/packages/52/dd/843b6314087c41657c7036d7914d8f294bdf9b580aa8513ea0588c8e9a3d/librt-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:260c33e92263fa629b4f6d3c51967a1c2158fe6c33237aaa3ebeac586b085259", size = 126998, upload-time = "2026-07-08T12:26:10.975Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/3dcec2884ba1b0806d1408612555c38dd5d68e90156b59f75f6e36435c3a/librt-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2f281549a4c52ac7bb97997f14353f8bd0e53a34ca0dad1c905cfd0b4a58ae99", size = 110771, upload-time = "2026-07-08T12:26:12.303Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mypy" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/12/af/4e516a05d3ca2eb9283e9ec45b2c02225c1514dd6da49fd3c9eaa6639370/mypy-2.3.0.tar.gz", hash = "sha256:465965d41cd9a2726694e983e8ce7113259327bec798115d1e1dfa2a52fb666e", size = 3988104, upload-time = "2026-07-13T11:34:53.387Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/94/0e7e592619e2133596a47cdd642534b0456545c218430bd3b9d8fefdd1b1/mypy-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d53fc67b9d28a43c6199077f49fea0f05839e36cf6158500331c9549225e5a5", size = 15026523, upload-time = "2026-07-13T11:34:49.206Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/1e1731df090a857df2807177a4626863e5ac0f0256513c35780efe53986f/mypy-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fbc00cee7bdbb9291979ddc9d08034a29dfcda4932628c9bbc28c1edd589df0c", size = 14032189, upload-time = "2026-07-13T11:33:57.168Z" }, + { url = "https://files.pythonhosted.org/packages/44/95/cab921f4a806e171f34113e6181dd23c55358ccf6a80741269ef594a410e/mypy-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04e617030eca5221909c8b7d8d7fd1c637948199aa2100b2ad9813feb07e1491", size = 14198696, upload-time = "2026-07-13T11:32:12.767Z" }, + { url = "https://files.pythonhosted.org/packages/66/80/e6d008bb19fe446e3662d85e0e2717bf9f2d611a2164fb29d6e067dbf46c/mypy-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56c184d2c20ca6b6378d58d1960270a767f41f5e44acbbd27f05effef4f4e1d7", size = 15286904, upload-time = "2026-07-13T11:34:27.594Z" }, + { url = "https://files.pythonhosted.org/packages/db/83/94397c9293608a364aa03e8084fb34ede4ae976a260384b9b52929308135/mypy-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3961a4a34b05f7c74b0f05aa51fbfe99a2d1e126038df40318d15c8f558b7ef3", size = 15528342, upload-time = "2026-07-13T11:34:07.819Z" }, + { url = "https://files.pythonhosted.org/packages/cf/96/d8b37d819adec6cfccfb1fd3afc1735d94717ddeafb45536db9c6943e09b/mypy-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:b1942b9314d4c784b8ea1dbab4972603290e5dd5630f06675f13aec97526bc4c", size = 11218346, upload-time = "2026-07-13T11:28:27.745Z" }, + { url = "https://files.pythonhosted.org/packages/2b/cd/cd9f725b19b19e5b530a154cf9bcf9e94279c5d55b3c34fb42b3aa48ea1b/mypy-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:be51653d7669d7d7955d613b8d0bb57d5b652eaf71a873ddf65ac87254dd2595", size = 10204525, upload-time = "2026-07-13T11:31:02.552Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ae/f7d056eb0294586a572d0d0d89580ec633c064db520f11d37d5a2fb833bd/mypy-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:91ad22a52ae2c7e621c2f67c94d5a17f66b3209a4cff5cf8a573579835c69e97", size = 14947298, upload-time = "2026-07-13T11:27:47.734Z" }, + { url = "https://files.pythonhosted.org/packages/32/d5/db3e7af01e7844d21662c6ddc1f7825ec7cb4053f0391ac02faf3638396f/mypy-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:99ac767cc5d3b64c8d0ae226ead10c96694f94e4e7da1668642225dcd4e75aac", size = 13950768, upload-time = "2026-07-13T11:27:57.726Z" }, + { url = "https://files.pythonhosted.org/packages/d9/fb/43c031f0190513d1ec248ed037eceb742ddd2a4d74bbf406658a28173837/mypy-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de6d2c484742a4d7b0ed6d07b143375624d3b899c5749c7b3c947f56261f48a6", size = 14151586, upload-time = "2026-07-13T11:29:18.615Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c3/f8b2ffc60883084da91be51af58e88a7ffd4ff9795acb7d902ff88d31eb1/mypy-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7da939dd335cfd2ad788bdfd081c9f4e47634ab995e5a45eb15fd1e5bc052f8b", size = 15227411, upload-time = "2026-07-13T11:30:29.904Z" }, + { url = "https://files.pythonhosted.org/packages/83/2e/16b917fc7adcf03f1aadddfc93aab804ffb234b1ab09c0ffd6d92a5d34a2/mypy-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7247eb2824f996722a949530183394921ca71deb9680052a338cf53cff7925c2", size = 15478790, upload-time = "2026-07-13T11:33:14.686Z" }, + { url = "https://files.pythonhosted.org/packages/c0/88/aaa65a93c73d0cdae7e42f8adb302bf6885bb281302084f99d0290a35347/mypy-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:75b0984bb3cbd76bb5c9291a8671f7ae66ca3b51c7584c358fc2e923259f0757", size = 11234919, upload-time = "2026-07-13T11:33:39.28Z" }, + { url = "https://files.pythonhosted.org/packages/35/19/b40de63f1a80e63bc2d40f0679a6a8dbd34e95176c8122119bdf406aa552/mypy-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:d78fcf900b59cb7e82cb7e3a235e31b462d9333d92285bd1e4952d355b8ffba1", size = 10201510, upload-time = "2026-07-13T11:31:52.619Z" }, + { url = "https://files.pythonhosted.org/packages/a4/58/fa0ae047da911f540284009b4f44b96fe09d83c076d7c103e9d645f46303/mypy-2.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ea317b060ce83e26050f8f9e4d7d6bf44ed7597c8ff9990bccffbb9d1d8522db", size = 14941909, upload-time = "2026-07-13T11:32:34.332Z" }, + { url = "https://files.pythonhosted.org/packages/15/14/2ba1d61452d7c2a7fe12741e8d374e52b183476b07aa7f9e2a0d02b0720a/mypy-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:094af99f92638aa92852326188b85a89e50f4a472f44827c03362228482f0762", size = 13967581, upload-time = "2026-07-13T11:30:00.587Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5a/483fb9e5ffbbb1a28dccc7b0a13d141b17ac769b6c9f488c0a0c63698962/mypy-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de121747278144fc9ae7caa2e978cf5df12aebc82933182f5b3b86081a30baef", size = 14168807, upload-time = "2026-07-13T11:28:48.6Z" }, + { url = "https://files.pythonhosted.org/packages/ae/77/70d7a10732063beb74ad713682cf871e88f5c5fa39bfc8beff8a524bf9cb/mypy-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:37fa4de896a84e2dc9200d91e614c22563b43d1a266789d4bbac7b22ebe6192b", size = 15200144, upload-time = "2026-07-13T11:31:25.283Z" }, + { url = "https://files.pythonhosted.org/packages/56/72/766218ac783be4fdfcd699b90037b63017348a3e86fb2c1fbfb18302637d/mypy-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f1b3a98dfd21058bc759bb3337d5d1f61d0fdf9f3cf9c00f4291790fb5427bff", size = 15460389, upload-time = "2026-07-13T11:29:29.077Z" }, + { url = "https://files.pythonhosted.org/packages/38/4e/8a9db7411ecb8ec0cb1fd05dba432f28bafffcd38b4e887714a4a0506689/mypy-2.3.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:944c665d984157cb96a679dfb7a4a81dd1d36b24b9c284b699514e6e626b82d4", size = 7753664, upload-time = "2026-07-13T11:29:08.147Z" }, + { url = "https://files.pythonhosted.org/packages/65/4c/c3f8bfd6ed0e5e38b5a244403b27f821d433443df5a15a278417c10a3a3c/mypy-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:4359424140d985192c778c1ce2c114a10c1ca58a381ed79cfa70d37df94b299f", size = 11417237, upload-time = "2026-07-13T11:33:47.467Z" }, + { url = "https://files.pythonhosted.org/packages/3c/00/89a32eaf5ccf174bc4f90db0eaea5d70636c01b8d49f384bdab2e8834390/mypy-2.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:3dd0bed92c4bdec57c42505b96416fb9e6a5aa7be84d2809bcd5f2ecec2860d7", size = 10389252, upload-time = "2026-07-13T11:31:43.81Z" }, + { url = "https://files.pythonhosted.org/packages/31/56/104f93d69aa9f339b6b9d3b0a7faa699b8b466c942cf3ae86cc2a2ec0915/mypy-2.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:691fdc37132b1ae628d834f672e74de83462d9fb4aff621835767fb43a8dd373", size = 16385495, upload-time = "2026-07-13T11:29:49.818Z" }, + { url = "https://files.pythonhosted.org/packages/d2/03/f1d2123313f55efafdd27706960f43a771c62f1b68426c76043f3ab9ebf3/mypy-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:aec15d465d477558fd842757b487849007311cf3897849cdda0e3162ac0ac556", size = 15098155, upload-time = "2026-07-13T11:30:40.301Z" }, + { url = "https://files.pythonhosted.org/packages/e5/5d/d5f9200399b445e81726c4f23becee33f233aee81c72680b1ef3a258b641/mypy-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b352b7e49f5e6576009e8df730e1ff4f915cb565b851b396d2ffe2f5a6f5da88", size = 15514155, upload-time = "2026-07-13T11:34:38.569Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ce/69977c555f08faa3190cfde44189b89dbd56861b1ab97aa18fc5f3a2e4a3/mypy-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c6c6bf687b17f90dbfcad95b960d32eaa0154c00da45f03ab50bf8952e047fe", size = 16766351, upload-time = "2026-07-13T11:33:29.195Z" }, + { url = "https://files.pythonhosted.org/packages/bc/92/6648b6caa3ab9e00f9ac0c2a78307805f873dd48139b24a6f6f7c3667bbf/mypy-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f4ed18f111bfe2d599bca7468e7f9251042c1c2118f762c8de2766a56d773c60", size = 17043490, upload-time = "2026-07-13T11:30:53.927Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ab/0dc91d80f3f016634c68d451f294a97320fe903a9b6f90b9e57b3f7f1717/mypy-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0b025a93cffb9781d231f232be07a17912f35f10a313c24f301c81e842870654", size = 12146869, upload-time = "2026-07-13T11:29:38.874Z" }, + { url = "https://files.pythonhosted.org/packages/85/b5/4c964d02634ba81f4d1c84838e5c5b18ab06d13ed568960f5d6318495ccc/mypy-2.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:adebc76aab4f3495a88b41d48aa4aff0c03f2822501da76625afcca5975f19e5", size = 10965113, upload-time = "2026-07-13T11:28:07.056Z" }, + { url = "https://files.pythonhosted.org/packages/2c/fa/fdc54fe583ba3cafbcedfb70eeeaf03849f75b1827a07096c7bd996f582d/mypy-2.3.0-py3-none-any.whl", hash = "sha256:6b1cdb579446b60432432b2b2403a6201b4b475a004d7f488511c9ba177c9e88", size = 2753292, upload-time = "2026-07-13T11:33:18.48Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +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" } +wheels = [ + { 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" }, +] [[package]] name = "promexp" -version = "0.1.0" +version = "2.0.0" source = { virtual = "." } dependencies = [ { name = "psutil" }, + { name = "typer" }, +] + +[package.dev-dependencies] +dev = [ + { name = "mypy" }, + { name = "pytest" }, + { name = "ruff" }, ] [package.metadata] -requires-dist = [{ name = "psutil", specifier = ">=7.2.2" }] +requires-dist = [ + { name = "psutil", specifier = ">=7.2.2" }, + { name = "typer", specifier = ">=0.27.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "mypy", specifier = ">=1.19" }, + { name = "pytest", specifier = ">=9.0" }, + { name = "ruff", specifier = ">=0.14.13" }, +] [[package]] name = "psutil" @@ -40,3 +294,99 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, ] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/06/ae069393fc66e8ff33036d4b368003833bf6e88ccf182e17e7a2f1c754fd/ruff-0.15.22.tar.gz", hash = "sha256:3f15175b1fb580126f58285a5dae6b2ea89000136d980c64499211f116b54809", size = 4785063, upload-time = "2026-07-16T15:14:13.244Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/18/ee54b7ae1e121be7a28ea6da4b67564ebb0530e183a54415ab7e3bcd2c4e/ruff-0.15.22-py3-none-linux_armv6l.whl", hash = "sha256:44423e73493737f5e7c5b41d475483898ff37afcdae38bc3da5085e29af1c2d8", size = 10781258, upload-time = "2026-07-16T15:13:19.452Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d2/2520cb14761ddbeaf57642a76942fc36adcbdbe53b4532241995f6fc485c/ruff-0.15.22-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b82c6482946e9eda7ff2e091d25b8bad3f718684e1916d41bd56873cee05b697", size = 10999477, upload-time = "2026-07-16T15:13:23.318Z" }, + { url = "https://files.pythonhosted.org/packages/c9/10/74e53572aa758dfaa678c2a2646b5c5515d884b7ca56be4d2ce03ca4b560/ruff-0.15.22-py3-none-macosx_11_0_arm64.whl", hash = "sha256:11c1c715af53a09f714e011106bffc419751ec8232fcb5da42173284ea3fec6f", size = 10466716, upload-time = "2026-07-16T15:13:26.162Z" }, + { url = "https://files.pythonhosted.org/packages/1e/cc/44eaaf0844e028182f2d0a8f2190d0f359159aed0a9e5ab861d892f1ae2a/ruff-0.15.22-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:742a29cf29bddb7c8327895d6a10e0e6c5b38a96dd407af9b5d0857f809c0576", size = 10892644, upload-time = "2026-07-16T15:13:29.229Z" }, + { url = "https://files.pythonhosted.org/packages/9f/21/8edf559014d2b0f82beea19cfb713993ad802ccda16868769979c6090a84/ruff-0.15.22-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72af58b951b0ae395935ae79763dc349bc0eb706319d28f7a33ad2cfb3cfc178", size = 10576719, upload-time = "2026-07-16T15:13:32.35Z" }, + { url = "https://files.pythonhosted.org/packages/bf/1e/3a13abd392a3b50b62e5938a831f9ab6e588358cacad5c18545b716d2182/ruff-0.15.22-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62d425005c1835eb24e2ee4161cb90e8db263415f4a71c8c72c33abaa6c0c224", size = 11376494, upload-time = "2026-07-16T15:13:35.958Z" }, + { url = "https://files.pythonhosted.org/packages/bf/3e/422d3d95bcf04dd78e1aeac22184d4f9a8fb2c01865d39d44618484a0317/ruff-0.15.22-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8b9b3f8779a4f08c969defc3c8c35abffaa757e601ed5ae66d6d1db6519969a", size = 12208370, upload-time = "2026-07-16T15:13:39.185Z" }, + { url = "https://files.pythonhosted.org/packages/1e/91/5d065a0e0a02bf4813f5119ad278462eed081d2b832eb7c021ade0ec9e65/ruff-0.15.22-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1e0dd1b2e4d3d585f897a0d137cbf4eaf6223bef4e8ce34d6bb12556c5f9249e", size = 11581098, upload-time = "2026-07-16T15:13:42.132Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f9/a0d4871d12fae702eb1f41b686caf05f1f8b124dc6db6f784f53d74918fa/ruff-0.15.22-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:365523eb91d9224e1bcb03b022fbf0facb8f9e23792a2c53d9d4b3924bdbdebb", size = 11399422, upload-time = "2026-07-16T15:13:45.2Z" }, + { url = "https://files.pythonhosted.org/packages/18/80/c843a5176cddbceb0b7e8dd41cf9993490796c1c469348d384f5a5c13c56/ruff-0.15.22-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:fabfd168afdf29fee5be98b831efa9683c94d7c5a3b58b9ce5a2e38444589a74", size = 11381683, upload-time = "2026-07-16T15:13:48.46Z" }, + { url = "https://files.pythonhosted.org/packages/d4/00/8485de0ae92239438a36cfc51350db9b9e85c9ebdfaea91b18e422706662/ruff-0.15.22-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:225dbf095a87f1d9f90f5fd7924d2613ee452a75a4308c63a8f50f761787aa7c", size = 10850295, upload-time = "2026-07-16T15:13:51.655Z" }, + { url = "https://files.pythonhosted.org/packages/fa/91/24977ec2ec72eaf15e4394ace2959fdff2dd1e14f03e005e838023407169/ruff-0.15.22-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1877d63b9d24ed278744f1523fd11b85540566d54641f97c566d7d9dc5ca5296", size = 10579640, upload-time = "2026-07-16T15:13:54.79Z" }, + { url = "https://files.pythonhosted.org/packages/9c/47/9b51216951974df1f263ac19da550d34252e0ed7218c25f10c5ef9ed7517/ruff-0.15.22-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a1606c510bd7215680d32efab38965f7cdec3ef69f5170a3f4791404ffdd5262", size = 11105077, upload-time = "2026-07-16T15:13:57.915Z" }, + { url = "https://files.pythonhosted.org/packages/c2/47/20e9d4a3b8016778acea5fc32bb50d35d207500a17ddb529ffa6996feef8/ruff-0.15.22-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:630479b18625f5ffc373f77603a22a9f8ac0acd7ff0501178b5db28ec71e9c64", size = 11490980, upload-time = "2026-07-16T15:14:01.032Z" }, + { url = "https://files.pythonhosted.org/packages/4d/76/3f72d8fc38c1cb77b38c56a70da9d0c17700cc1cc50f9649c9d3c8f5ba71/ruff-0.15.22-py3-none-win32.whl", hash = "sha256:e5ba0e4a13fd14abbed2a77b517a3911290c6c6c59ef67784328d1668fab76cf", size = 10789165, upload-time = "2026-07-16T15:14:04.16Z" }, + { url = "https://files.pythonhosted.org/packages/cb/46/4965251734c2b6fcdca1b1b187d20bcac3af0ee5b083b89c910bb961ce3a/ruff-0.15.22-py3-none-win_amd64.whl", hash = "sha256:9be63ba1eb936acd2d1342fb8337c356353706fce233b2a15a09a97037e6acde", size = 11938297, upload-time = "2026-07-16T15:14:07.316Z" }, + { url = "https://files.pythonhosted.org/packages/57/c9/e69b1ff4c8b69093ef08b8919ab767af0569666865b39c30a8795d88d3c6/ruff-0.15.22-py3-none-win_arm64.whl", hash = "sha256:e1168075b72158510839f250027659cdd78476f40507dd517892304c41318661", size = 11298172, upload-time = "2026-07-16T15:14:10.51Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "typer" +version = "0.27.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/37/78/fda3361b56efc27944f24225f6ecd13d96d6fcfe37bd0eb34e2f4c63f9fc/typer-0.27.0.tar.gz", hash = "sha256:629bd12ea5d13a17148125d9a264f949eb171fb3f120f9b04d85873cab054fa5", size = 203430, upload-time = "2026-07-15T19:21:07.007Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/03/26a383c9e58c213199d1aad1c3d353cfc22d4444ec6d2c0bf8ad02523843/typer-0.27.0-py3-none-any.whl", hash = "sha256:6f4b27631e47f077871b7dc30e933ec0131c1390fbe0e387ea5574b5bac9ccf1", size = 122716, upload-time = "2026-07-15T19:21:05.553Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] From c4e8aacded8cecc1cddc795e746fd38f83e48683 Mon Sep 17 00:00:00 2001 From: Martastain Date: Sat, 18 Jul 2026 00:03:59 +0200 Subject: [PATCH 09/17] feat: typer options --- promexp/__main__.py | 307 ++++++++++++++++++++++++++++++++++++++------ 1 file changed, 270 insertions(+), 37 deletions(-) diff --git a/promexp/__main__.py b/promexp/__main__.py index 9080e19..bcb41fc 100755 --- a/promexp/__main__.py +++ b/promexp/__main__.py @@ -1,52 +1,96 @@ #!/usr/bin/env python3 -import os -import json +import http.server import socket +from typing import Annotated, Any + +import typer from promexp import Promexp from promexp.logger import logger as logging -settings = { - "host" : "", - "port" : 9731, - "hostname" : True, - "tags" : {}, - "prefix" : "nebula", - "provider_settings" : {} -} - -logging.show_time = True - -settings_path = "settings.json" -if os.path.exists(settings_path): - try: - settings.update(json.load(open(settings_path))) - except Exception: - logging.error("Unable to parse {settings_path}. Using defaults.") - -if settings["hostname"] is True: - settings["tags"]["hostname"] = socket.gethostname() -elif type(settings["hostname"]) == str: - settings["tags"]["hostname"] = settings["hostname"] - -promexp = Promexp( - prefix=settings["prefix"], - tags=settings["tags"], - provider_settings=settings["provider_settings"], - logger=logging, -) -import http.server +def str_to_bool(value: Any) -> bool: + if isinstance(value, bool): + return value + if not isinstance(value, str): + return value + if value.lower() in ("true", "1", "yes", "on"): + return True + if value.lower() in ("false", "0", "no", "off"): + return False + return bool(value) + + +def parse_list_option(val) -> list: + if not val: + return [] + if isinstance(val, str): + items = [] + parts = val.split(",") if "," in val else val.split() + for p in parts: + p_stripped = p.strip() + if p_stripped: + items.append(p_stripped) + return items + items = [] + for item in val: + items.extend(parse_list_option(item)) + return items + + +def parse_tags_option(val): + parsed = {} + if not val: + return parsed + if isinstance(val, str): + parts = val.split(",") if "," in val else val.split() + for part in parts: + if "=" in part: + k, v = part.split("=", 1) + parsed[k.strip()] = v.strip() + return parsed + for item in val: + parsed.update(parse_tags_option(item)) + return parsed + + +def merge_settings(base, override): + if not override: + return + for k, v in override.items(): + if k == "provider_settings" and isinstance(v, dict): + if "provider_settings" not in base: + base["provider_settings"] = {} + for pk, pv in v.items(): + if pk not in base["provider_settings"]: + base["provider_settings"][pk] = {} + if isinstance(pv, dict) and isinstance( + base["provider_settings"][pk], dict + ): + base["provider_settings"][pk].update(pv) + else: + base["provider_settings"][pk] = pv + elif k == "tags" and isinstance(v, dict): + if "tags" not in base: + base["tags"] = {} + base["tags"].update(v) + else: + base[k] = v + class MetricsHandler(http.server.BaseHTTPRequestHandler): + promexp: Promexp | None = None + def log_message(self, format, *args): logging.debug(f"{self.address_string()} - - {format % args}") def do_GET(self): if self.path == "/metrics": try: - content = promexp.render() + if self.promexp is None: + raise RuntimeError("Promexp instance not set on MetricsHandler") + content = self.promexp.render() encoded = content.encode("utf-8") self.send_response(200) self.send_header("Content-Type", "text/plain; charset=utf-8") @@ -71,18 +115,207 @@ def __init__(self, app_name="promexp", logger=None): self.logger = logger or logging def serve(self, host: str, port: int): - self.logger.info(f"Starting HTTP server at {host if host else '*'}:{port}") + self.logger.info(f"Starting HTTP server at {host or '*'}:{port}") server_address = (host, port) httpd = http.server.HTTPServer(server_address, MetricsHandler) try: httpd.serve_forever() except KeyboardInterrupt: - print() self.logger.info("Keyboard interrupt. Shutting down...") httpd.server_close() -if __name__ == "__main__": +app = typer.Typer( + name="promexp", + help="Prometheus exporter for Nebula", +) + + +@app.command() +def main( + listen_address: Annotated[ + str, + typer.Option( + help="Listen address", + envvar="PROMEXP_LISTEN_ADDRESS", + ), + ] = "", + listen_port: Annotated[ + int, + typer.Option( + help="Listen port", + envvar="PROMEXP_LISTEN_PORT", + ), + ] = 9731, + prefix: Annotated[ + str, + typer.Option( + help="Metrics prefix", + envvar="PROMEXP_PREFIX", + ), + ] = "nebula", + hostname: Annotated[ + str | None, + typer.Option( + help="Include hostname tag (True/False or custom hostname string)", + envvar="PROMEXP_HOSTNAME", + ), + ] = None, + tag: Annotated[ + list[str] | None, + typer.Option( + help="Extra tags in KEY=VALUE format", + ), + ] = None, + caspar_host: Annotated[ + str | None, + typer.Option( + help="CasparCG server host", + envvar="PROMEXP_CASPAR_HOST", + ), + ] = None, + caspar_port: Annotated[ + int, + typer.Option( + help="CasparCG server port", + envvar="PROMEXP_CASPAR_PORT", + ), + ] = 5250, + caspar_osc_port: Annotated[ + int, + typer.Option( + help="CasparCG server OSC port", + envvar="PROMEXP_CASPAR_OSC_PORT", + ), + ] = 6250, + caspar_heartbeat_interval: Annotated[ + int, + typer.Option( + help="CasparCG heartbeat interval", + envvar="PROMEXP_CASPAR_HEARTBEAT_INTERVAL", + ), + ] = 10, + nvidia_smi_path: Annotated[ + str | None, + typer.Option( + help="Path to nvidia-smi binary", + envvar="PROMEXP_NVIDIA_SMI_PATH", + ), + ] = None, + storage: Annotated[ + list[str] | None, + typer.Option( + help="Storage mountpoint whitelist", + envvar="PROMEXP_STORAGES", + ), + ] = None, + network_interface: Annotated[ + list[str] | None, + typer.Option( + help="Network interfaces whitelist", + envvar="PROMEXP_NETWORK_INTERFACES", + ), + ] = None, + network_ignore_inactive: Annotated[ + bool, + typer.Option( + help="Ignore inactive network interfaces", + envvar="PROMEXP_NETWORK_IGNORE_INACTIVE", + ), + ] = True, +): + settings_dict = { + "host": "", + "port": 9731, + "hostname": True, + "tags": {}, + "prefix": "nebula", + "provider_settings": { + "casparcg": { + "host": "127.0.0.1", + "port": 5250, + "osc_port": 6250, + "heartbeat_interval": 10, + "force": False, + } + }, + } + + override = {} + if listen_address is not None: + override["host"] = listen_address + if listen_port is not None: + override["port"] = listen_port + if prefix is not None: + override["prefix"] = prefix + if hostname is not None: + override["hostname"] = str_to_bool(hostname) + + parsed_tags = parse_tags_option(tag) + if parsed_tags: + override["tags"] = parsed_tags + + prov_settings = {} + + def get_prov(name): + if name not in prov_settings: + prov_settings[name] = {} + return prov_settings[name] + + if caspar_host: + caspar_settings = {} + caspar_settings["host"] = caspar_host + caspar_settings["port"] = caspar_port + caspar_settings["osc_port"] = caspar_osc_port + caspar_settings["heartbeat_interval"] = caspar_heartbeat_interval + prov_settings["casparcg"] = caspar_settings + + # NVIDIA specific + if prov_settings.get("nvidia") is not None: + nvidia = get_prov("nvidia") + if nvidia_smi_path is not None: + nvidia["smi_path"] = nvidia_smi_path + + # Storage specific + # if prov_settings.get("storage") is not None: + # storage = get_prov("storage") + # if storages + # storage["storages"] = parse_list_option(storages) + + # Network specific + # if prov_settings.get("network") is not None: + # network = get_prov("network") + # if network_interfaces: + # network["interfaces"] = parse_list_option(network_interfaces) + # if network_ignore_inactive is not None: + # network["ignore_inactive"] = network_ignore_inactive + + if prov_settings: + override["provider_settings"] = prov_settings + + tags = {} + + # Apply hostname configuration + if hostname is None: + tags["hostname"] = socket.gethostname() + elif hostname: + tags["hostname"] = str(hostname) + + + # Initialize promexp + promexp = Promexp( + prefix=settings_dict["prefix"], + tags=tags, + provider_settings=prov_settings, + logger=logging, + ) + + # Set promexp on MetricsHandler class + MetricsHandler.promexp = promexp + server = Server("promexp", logger=logging) - server.serve("", 9731) + server.serve(listen_address, listen_port) + +if __name__ == "__main__": + app() From 41f66b39c118a1cbb01fc67e51e8152a823def5b Mon Sep 17 00:00:00 2001 From: Martastain Date: Sat, 18 Jul 2026 00:04:17 +0200 Subject: [PATCH 10/17] chore: linting --- Makefile | 14 ++- promexp/__init__.py | 13 +- promexp/logger.py | 14 +-- promexp/providers/__init__.py | 8 +- promexp/providers/casparcg/__init__.py | 87 ++++++------- promexp/providers/casparcg/bundle.py | 13 +- promexp/providers/casparcg/message.py | 30 ++--- promexp/providers/casparcg/ntp.py | 38 +++--- promexp/providers/casparcg/osc_server.py | 18 +-- promexp/providers/casparcg/osc_types.py | 148 ++++++++++++----------- promexp/providers/casparcg/packet.py | 31 +++-- promexp/providers/network.py | 19 +-- promexp/providers/nvidia.py | 57 +++++---- promexp/providers/psutil.py | 10 +- promexp/providers/storage.py | 23 ++-- promexp/providers/storagespaces.py | 55 ++++----- setup.py | 1 + 17 files changed, 296 insertions(+), 283 deletions(-) diff --git a/Makefile b/Makefile index db97e0b..7694139 100644 --- a/Makefile +++ b/Makefile @@ -1,10 +1,20 @@ IMAGE_NAME=nebulabroadcast/promexp:latest +check: + #uv version $(VERSION) + uv run ruff format . + uv run ruff check --fix . + uv run mypy . + + +build: + docker build -t $(IMAGE_NAME) . + run: build docker run -it --rm \ -p 9731:9731 \ -v ./settings.json:/app/settings.json \ $(IMAGE_NAME) -build: - docker build -t $(IMAGE_NAME) . +push: build + docker push $(IMAGE_NAME) diff --git a/promexp/__init__.py b/promexp/__init__.py index 7cae7bc..776756d 100644 --- a/promexp/__init__.py +++ b/promexp/__init__.py @@ -5,7 +5,11 @@ class Promexp: - def __init__(self, prefix="", tags={}, provider_settings={}, logger=None): + def __init__(self, prefix="", tags=None, provider_settings=None, logger=None): + if provider_settings is None: + provider_settings = {} + if tags is None: + tags = {} self.prefix = prefix self.tags = tags self.providers = {} @@ -14,7 +18,9 @@ def __init__(self, prefix="", tags={}, provider_settings={}, logger=None): for pclass in registry: self.add_provider(pclass, provider_settings.get(pclass.name, {})) - def add_provider(self, pclass, psettings={}): + def add_provider(self, pclass, psettings=None): + if psettings is None: + psettings = {} if psettings is None: return False @@ -28,6 +34,7 @@ def add_provider(self, pclass, psettings={}): if self.providers[pclass.name].enabled: self.logger.info(f"Enabling {pclass.name} provider") + return None @property def logger(self): @@ -36,7 +43,7 @@ def logger(self): return self._logger def collect(self): - for name, provider in self.providers.items(): + for provider in self.providers.values(): if provider.enabled: provider.collect() diff --git a/promexp/logger.py b/promexp/logger.py index ba19084..972bc35 100644 --- a/promexp/logger.py +++ b/promexp/logger.py @@ -1,25 +1,19 @@ +__all__ = ["log_traceback", "logger"] + import logging import sys import traceback -class CustomLogger(logging.Logger): - def goodnews(self, msg, *args, **kwargs): - # Translate goodnews to INFO level - self.info(msg, *args, **kwargs) - -# Register CustomLogger as the default logger class -logging.setLoggerClass(CustomLogger) - # Configure base logging logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S", - stream=sys.stderr + stream=sys.stderr, ) logger = logging.getLogger("promexp") -logger.show_time = True # Backward compatibility for logging.show_time = True + def log_traceback(message="Exception!", logger=logger): tb = traceback.format_exc() diff --git a/promexp/providers/__init__.py b/promexp/providers/__init__.py index 4216150..b13542d 100644 --- a/promexp/providers/__init__.py +++ b/promexp/providers/__init__.py @@ -1,9 +1,9 @@ -from .psutil import PSUtilProvider +from .casparcg import CasparCGProvider +from .network import NetworkProvider from .nvidia import NVIDIAProvider +from .psutil import PSUtilProvider from .storage import StorageProvider -from .network import NetworkProvider from .storagespaces import StorageSpacesProvider -from .casparcg import CasparCGProvider registry = [ PSUtilProvider, @@ -11,5 +11,5 @@ StorageProvider, NetworkProvider, StorageSpacesProvider, - CasparCGProvider + CasparCGProvider, ] diff --git a/promexp/providers/casparcg/__init__.py b/promexp/providers/casparcg/__init__.py index 08868e3..ff2efbe 100644 --- a/promexp/providers/casparcg/__init__.py +++ b/promexp/providers/casparcg/__init__.py @@ -1,16 +1,17 @@ -import time -import math -import threading import fractions import socket +import threading +import time + +from promexp.logger import log_traceback, logger +from promexp.provider import BaseProvider -from ...logger import log_traceback, logger as logging from .osc_server import OSCServer -from ...provider import BaseProvider class CasparResponse: """Caspar query response object""" + def __init__(self, code: int, data: str): self.code = code self.data = data @@ -41,6 +42,7 @@ def __len__(self): class CasparCG: """CasparCG client object implemented with raw sockets""" + def __init__(self, host: str = "localhost", port: int = 5250, timeout: float = 2): self.host = host self.port = port @@ -49,33 +51,35 @@ def __init__(self, host: str = "localhost", port: int = 5250, timeout: float = 2 self.rfile = None self.verbose = False - def connect(self, **kwargs) -> bool: + def connect(self) -> bool: """Create connection to CasparCG Server""" try: - self.connection = socket.create_connection((self.host, self.port), timeout=self.timeout) + self.connection = socket.create_connection( + (self.host, self.port), timeout=self.timeout + ) self.rfile = self.connection.makefile("rb") except ConnectionRefusedError: if self.verbose: - logging.error("CasparCG: Connection refused") + logger.error("CasparCG: Connection refused") return False - except socket.timeout: + except TimeoutError: if self.verbose: - logging.error("CasparCG: Connection timeout") + logger.error("CasparCG: Connection timeout") return False except Exception: if self.verbose: log_traceback("CasparCG: Unable to connect") return False - if self.verbose: - logging.goodnews("CasparCG: Connected") + logger.info("CasparCG: Connected") return True def disconnect(self): if self.connection: if self.verbose: - logging.warning("CasparCG: Disconnected") + logger.warning("CasparCG: Disconnected") try: - self.rfile.close() + if self.rfile: + self.rfile.close() except Exception: pass try: @@ -86,20 +90,15 @@ def disconnect(self): self.rfile = None @property - def is_connected(self): + def is_connected(self) -> bool: return self.connection is not None - def query(self, query: str, **kwargs) -> CasparResponse: + def query(self, query: str) -> CasparResponse: """Send an AMCP command""" - if not self.is_connected: - if not self.connect(**kwargs): - return CasparResponse(500, "Unable to connect CasparCG server") + if not self.is_connected and not self.connect(): + return CasparResponse(500, "Unable to connect CasparCG server") query = query.strip() - if kwargs.get("verbose", self.verbose): - if not query.startswith("INFO"): - logging.debug(f"Executing AMCP: {query}") - query_bytes = bytes(query.encode("utf-8")) + b"\r\n" try: @@ -126,7 +125,7 @@ def query(self, query: str, **kwargs) -> CasparResponse: if code_str == "202": return CasparResponse(202, "No result") - elif code_str in ["201", "200"]: + if code_str in ["201", "200"]: stat = int(code_str) data_line = self.rfile.readline() if not data_line: @@ -134,7 +133,7 @@ def query(self, query: str, **kwargs) -> CasparResponse: data_str = data_line.decode("utf-8").strip() return CasparResponse(stat, data_str) - elif result_str[0] in ["3", "4", "5"]: + if result_str[0] in ["3", "4", "5"]: stat = int(code_str) return CasparResponse(stat, result_str) @@ -143,10 +142,8 @@ def query(self, query: str, **kwargs) -> CasparResponse: return CasparResponse(500, f"Unexpected result: {result_str}") - - -class CasparChannel(): - def __init__(self): +class CasparChannel: + def __init__(self) -> None: self.fps = fractions.Fraction(25, 1) self._volume = 0 self.layers = {} @@ -155,18 +152,17 @@ def __getitem__(self, key): return self.layers.get(key, None) def handle_osc(self, address, *args): - if address[0:2] == ["mixer", "audio"]: + if address[0:2] == ["mixer", "audio"]: if address[2] == "volume": # Caspar 2.3 v = (max(args) / 2147483647) * 100 self._volume = max(self._volume, v) return - elif len(address) == 4 and address[3] == "pFS": + if len(address) == 4 and address[3] == "pFS": # Caspar 2.07 self._volume = max(self._volume, args[0] * 100) return - @property def peak_volume(self): v = self._volume @@ -178,14 +174,14 @@ def dropped_frames(self): return 0 -class CasparOSCServer(): +class CasparOSCServer: def __init__(self, osc_port=6250): self.osc_port = osc_port self.channels = {} self.last_message = time.time() self.osc_server = OSCServer("", self.osc_port, self.handle_osc) self.osc_thread = threading.Thread(target=self.serve, args=()) - self.osc_thread.name = 'OSC Server' + self.osc_thread.name = "OSC Server" self.osc_thread.start() def serve(self): @@ -200,7 +196,7 @@ def __getitem__(self, key): return self.channels.get(key, None) def handle_osc(self, address, *args): - if type(address) == str: + if isinstance(address, str): address = address.split("/") if len(address) < 2: return False @@ -211,13 +207,12 @@ def handle_osc(self, address, *args): except (KeyError, ValueError): return False - if not channel in self.channels: + if channel not in self.channels: self.channels[channel] = CasparChannel() self.channels[channel].handle_osc(address[3:], *args) self.last_message = time.time() - - + return None class CasparCGHeartbeat(threading.Thread): @@ -235,22 +230,19 @@ class CasparCGProvider(BaseProvider): name = "casparcg" def __init__(self, parent, settings): - super(CasparCGProvider, self).__init__(parent, settings) + super().__init__(parent, settings) self.host = settings.get("host", "127.0.0.1") self.port = settings.get("port", 5250) self.osc_port = settings.get("osc_port", 6250) self.heartbeat_interval = settings.get("heartbeat_interval", 10) - + logger.info(f"Connecting to CasparCG server at {self.host}:{self.port}") self.caspar = CasparCG(self.host, self.port, timeout=2) self.caspar.verbose = settings.get("force") - response = self.query("VERSION") - - if (not response) and (not settings.get("force")): - self.disable() - return + _ = self.query("VERSION") + logger.info(f"Starting OSC server on port {self.osc_port}") self.osc = CasparOSCServer(self.osc_port) self.heartbeat = CasparCGHeartbeat() @@ -258,18 +250,17 @@ def __init__(self, parent, settings): self.heartbeat.start() def query(self, q): - result = self.caspar.query(q, verbose=False) + result = self.caspar.query(q) if result: self.enable() return result - def collect(self): tags = {} self.add("casparcg_connected", int(self.caspar.is_connected), **tags) self.add("casparcg_idle_seconds", time.time() - self.osc.last_message, **tags) for id_channel, channel in self.osc.channels.items(): - tags = {"channel" : id_channel} + tags = {"channel": id_channel} self.add("casparcg_peak_volume", channel.peak_volume, **tags) self.add("casparcg_dropped_total", channel.dropped_frames, **tags) diff --git a/promexp/providers/casparcg/bundle.py b/promexp/providers/casparcg/bundle.py index 43ebd99..a1105e1 100644 --- a/promexp/providers/casparcg/bundle.py +++ b/promexp/providers/casparcg/bundle.py @@ -1,14 +1,15 @@ __all__ = ["OSCBundle"] +from collections.abc import Iterator +from typing import Any + from .message import OSCMessage from .osc_types import * -from typing import Any, Iterator - _BUNDLE_PREFIX = b"#bundle\x00" -class OSCBundle(object): +class OSCBundle: """Bundles elements that should be triggered at the same time. An element can be another OscBundle or an OscMessage. @@ -29,7 +30,7 @@ def __init__(self, dgram: bytes) -> None: try: self._timestamp, index = get_date(self._dgram, index) except OSCParseError as pe: - raise ParseError("Could not get the date from the datagram: %s" % pe) + raise ParseError(f"Could not get the date from the datagram: {pe}") # Get the contents as a list of OscBundle and OscMessage. self._contents = self._parse_contents(index) @@ -47,7 +48,7 @@ def _parse_contents(self, index: int) -> Any: # Get the sub content size. content_size, index = get_int(self._dgram, index) # Get the datagram for the sub content. - content_dgram = self._dgram[index:index + content_size] + content_dgram = self._dgram[index : index + content_size] # Increment our position index up to the next possible content. index += content_size # Parse the content into an OSC message or bundle. @@ -56,7 +57,7 @@ def _parse_contents(self, index: int) -> Any: elif OSCMessage.dgram_is_message(content_dgram): contents.append(OSCMessage(content_dgram)) except (OSCParseError, IndexError) as e: - raise ParseError("Could not parse a content datagram: %s" % e) + raise ParseError(f"Could not parse a content datagram: {e}") return contents diff --git a/promexp/providers/casparcg/message.py b/promexp/providers/casparcg/message.py index 5d33cf2..8f74613 100644 --- a/promexp/providers/casparcg/message.py +++ b/promexp/providers/casparcg/message.py @@ -3,12 +3,13 @@ __all__ = ["OSCMessage"] -from .osc_types import * +from collections.abc import Iterator +from typing import Any -from typing import List, Iterator, Any +from .osc_types import * -class OSCMessage(object): +class OSCMessage: """Representation of a parsed datagram representing an OSC message. An OSC message consists of an OSC Address Pattern followed by an OSC @@ -29,16 +30,13 @@ def _parse_datagram(self) -> None: # Get the parameters types. type_tag, index = get_string(self._dgram, index) - if type_tag.startswith(','): - type_tag = type_tag[1:] + type_tag = type_tag.removeprefix(",") params = [] param_stack = [params] # Parse each parameter given its type. for param in type_tag: - if param == "i": # Integer. - val, index = get_int(self._dgram, index) - elif param == "h": # Integer. + if param in {"i", "h"}: # Integer. val, index = get_int(self._dgram, index) elif param == "f": # Float. val, index = get_float(self._dgram, index) @@ -64,19 +62,23 @@ def _parse_datagram(self) -> None: param_stack.append(array) elif param == "]": # Array stop. if len(param_stack) < 2: - raise ParseError('Unexpected closing bracket in type tag: {0}'.format(type_tag)) + raise ParseError( + f"Unexpected closing bracket in type tag: {type_tag}" + ) param_stack.pop() # TODO: Support more exotic types as described in the specification. else: - logging.warning('Unhandled parameter type: {0}'.format(param)) + logging.warning(f"Unhandled parameter type: {param}") continue if param not in "[]": param_stack[-1].append(val) if len(param_stack) != 1: - raise ParseError('Missing closing bracket in type tag: {0}'.format(type_tag)) + raise ParseError( + f"Missing closing bracket in type tag: {type_tag}" + ) self._parameters = params except OSCParseError as pe: - raise ParseError('Found incorrect datagram, ignoring it', pe) + raise ParseError("Found incorrect datagram, ignoring it", pe) @property def address(self) -> str: @@ -86,7 +88,7 @@ def address(self) -> str: @staticmethod def dgram_is_message(dgram: bytes) -> bool: """Returns whether this datagram starts as an OSC message.""" - return dgram.startswith(b'/') + return dgram.startswith(b"/") @property def size(self) -> int: @@ -99,7 +101,7 @@ def dgram(self) -> bytes: return self._dgram @property - def params(self) -> List[Any]: + def params(self) -> list[Any]: """Convenience method for list(self) to get the list of parameters.""" return list(self) diff --git a/promexp/providers/casparcg/ntp.py b/promexp/providers/casparcg/ntp.py index 09ecc43..e04d3c4 100644 --- a/promexp/providers/casparcg/ntp.py +++ b/promexp/providers/casparcg/ntp.py @@ -1,18 +1,17 @@ """Parsing and conversion of NTP dates contained in datagrams.""" +import collections import datetime import struct import time -import collections - # 63 zero bits followed by a one in the least signifigant bit is a special # case meaning "immediately." -IMMEDIATELY = struct.pack('>Q', 1) +IMMEDIATELY = struct.pack(">Q", 1) # timetag * (1 / 2 ** 32) == l32bits + (r32bits / 1 ** 32) -_NTP_TIMESTAMP_TO_SECONDS = 1. / 2. ** 32. -_SECONDS_TO_NTP_TIMESTAMP = 2. ** 32. +_NTP_TIMESTAMP_TO_SECONDS = 1.0 / 2.0**32.0 +_SECONDS_TO_NTP_TIMESTAMP = 2.0**32.0 # From NTP lib. _SYSTEM_EPOCH = datetime.date(*time.gmtime(0)[0:3]) @@ -22,49 +21,44 @@ Timestamp = collections.namedtuple( - typename='Timetag', - field_names=('seconds', 'fraction')) + typename="Timetag", field_names=("seconds", "fraction") +) class NtpError(Exception): - """Base class for ntp module errors.""" + """Base class for ntp module errors.""" def parse_timestamp(timestamp: int) -> Timestamp: - """Parse NTP timestamp as Timetag. - """ + """Parse NTP timestamp as Timetag.""" seconds = timestamp >> 32 fraction = timestamp & 0xFFFFFFFF return Timestamp(seconds, fraction) def ntp_to_system_time(timestamp: bytes) -> float: - """Convert a NTP timestamp to system time in seconds. - """ + """Convert a NTP timestamp to system time in seconds.""" try: - timestamp = struct.unpack('>Q', timestamp)[0] + timestamp = struct.unpack(">Q", timestamp)[0] except Exception as e: raise NtpError(e) return timestamp * _NTP_TIMESTAMP_TO_SECONDS - _NTP_DELTA def system_time_to_ntp(seconds: float) -> bytes: - """Convert a system time in seconds to NTP timestamp. - """ + """Convert a system time in seconds to NTP timestamp.""" try: - seconds = seconds + _NTP_DELTA + seconds = seconds + _NTP_DELTA except TypeError as e: - raise NtpError(e) - return struct.pack('>Q', int(seconds * _SECONDS_TO_NTP_TIMESTAMP)) + raise NtpError(e) + return struct.pack(">Q", int(seconds * _SECONDS_TO_NTP_TIMESTAMP)) def ntp_time_to_system_epoch(seconds: float) -> float: - """Convert a NTP time in seconds to system time in seconds. - """ + """Convert a NTP time in seconds to system time in seconds.""" return seconds - _NTP_DELTA def system_time_to_ntp_epoch(seconds: float) -> float: - """Convert a system time in seconds to NTP time in seconds. - """ + """Convert a system time in seconds to NTP time in seconds.""" return seconds + _NTP_DELTA diff --git a/promexp/providers/casparcg/osc_server.py b/promexp/providers/casparcg/osc_server.py index 695ffae..af67113 100644 --- a/promexp/providers/casparcg/osc_server.py +++ b/promexp/providers/casparcg/osc_server.py @@ -1,29 +1,31 @@ -import time import socketserver - -from typing import Tuple, List +import time from types import FunctionType -from .packet import OSCPacket from .bundle import _BUNDLE_PREFIX from .osc_types import OSCParseError +from .packet import OSCPacket + class OSCHandler(socketserver.BaseRequestHandler): def handle(self) -> None: try: packet = OSCPacket(self.request[0]) for timed_msg in packet.messages: - now = time.time() + time.time() message = timed_msg.message self.server.handle(message.address, *message.params) except OSCParseError: pass + class OSCServer(socketserver.UDPServer): - def __init__(self, host: str, port:int, handler:FunctionType) -> None: + def __init__(self, host: str, port: int, handler: FunctionType) -> None: self.handle = handler super().__init__((host, port), OSCHandler) - def verify_request(self, request: List[bytes], client_address: Tuple[str, int]) -> bool: + def verify_request( + self, request: list[bytes], client_address: tuple[str, int] + ) -> bool: data = request[0] - return data.startswith(_BUNDLE_PREFIX) or data.startswith(b"/") + return data.startswith((_BUNDLE_PREFIX, b"/")) diff --git a/promexp/providers/casparcg/osc_types.py b/promexp/providers/casparcg/osc_types.py index eb70a24..efcbc79 100644 --- a/promexp/providers/casparcg/osc_types.py +++ b/promexp/providers/casparcg/osc_types.py @@ -1,31 +1,30 @@ """Functions to get OSC types from datagrams""" __all__ = [ - "OSCParseError", "IMMEDIATELY", - "get_string", - "get_int", - "get_uint64", - "get_timetag", - "get_float", - "get_double", + "OSCParseError", "get_blob", "get_date", + "get_double", + "get_float", + "get_int", + "get_midi", "get_rgba", - "get_midi" + "get_string", + "get_timetag", + "get_uint64", ] import struct +from datetime import datetime, timedelta from . import ntp -from datetime import datetime, timedelta, date - -from typing import Union, Tuple class OSCParseError(Exception): """Base exception for when a datagram parsing error occurs.""" + # Constant for special ntp datagram sequences that represent an immediate time. IMMEDIATELY = 0 @@ -38,10 +37,10 @@ class OSCParseError(Exception): # Strings and blob dgram length is always a multiple of 4 bytes. _STRING_DGRAM_PAD = 4 _BLOB_DGRAM_PAD = 4 -_EMPTY_STR_DGRAM = b'\x00\x00\x00\x00' +_EMPTY_STR_DGRAM = b"\x00\x00\x00\x00" -def get_string(dgram: bytes, start_index: int) -> Tuple[str, int]: +def get_string(dgram: bytes, start_index: int) -> tuple[str, int]: """Get a python string from the datagram, starting at pos start_index. According to the specifications, a string is: @@ -60,32 +59,34 @@ def get_string(dgram: bytes, start_index: int) -> Tuple[str, int]: ParseError if the datagram could not be parsed. """ if start_index < 0: - raise ParseError('start_index < 0') + raise ParseError("start_index < 0") offset = 0 try: - if (len(dgram) > start_index + _STRING_DGRAM_PAD - and dgram[start_index + _STRING_DGRAM_PAD] == _EMPTY_STR_DGRAM): - return '', start_index + _STRING_DGRAM_PAD + if ( + len(dgram) > start_index + _STRING_DGRAM_PAD + and dgram[start_index + _STRING_DGRAM_PAD] == _EMPTY_STR_DGRAM + ): + return "", start_index + _STRING_DGRAM_PAD while dgram[start_index + offset] != 0: offset += 1 # Align to a byte word. if (offset) % _STRING_DGRAM_PAD == 0: offset += _STRING_DGRAM_PAD else: - offset += (-offset % _STRING_DGRAM_PAD) + offset += -offset % _STRING_DGRAM_PAD # Python slices do not raise an IndexError past the last index, # do it ourselves. if offset > len(dgram[start_index:]): - raise ParseError('Datagram is too short') - data_str = dgram[start_index:start_index + offset] - return data_str.replace(b'\x00', b'').decode('utf-8'), start_index + offset + raise ParseError("Datagram is too short") + data_str = dgram[start_index : start_index + offset] + return data_str.replace(b"\x00", b"").decode("utf-8"), start_index + offset except IndexError as ie: - raise ParseError('Could not parse datagram %s' % ie) + raise ParseError(f"Could not parse datagram {ie}") except TypeError as te: - raise ParseError('Could not parse datagram %s' % te) + raise ParseError(f"Could not parse datagram {te}") -def get_int(dgram: bytes, start_index: int) -> Tuple[int, int]: +def get_int(dgram: bytes, start_index: int) -> tuple[int, int]: """Get a 32-bit big-endian two's complement integer from the datagram. Args: @@ -100,16 +101,16 @@ def get_int(dgram: bytes, start_index: int) -> Tuple[int, int]: """ try: if len(dgram[start_index:]) < _INT_DGRAM_LEN: - raise ParseError('Datagram is too short') + raise ParseError("Datagram is too short") return ( - struct.unpack('>i', - dgram[start_index:start_index + _INT_DGRAM_LEN])[0], - start_index + _INT_DGRAM_LEN) + struct.unpack(">i", dgram[start_index : start_index + _INT_DGRAM_LEN])[0], + start_index + _INT_DGRAM_LEN, + ) except (struct.error, TypeError) as e: - raise ParseError('Could not parse datagram %s' % e) + raise ParseError(f"Could not parse datagram {e}") -def get_uint64(dgram: bytes, start_index: int) -> Tuple[int, int]: +def get_uint64(dgram: bytes, start_index: int) -> tuple[int, int]: """Get a 64-bit big-endian unsigned integer from the datagram. Args: @@ -124,16 +125,18 @@ def get_uint64(dgram: bytes, start_index: int) -> Tuple[int, int]: """ try: if len(dgram[start_index:]) < _UINT64_DGRAM_LEN: - raise ParseError('Datagram is too short') + raise ParseError("Datagram is too short") return ( - struct.unpack('>Q', - dgram[start_index:start_index + _UINT64_DGRAM_LEN])[0], - start_index + _UINT64_DGRAM_LEN) + struct.unpack(">Q", dgram[start_index : start_index + _UINT64_DGRAM_LEN])[ + 0 + ], + start_index + _UINT64_DGRAM_LEN, + ) except (struct.error, TypeError) as e: - raise ParseError('Could not parse datagram %s' % e) + raise ParseError(f"Could not parse datagram {e}") -def get_timetag(dgram: bytes, start_index: int) -> Tuple[datetime, int]: +def get_timetag(dgram: bytes, start_index: int) -> tuple[datetime, int]: """Get a 64-bit OSC time tag from the datagram. Args: @@ -149,7 +152,7 @@ def get_timetag(dgram: bytes, start_index: int) -> Tuple[datetime, int]: """ try: if len(dgram[start_index:]) < _TIMETAG_DGRAM_LEN: - raise ParseError('Datagram is too short') + raise ParseError("Datagram is too short") timetag, _ = get_uint64(dgram, start_index) seconds, fraction = ntp.parse_timestamp(timetag) @@ -157,16 +160,16 @@ def get_timetag(dgram: bytes, start_index: int) -> Tuple[datetime, int]: hours, seconds = seconds // 3600, seconds % 3600 minutes, seconds = seconds // 60, seconds % 60 - utc = (datetime.combine(ntp._NTP_EPOCH, datetime.min.time()) + - timedelta(hours=hours, minutes=minutes, seconds=seconds)) + utc = datetime.combine(ntp._NTP_EPOCH, datetime.min.time()) + timedelta( + hours=hours, minutes=minutes, seconds=seconds + ) return (utc, fraction), start_index + _TIMETAG_DGRAM_LEN except (struct.error, TypeError) as e: - raise ParseError('Could not parse datagram %s' % e) - + raise ParseError(f"Could not parse datagram {e}") -def get_float(dgram: bytes, start_index: int) -> Tuple[float, int]: +def get_float(dgram: bytes, start_index: int) -> tuple[float, int]: """Get a 32-bit big-endian IEEE 754 floating point number from the datagram. Args: @@ -184,17 +187,16 @@ def get_float(dgram: bytes, start_index: int) -> Tuple[float, int]: # Noticed that Reaktor doesn't send the last bunch of \x00 needed to make # the float representation complete in some cases, thus we pad here to # account for that. - dgram = dgram + b'\x00' * (_FLOAT_DGRAM_LEN - len(dgram[start_index:])) + dgram = dgram + b"\x00" * (_FLOAT_DGRAM_LEN - len(dgram[start_index:])) return ( - struct.unpack('>f', - dgram[start_index:start_index + _FLOAT_DGRAM_LEN])[0], - start_index + _FLOAT_DGRAM_LEN) + struct.unpack(">f", dgram[start_index : start_index + _FLOAT_DGRAM_LEN])[0], + start_index + _FLOAT_DGRAM_LEN, + ) except (struct.error, TypeError) as e: - raise ParseError('Could not parse datagram %s' % e) + raise ParseError(f"Could not parse datagram {e}") - -def get_double(dgram: bytes, start_index: int) -> Tuple[float, int]: +def get_double(dgram: bytes, start_index: int) -> tuple[float, int]: """Get a 64-bit big-endian IEEE 754 floating point number from the datagram. Args: @@ -209,17 +211,19 @@ def get_double(dgram: bytes, start_index: int) -> Tuple[float, int]: """ try: if len(dgram[start_index:]) < _DOUBLE_DGRAM_LEN: - raise ParseError('Datagram is too short') + raise ParseError("Datagram is too short") return ( - struct.unpack('>d', - dgram[start_index:start_index + _DOUBLE_DGRAM_LEN])[0], - start_index + _DOUBLE_DGRAM_LEN) + struct.unpack(">d", dgram[start_index : start_index + _DOUBLE_DGRAM_LEN])[ + 0 + ], + start_index + _DOUBLE_DGRAM_LEN, + ) except (struct.error, TypeError) as e: - raise ParseError('Could not parse datagram {}'.format(e)) + raise ParseError(f"Could not parse datagram {e}") -def get_blob(dgram: bytes, start_index: int) -> Tuple[bytes, int]: - """ Get a blob from the datagram. +def get_blob(dgram: bytes, start_index: int) -> tuple[bytes, int]: + """Get a blob from the datagram. According to the specifications, a blob is made of "an int32 size count, followed by that many 8-bit bytes of arbitrary @@ -241,12 +245,11 @@ def get_blob(dgram: bytes, start_index: int) -> Tuple[bytes, int]: total_size = size + (-size % _BLOB_DGRAM_PAD) end_index = int_offset + size if end_index - start_index > len(dgram[start_index:]): - raise ParseError('Datagram is too short.') - return dgram[int_offset:int_offset + size], int_offset + total_size - + raise ParseError("Datagram is too short.") + return dgram[int_offset : int_offset + size], int_offset + total_size -def get_date(dgram: bytes, start_index: int) -> Tuple[float, int]: +def get_date(dgram: bytes, start_index: int) -> tuple[float, int]: """Get a 64-bit big-endian fixed-point time tag as a date from the datagram. According to the specifications, a date is represented as is: @@ -266,16 +269,16 @@ def get_date(dgram: bytes, start_index: int) -> Tuple[float, int]: ParseError if the datagram could not be parsed. """ # Check for the special case first. - if dgram[start_index:start_index + _TIMETAG_DGRAM_LEN] == ntp.IMMEDIATELY: + if dgram[start_index : start_index + _TIMETAG_DGRAM_LEN] == ntp.IMMEDIATELY: return IMMEDIATELY, start_index + _TIMETAG_DGRAM_LEN if len(dgram[start_index:]) < _TIMETAG_DGRAM_LEN: - raise ParseError('Datagram is too short') + raise ParseError("Datagram is too short") timetag, start_index = get_uint64(dgram, start_index) seconds = timetag * ntp._NTP_TIMESTAMP_TO_SECONDS return ntp.ntp_time_to_system_epoch(seconds), start_index -def get_rgba(dgram: bytes, start_index: int) -> Tuple[bytes, int]: +def get_rgba(dgram: bytes, start_index: int) -> tuple[bytes, int]: """Get an rgba32 integer from the datagram. Args: @@ -290,16 +293,16 @@ def get_rgba(dgram: bytes, start_index: int) -> Tuple[bytes, int]: """ try: if len(dgram[start_index:]) < _INT_DGRAM_LEN: - raise ParseError('Datagram is too short') + raise ParseError("Datagram is too short") return ( - struct.unpack('>I', - dgram[start_index:start_index + _INT_DGRAM_LEN])[0], - start_index + _INT_DGRAM_LEN) + struct.unpack(">I", dgram[start_index : start_index + _INT_DGRAM_LEN])[0], + start_index + _INT_DGRAM_LEN, + ) except (struct.error, TypeError) as e: - raise ParseError('Could not parse datagram %s' % e) + raise ParseError(f"Could not parse datagram {e}") -def get_midi(dgram: bytes, start_index: int) -> Tuple[Tuple[int, int, int, int], int]: +def get_midi(dgram: bytes, start_index: int) -> tuple[tuple[int, int, int, int], int]: """Get a MIDI message (port id, status byte, data1, data2) from the datagram. Args: @@ -314,10 +317,9 @@ def get_midi(dgram: bytes, start_index: int) -> Tuple[Tuple[int, int, int, int], """ try: if len(dgram[start_index:]) < _INT_DGRAM_LEN: - raise ParseError('Datagram is too short') - val = struct.unpack('>I', - dgram[start_index:start_index + _INT_DGRAM_LEN])[0] + raise ParseError("Datagram is too short") + val = struct.unpack(">I", dgram[start_index : start_index + _INT_DGRAM_LEN])[0] midi_msg = tuple((val & 0xFF << 8 * i) >> 8 * i for i in range(3, -1, -1)) return (midi_msg, start_index + _INT_DGRAM_LEN) except (struct.error, TypeError) as e: - raise ParseError('Could not parse datagram %s' % e) + raise ParseError(f"Could not parse datagram {e}") diff --git a/promexp/providers/casparcg/packet.py b/promexp/providers/casparcg/packet.py index 430079c..27d8843 100644 --- a/promexp/providers/casparcg/packet.py +++ b/promexp/providers/casparcg/packet.py @@ -3,31 +3,28 @@ It lets you access easily to OscMessage and OscBundle instances in the packet. """ -import time import collections +import time -from typing import Union, List - -from .osc_types import * from .bundle import OSCBundle from .message import OSCMessage - +from .osc_types import * # A namedtuple as returned my the _timed_msg_of_bundle function. # 1) the system time at which the message should be executed # in seconds since the epoch. # 2) the actual message. TimedMessage = collections.namedtuple( - typename='TimedMessage', - field_names=('time', 'message')) + typename="TimedMessage", field_names=("time", "message") +) -def _timed_msg_of_bundle(bundle: OSCBundle, now: float) -> List[TimedMessage]: +def _timed_msg_of_bundle(bundle: OSCBundle, now: float) -> list[TimedMessage]: """Returns messages contained in nested bundles as a list of TimedMessage.""" msgs = [] for content in bundle: if type(content) is OSCMessage: - if (bundle.timestamp == IMMEDIATELY or bundle.timestamp < now): + if bundle.timestamp == IMMEDIATELY or bundle.timestamp < now: msgs.append(TimedMessage(now, content)) else: msgs.append(TimedMessage(bundle.timestamp, content)) @@ -36,8 +33,7 @@ def _timed_msg_of_bundle(bundle: OSCBundle, now: float) -> List[TimedMessage]: return msgs - -class OSCPacket(object): +class OSCPacket: """Unit of transmission of the OSC protocol. Any application that sends OSC Packets is an OSC Client. @@ -57,17 +53,18 @@ def __init__(self, dgram: bytes) -> None: try: if OSCBundle.dgram_is_bundle(dgram): self._messages = sorted( - _timed_msg_of_bundle(OSCBundle(dgram), now), - key=lambda x: x.time) + _timed_msg_of_bundle(OSCBundle(dgram), now), key=lambda x: x.time + ) elif OSCMessage.dgram_is_message(dgram): self._messages = [TimedMessage(now, OscMessage(dgram))] else: raise OSCParseError( - 'OSC Packet should at least contain an OscMessage or an OscBundle.') - except (OSCParseError) as pe: - raise ParseError(f'Could not parse packet {pe}') + "OSC Packet should at least contain an OscMessage or an OscBundle." + ) + except OSCParseError as pe: + raise ParseError(f"Could not parse packet {pe}") @property - def messages(self) -> List[TimedMessage]: + def messages(self) -> list[TimedMessage]: """Returns asc-time-sorted TimedMessages of the messages in this packet.""" return self._messages diff --git a/promexp/providers/network.py b/promexp/providers/network.py index a6755c7..0e08fd9 100644 --- a/promexp/providers/network.py +++ b/promexp/providers/network.py @@ -2,20 +2,21 @@ import psutil -from ..provider import BaseProvider +from promexp.provider import BaseProvider + class NetworkProvider(BaseProvider): name = "network" def __init__(self, parent, settings): - super(NetworkProvider, self).__init__(parent, settings) + super().__init__(parent, settings) self.interface_blacklist = ["lo"] self.interface_whitelist = [] wl = self.get("interfaces", []) - for interface in psutil.net_if_stats().keys(): + for interface in psutil.net_if_stats(): istat = psutil.net_if_stats()[interface] if not istat.isup: continue @@ -25,10 +26,9 @@ def __init__(self, parent, settings): continue self.interface_whitelist.append(interface) - def collect(self): netstat = psutil.net_io_counters(pernic=True) - for interface in netstat.keys(): + for interface in netstat: if interface in self.interface_blacklist: continue @@ -37,7 +37,10 @@ def collect(self): istat = netstat[interface] if istat.bytes_sent or not self.get("ignore_inactive", True): - self.add("network_sent_bytes_total", istat.bytes_sent, interface=interface) + self.add( + "network_sent_bytes_total", istat.bytes_sent, interface=interface + ) if istat.bytes_recv or not self.get("ignore_inactive", True): - self.add("network_recv_bytes_total", istat.bytes_recv, interface=interface) - + self.add( + "network_recv_bytes_total", istat.bytes_recv, interface=interface + ) diff --git a/promexp/providers/nvidia.py b/promexp/providers/nvidia.py index 7e3a778..2f50a5a 100644 --- a/promexp/providers/nvidia.py +++ b/promexp/providers/nvidia.py @@ -2,9 +2,9 @@ import os import subprocess - from xml.etree import ElementTree as ET -from ..provider import BaseProvider + +from promexp.provider import BaseProvider def parse_number(string): @@ -18,14 +18,14 @@ class NVIDIAProvider(BaseProvider): name = "nvidia" def __init__(self, parent, settings): - super(NVIDIAProvider, self).__init__(parent, settings) + super().__init__(parent, settings) smi_paths = [ - "c:\\Program Files\\NVIDIA Corporation\\NVSMI\\nvidia-smi.exe", - "c:\\Windows\\System32\\nvidia-smi.exe", - "/usr/bin/nvidia-smi", - "/usr/local/bin/nvidia-smi" - ] + "c:\\Program Files\\NVIDIA Corporation\\NVSMI\\nvidia-smi.exe", + "c:\\Windows\\System32\\nvidia-smi.exe", + "/usr/bin/nvidia-smi", + "/usr/local/bin/nvidia-smi", + ] if self["smi_path"]: smi_paths.insert(0, self["smi_path"]) @@ -37,36 +37,49 @@ def __init__(self, parent, settings): else: self.disable() - def collect(self): try: rawdata = subprocess.check_output([self.smi_path, "-q", "-x"]) except Exception: - self.logger.error("Unable to execute nvidia-smi") + self.logger.exception("Unable to execute nvidia-smi") return rawdata = rawdata.decode("utf-8") try: xml = ET.XML(rawdata) except Exception: - self.logger.error("Unable to parse nvidia-smi output") + self.logger.exception("Unable to parse nvidia-smi output") return for i, gpu in enumerate(xml.findall("gpu")): - - tags = { - "id" : i, - "model" : gpu.find("product_name").text - } + tags = {"id": i, "model": gpu.find("product_name").text} utilization = gpu.find("utilization") temperature = gpu.find("temperature") power = gpu.find("power_readings") - self.add("gpu_usage", parse_number(utilization.find("gpu_util").text), **tags) - self.add("gpu_memory", parse_number(utilization.find("memory_util").text), **tags) - self.add("gpu_encoder", parse_number(utilization.find("encoder_util").text), **tags) - self.add("gpu_decoder", parse_number(utilization.find("decoder_util").text), **tags) + self.add( + "gpu_usage", parse_number(utilization.find("gpu_util").text), **tags + ) + self.add( + "gpu_memory", parse_number(utilization.find("memory_util").text), **tags + ) + self.add( + "gpu_encoder", + parse_number(utilization.find("encoder_util").text), + **tags, + ) + self.add( + "gpu_decoder", + parse_number(utilization.find("decoder_util").text), + **tags, + ) self.add("gpu_fan_speed", parse_number(gpu.find("fan_speed").text), **tags) - self.add("gpu_temperature", parse_number(temperature.find("gpu_temp").text), **tags) - self.add("gpu_power_draw", parse_number(power.find("power_draw").text), **tags) + self.add( + "gpu_temperature", + parse_number(temperature.find("gpu_temp").text), + **tags, + ) + self.add( + "gpu_power_draw", parse_number(power.find("power_draw").text), **tags + ) diff --git a/promexp/providers/psutil.py b/promexp/providers/psutil.py index 3b2ae94..162c7c7 100644 --- a/promexp/providers/psutil.py +++ b/promexp/providers/psutil.py @@ -1,15 +1,17 @@ __all__ = ["PSUtilProvider"] import time + import psutil -from ..provider import BaseProvider +from promexp.provider import BaseProvider + class PSUtilProvider(BaseProvider): name = "psutil" def __init__(self, parent, settings): - super(PSUtilProvider, self).__init__(parent, settings) + super().__init__(parent, settings) self.boot_time = psutil.boot_time() self.run_time = time.time() @@ -29,12 +31,12 @@ def collect(self): self.add("cpu_usage", cpu) self.add("memory_bytes_total", mem.total) self.add("memory_bytes_free", mem.available) - self.add("memory_usage", 100*((mem.total-mem.available)/mem.total)) + self.add("memory_usage", 100 * ((mem.total - mem.available) / mem.total)) if swp and swp.total: self.add("swap_bytes_total", swp.total) self.add("swap_bytes_free", swp.free) - self.add("swap_usage", 100*((swp.total-swp.free)/swp.total)) + self.add("swap_usage", 100 * ((swp.total - swp.free) / swp.total)) if dsk: self.add("disk_read_bytes", dsk.read_bytes) diff --git a/promexp/providers/storage.py b/promexp/providers/storage.py index 8e8aa50..a6fb937 100644 --- a/promexp/providers/storage.py +++ b/promexp/providers/storage.py @@ -2,14 +2,14 @@ import psutil -from ..provider import BaseProvider +from promexp.provider import BaseProvider class StorageProvider(BaseProvider): name = "storage" def __init__(self, parent, settings): - super(StorageProvider, self).__init__(parent, settings) + super().__init__(parent, settings) mountpoint_blacklist = [ "/run", "/proc", @@ -25,20 +25,17 @@ def __init__(self, parent, settings): self.storages = [] for storage in psutil.disk_partitions(all=True): if not all( - [not storage.mountpoint.startswith(b) for b in mountpoint_blacklist] + not storage.mountpoint.startswith(b) for b in mountpoint_blacklist ): continue - if mountpoint_whitelist: - if not any( - [ - storage.mountpoint == b - if b == "/" - else storage.mountpoint.lower().startswith(b.lower()) - for b in mountpoint_whitelist - ] - ): - continue + if mountpoint_whitelist and not any( + storage.mountpoint == b + if b == "/" + else storage.mountpoint.lower().startswith(b.lower()) + for b in mountpoint_whitelist + ): + continue self.storages.append( { diff --git a/promexp/providers/storagespaces.py b/promexp/providers/storagespaces.py index b12e1e0..0cc9915 100644 --- a/promexp/providers/storagespaces.py +++ b/promexp/providers/storagespaces.py @@ -1,20 +1,21 @@ __all__ = ["StorageSpacesProvider"] import os -import time import subprocess import threading +import time -from ..provider import BaseProvider +from promexp.provider import BaseProvider def get_ss_status(): - c = subprocess.Popen([ - "powershell.exe", - "Get-VirtualDisk | Format-Table FriendlyName,ResiliencySettingName,OperationalStatus, HealthStatus" - ], - stderr=subprocess.PIPE, - stdout=subprocess.PIPE + c = subprocess.Popen( + [ + "powershell.exe", + "Get-VirtualDisk | Format-Table FriendlyName,ResiliencySettingName,OperationalStatus, HealthStatus", + ], + stderr=subprocess.PIPE, + stdout=subprocess.PIPE, ) stdout, _ = c.communicate() @@ -27,7 +28,7 @@ def get_ss_status(): pos = line.find(" -", bounds[-1]) if pos == -1: break - bounds.append(pos+1) + bounds.append(pos + 1) continue if not bounds: @@ -37,19 +38,16 @@ def get_ss_status(): if not line: continue - title = line[bounds[0]:bounds[1]].strip() - mode = line[bounds[1]:bounds[2]].strip() - status = line[bounds[2]:bounds[3]].strip() - health = line[bounds[3]:].strip() - - result.append({ - "title" : title, - "mode" : mode, - "status" : status, - "health" : health - }) - return result + title = line[bounds[0] : bounds[1]].strip() + mode = line[bounds[1] : bounds[2]].strip() + status = line[bounds[2] : bounds[3]].strip() + health = line[bounds[3] :].strip() + result.append( + {"title": title, "mode": mode, "status": status, "health": health} + ) + return result + return None class SSWorker(threading.Thread): @@ -58,7 +56,7 @@ def run(self): try: self.result = get_ss_status() except Exception: - self.logger.error("Unable to get storage spaces status") + self.logger.exception("Unable to get storage spaces status") time.sleep(30) @@ -67,9 +65,9 @@ class StorageSpacesProvider(BaseProvider): name = "storagespaces" def __init__(self, parent, settings): - super(StorageSpacesProvider, self).__init__(parent, settings) + super().__init__(parent, settings) - if os.name != "nt": + if os.name != "nt": self.disable() return @@ -92,8 +90,7 @@ def collect(self): status = [] for sspace in status: - tags = { - "name" : sspace["title"], - "mode" : sspace["mode"] - } - self.add("storage_space_healthy", int(sspace["health"] == "Healthy"), **tags) + tags = {"name": sspace["title"], "mode": sspace["mode"]} + self.add( + "storage_space_healthy", int(sspace["health"] == "Healthy"), **tags + ) diff --git a/setup.py b/setup.py index f78df18..3c972e6 100644 --- a/setup.py +++ b/setup.py @@ -1,4 +1,5 @@ import os + from setuptools import setup From ddeb121571960dd9cc2796f4456ab83dd621fcc9 Mon Sep 17 00:00:00 2001 From: Martastain Date: Sat, 18 Jul 2026 00:12:30 +0200 Subject: [PATCH 11/17] fix: types --- promexp/__main__.py | 1 - promexp/providers/casparcg/bundle.py | 15 +++---- promexp/providers/casparcg/message.py | 4 +- promexp/providers/casparcg/ntp.py | 4 +- promexp/providers/casparcg/osc_types.py | 56 ++++++++++++------------- promexp/providers/casparcg/packet.py | 25 +++++------ promexp/providers/storage.py | 6 +-- 7 files changed, 50 insertions(+), 61 deletions(-) diff --git a/promexp/__main__.py b/promexp/__main__.py index bcb41fc..5e8474e 100755 --- a/promexp/__main__.py +++ b/promexp/__main__.py @@ -301,7 +301,6 @@ def get_prov(name): elif hostname: tags["hostname"] = str(hostname) - # Initialize promexp promexp = Promexp( prefix=settings_dict["prefix"], diff --git a/promexp/providers/casparcg/bundle.py b/promexp/providers/casparcg/bundle.py index a1105e1..842a032 100644 --- a/promexp/providers/casparcg/bundle.py +++ b/promexp/providers/casparcg/bundle.py @@ -4,7 +4,7 @@ from typing import Any from .message import OSCMessage -from .osc_types import * +from .osc_types import OSCParseError, get_date, get_int _BUNDLE_PREFIX = b"#bundle\x00" @@ -27,15 +27,10 @@ def __init__(self, dgram: bytes) -> None: # Interesting stuff starts after the initial b"#bundle\x00". self._dgram = dgram index = len(_BUNDLE_PREFIX) - try: - self._timestamp, index = get_date(self._dgram, index) - except OSCParseError as pe: - raise ParseError(f"Could not get the date from the datagram: {pe}") + self._timestamp, index = get_date(self._dgram, index) # Get the contents as a list of OscBundle and OscMessage. self._contents = self._parse_contents(index) - # Return type is actually List[OscBundle], but that would require import annotations from __future__, which is - # python 3.7+ only. def _parse_contents(self, index: int) -> Any: contents = [] @@ -56,8 +51,8 @@ def _parse_contents(self, index: int) -> Any: contents.append(OSCBundle(content_dgram)) elif OSCMessage.dgram_is_message(content_dgram): contents.append(OSCMessage(content_dgram)) - except (OSCParseError, IndexError) as e: - raise ParseError(f"Could not parse a content datagram: {e}") + except IndexError as e: + raise OSCParseError(f"Could not parse a content datagram: {e}") from e return contents @@ -67,7 +62,7 @@ def dgram_is_bundle(dgram: bytes) -> bool: return dgram.startswith(_BUNDLE_PREFIX) @property - def timestamp(self) -> int: + def timestamp(self) -> float: """Returns the timestamp associated with this bundle.""" return self._timestamp diff --git a/promexp/providers/casparcg/message.py b/promexp/providers/casparcg/message.py index 8f74613..716a0e8 100644 --- a/promexp/providers/casparcg/message.py +++ b/promexp/providers/casparcg/message.py @@ -73,9 +73,7 @@ def _parse_datagram(self) -> None: if param not in "[]": param_stack[-1].append(val) if len(param_stack) != 1: - raise ParseError( - f"Missing closing bracket in type tag: {type_tag}" - ) + raise ParseError(f"Missing closing bracket in type tag: {type_tag}") self._parameters = params except OSCParseError as pe: raise ParseError("Found incorrect datagram, ignoring it", pe) diff --git a/promexp/providers/casparcg/ntp.py b/promexp/providers/casparcg/ntp.py index e04d3c4..9714a77 100644 --- a/promexp/providers/casparcg/ntp.py +++ b/promexp/providers/casparcg/ntp.py @@ -41,7 +41,7 @@ def ntp_to_system_time(timestamp: bytes) -> float: try: timestamp = struct.unpack(">Q", timestamp)[0] except Exception as e: - raise NtpError(e) + raise NtpError(e) from e return timestamp * _NTP_TIMESTAMP_TO_SECONDS - _NTP_DELTA @@ -50,7 +50,7 @@ def system_time_to_ntp(seconds: float) -> bytes: try: seconds = seconds + _NTP_DELTA except TypeError as e: - raise NtpError(e) + raise NtpError(e) from e return struct.pack(">Q", int(seconds * _SECONDS_TO_NTP_TIMESTAMP)) diff --git a/promexp/providers/casparcg/osc_types.py b/promexp/providers/casparcg/osc_types.py index efcbc79..bbb8174 100644 --- a/promexp/providers/casparcg/osc_types.py +++ b/promexp/providers/casparcg/osc_types.py @@ -59,7 +59,7 @@ def get_string(dgram: bytes, start_index: int) -> tuple[str, int]: ParseError if the datagram could not be parsed. """ if start_index < 0: - raise ParseError("start_index < 0") + raise OSCParseError("start_index < 0") offset = 0 try: if ( @@ -77,13 +77,13 @@ def get_string(dgram: bytes, start_index: int) -> tuple[str, int]: # Python slices do not raise an IndexError past the last index, # do it ourselves. if offset > len(dgram[start_index:]): - raise ParseError("Datagram is too short") + raise OSCParseError("Datagram is too short") data_str = dgram[start_index : start_index + offset] return data_str.replace(b"\x00", b"").decode("utf-8"), start_index + offset except IndexError as ie: - raise ParseError(f"Could not parse datagram {ie}") + raise OSCParseError(f"Could not parse datagram {ie}") except TypeError as te: - raise ParseError(f"Could not parse datagram {te}") + raise OSCParseError(f"Could not parse datagram {te}") def get_int(dgram: bytes, start_index: int) -> tuple[int, int]: @@ -97,17 +97,17 @@ def get_int(dgram: bytes, start_index: int) -> tuple[int, int]: A tuple containing the integer and the new end index. Raises: - ParseError if the datagram could not be parsed. + OSCParseError if the datagram could not be parsed. """ try: if len(dgram[start_index:]) < _INT_DGRAM_LEN: - raise ParseError("Datagram is too short") + raise OSCParseError("Datagram is too short") return ( struct.unpack(">i", dgram[start_index : start_index + _INT_DGRAM_LEN])[0], start_index + _INT_DGRAM_LEN, ) except (struct.error, TypeError) as e: - raise ParseError(f"Could not parse datagram {e}") + raise OSCParseError(f"Could not parse datagram {e}") def get_uint64(dgram: bytes, start_index: int) -> tuple[int, int]: @@ -121,11 +121,11 @@ def get_uint64(dgram: bytes, start_index: int) -> tuple[int, int]: A tuple containing the integer and the new end index. Raises: - ParseError if the datagram could not be parsed. + OSCParseError if the datagram could not be parsed. """ try: if len(dgram[start_index:]) < _UINT64_DGRAM_LEN: - raise ParseError("Datagram is too short") + raise OSCParseError("Datagram is too short") return ( struct.unpack(">Q", dgram[start_index : start_index + _UINT64_DGRAM_LEN])[ 0 @@ -133,7 +133,7 @@ def get_uint64(dgram: bytes, start_index: int) -> tuple[int, int]: start_index + _UINT64_DGRAM_LEN, ) except (struct.error, TypeError) as e: - raise ParseError(f"Could not parse datagram {e}") + raise OSCParseError(f"Could not parse datagram {e}") def get_timetag(dgram: bytes, start_index: int) -> tuple[datetime, int]: @@ -148,11 +148,11 @@ def get_timetag(dgram: bytes, start_index: int) -> tuple[datetime, int]: fraction of the current second and the new end index. Raises: - ParseError if the datagram could not be parsed. + OSCParseError if the datagram could not be parsed. """ try: if len(dgram[start_index:]) < _TIMETAG_DGRAM_LEN: - raise ParseError("Datagram is too short") + raise OSCParseError("Datagram is too short") timetag, _ = get_uint64(dgram, start_index) seconds, fraction = ntp.parse_timestamp(timetag) @@ -166,7 +166,7 @@ def get_timetag(dgram: bytes, start_index: int) -> tuple[datetime, int]: return (utc, fraction), start_index + _TIMETAG_DGRAM_LEN except (struct.error, TypeError) as e: - raise ParseError(f"Could not parse datagram {e}") + raise OSCParseError(f"Could not parse datagram {e}") from e def get_float(dgram: bytes, start_index: int) -> tuple[float, int]: @@ -180,7 +180,7 @@ def get_float(dgram: bytes, start_index: int) -> tuple[float, int]: A tuple containing the float and the new end index. Raises: - ParseError if the datagram could not be parsed. + OSCParseError if the datagram could not be parsed. """ try: if len(dgram[start_index:]) < _FLOAT_DGRAM_LEN: @@ -193,7 +193,7 @@ def get_float(dgram: bytes, start_index: int) -> tuple[float, int]: start_index + _FLOAT_DGRAM_LEN, ) except (struct.error, TypeError) as e: - raise ParseError(f"Could not parse datagram {e}") + raise OSCParseError(f"Could not parse datagram {e}") from e def get_double(dgram: bytes, start_index: int) -> tuple[float, int]: @@ -207,11 +207,11 @@ def get_double(dgram: bytes, start_index: int) -> tuple[float, int]: A tuple containing the double and the new end index. Raises: - ParseError if the datagram could not be parsed. + OSCParseError if the datagram could not be parsed. """ try: if len(dgram[start_index:]) < _DOUBLE_DGRAM_LEN: - raise ParseError("Datagram is too short") + raise OSCParseError("Datagram is too short") return ( struct.unpack(">d", dgram[start_index : start_index + _DOUBLE_DGRAM_LEN])[ 0 @@ -219,7 +219,7 @@ def get_double(dgram: bytes, start_index: int) -> tuple[float, int]: start_index + _DOUBLE_DGRAM_LEN, ) except (struct.error, TypeError) as e: - raise ParseError(f"Could not parse datagram {e}") + raise OSCParseError(f"Could not parse datagram {e}") from e def get_blob(dgram: bytes, start_index: int) -> tuple[bytes, int]: @@ -238,14 +238,14 @@ def get_blob(dgram: bytes, start_index: int) -> tuple[bytes, int]: A tuple containing the blob and the new end index. Raises: - ParseError if the datagram could not be parsed. + OSCParseError if the datagram could not be parsed. """ size, int_offset = get_int(dgram, start_index) # Make the size a multiple of 32 bits. total_size = size + (-size % _BLOB_DGRAM_PAD) end_index = int_offset + size if end_index - start_index > len(dgram[start_index:]): - raise ParseError("Datagram is too short.") + raise OSCParseError("Datagram is too short.") return dgram[int_offset : int_offset + size], int_offset + total_size @@ -266,13 +266,13 @@ def get_date(dgram: bytes, start_index: int) -> tuple[float, int]: returns osc_immediately (0) if the corresponding OSC sequence was found. Raises: - ParseError if the datagram could not be parsed. + OSCParseError if the datagram could not be parsed. """ # Check for the special case first. if dgram[start_index : start_index + _TIMETAG_DGRAM_LEN] == ntp.IMMEDIATELY: return IMMEDIATELY, start_index + _TIMETAG_DGRAM_LEN if len(dgram[start_index:]) < _TIMETAG_DGRAM_LEN: - raise ParseError("Datagram is too short") + raise OSCParseError("Datagram is too short") timetag, start_index = get_uint64(dgram, start_index) seconds = timetag * ntp._NTP_TIMESTAMP_TO_SECONDS return ntp.ntp_time_to_system_epoch(seconds), start_index @@ -289,17 +289,17 @@ def get_rgba(dgram: bytes, start_index: int) -> tuple[bytes, int]: A tuple containing the integer and the new end index. Raises: - ParseError if the datagram could not be parsed. + OSCParseError if the datagram could not be parsed. """ try: if len(dgram[start_index:]) < _INT_DGRAM_LEN: - raise ParseError("Datagram is too short") + raise OSCParseError("Datagram is too short") return ( struct.unpack(">I", dgram[start_index : start_index + _INT_DGRAM_LEN])[0], start_index + _INT_DGRAM_LEN, ) except (struct.error, TypeError) as e: - raise ParseError(f"Could not parse datagram {e}") + raise OSCParseError(f"Could not parse datagram {e}") from e def get_midi(dgram: bytes, start_index: int) -> tuple[tuple[int, int, int, int], int]: @@ -313,13 +313,13 @@ def get_midi(dgram: bytes, start_index: int) -> tuple[tuple[int, int, int, int], A tuple containing the MIDI message and the new end index. Raises: - ParseError if the datagram could not be parsed. + OSCParseError if the datagram could not be parsed. """ try: if len(dgram[start_index:]) < _INT_DGRAM_LEN: - raise ParseError("Datagram is too short") + raise OSCParseError("Datagram is too short") val = struct.unpack(">I", dgram[start_index : start_index + _INT_DGRAM_LEN])[0] midi_msg = tuple((val & 0xFF << 8 * i) >> 8 * i for i in range(3, -1, -1)) return (midi_msg, start_index + _INT_DGRAM_LEN) except (struct.error, TypeError) as e: - raise ParseError(f"Could not parse datagram {e}") + raise OSCParseError(f"Could not parse datagram {e}") from e diff --git a/promexp/providers/casparcg/packet.py b/promexp/providers/casparcg/packet.py index 27d8843..1ec95c5 100644 --- a/promexp/providers/casparcg/packet.py +++ b/promexp/providers/casparcg/packet.py @@ -8,7 +8,7 @@ from .bundle import OSCBundle from .message import OSCMessage -from .osc_types import * +from .osc_types import IMMEDIATELY, OSCParseError # A namedtuple as returned my the _timed_msg_of_bundle function. # 1) the system time at which the message should be executed @@ -50,19 +50,16 @@ def __init__(self, dgram: bytes) -> None: - ParseError if the datagram could not be parsed. """ now = time.time() - try: - if OSCBundle.dgram_is_bundle(dgram): - self._messages = sorted( - _timed_msg_of_bundle(OSCBundle(dgram), now), key=lambda x: x.time - ) - elif OSCMessage.dgram_is_message(dgram): - self._messages = [TimedMessage(now, OscMessage(dgram))] - else: - raise OSCParseError( - "OSC Packet should at least contain an OscMessage or an OscBundle." - ) - except OSCParseError as pe: - raise ParseError(f"Could not parse packet {pe}") + if OSCBundle.dgram_is_bundle(dgram): + self._messages = sorted( + _timed_msg_of_bundle(OSCBundle(dgram), now), key=lambda x: x.time + ) + elif OSCMessage.dgram_is_message(dgram): + self._messages = [TimedMessage(now, OSCMessage(dgram))] + else: + raise OSCParseError( + "OSC Packet should at least contain an OscMessage or an OscBundle." + ) @property def messages(self) -> list[TimedMessage]: diff --git a/promexp/providers/storage.py b/promexp/providers/storage.py index a6fb937..426be5c 100644 --- a/promexp/providers/storage.py +++ b/promexp/providers/storage.py @@ -31,9 +31,9 @@ def __init__(self, parent, settings): if mountpoint_whitelist and not any( storage.mountpoint == b - if b == "/" - else storage.mountpoint.lower().startswith(b.lower()) - for b in mountpoint_whitelist + if b == "/" + else storage.mountpoint.lower().startswith(b.lower()) + for b in mountpoint_whitelist ): continue From 11169db623c01e61d38a22c8ab45aa009e5f1243 Mon Sep 17 00:00:00 2001 From: Martastain Date: Sat, 18 Jul 2026 00:25:52 +0200 Subject: [PATCH 12/17] fix: remove storagespaces, more clean-up --- promexp/__main__.py | 38 +++++----- promexp/metrics.py | 11 +-- promexp/provider.py | 2 +- promexp/providers/__init__.py | 4 +- promexp/providers/casparcg/__init__.py | 2 + promexp/providers/casparcg/osc_types.py | 8 +-- promexp/providers/storagespaces.py | 96 ------------------------- pyproject.toml | 16 +++-- 8 files changed, 44 insertions(+), 133 deletions(-) delete mode 100644 promexp/providers/storagespaces.py diff --git a/promexp/__main__.py b/promexp/__main__.py index 5e8474e..7377b57 100755 --- a/promexp/__main__.py +++ b/promexp/__main__.py @@ -82,27 +82,27 @@ def merge_settings(base, override): class MetricsHandler(http.server.BaseHTTPRequestHandler): promexp: Promexp | None = None - def log_message(self, format, *args): - logging.debug(f"{self.address_string()} - - {format % args}") - def do_GET(self): if self.path == "/metrics": - try: - if self.promexp is None: - raise RuntimeError("Promexp instance not set on MetricsHandler") - content = self.promexp.render() - encoded = content.encode("utf-8") - self.send_response(200) - self.send_header("Content-Type", "text/plain; charset=utf-8") - self.send_header("Content-Length", str(len(encoded))) - self.end_headers() - self.wfile.write(encoded) - except Exception: - logging.error("Error rendering metrics") - self.send_response(500) - self.send_header("Content-Type", "text/plain; charset=utf-8") - self.end_headers() - self.wfile.write(b"Internal Server Error") + if self.promexp: + try: + content = self.promexp.render() + encoded = content.encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "text/plain; charset=utf-8") + self.send_header("Content-Length", str(len(encoded))) + self.end_headers() + self.wfile.write(encoded) + except Exception: + pass + else: + return + + logging.error("Error rendering metrics") + self.send_response(500) + self.send_header("Content-Type", "text/plain; charset=utf-8") + self.end_headers() + self.wfile.write(b"Internal Server Error") else: self.send_response(400) self.send_header("Content-Type", "text/plain; charset=utf-8") diff --git a/promexp/metrics.py b/promexp/metrics.py index c577784..58dc8f4 100644 --- a/promexp/metrics.py +++ b/promexp/metrics.py @@ -1,13 +1,16 @@ import collections.abc -class frozendict(collections.abc.Mapping): +class FrozenDict(collections.abc.Mapping): dict_cls = dict def __init__(self, *args, **kwargs): self._dict = self.dict_cls(*args, **kwargs) self._hash = None + def dict(self): + return self._dict + def __getitem__(self, key): return self._dict[key] @@ -43,13 +46,13 @@ def add(self, metric_name: str, value: float, **tags) -> bool: """Add a metric to the pool""" if type(value) not in [int, float]: return False - key = (metric_name, frozendict(**tags)) + key = (metric_name, FrozenDict(**tags)) self.data[key] = value return True def dump(self): """Returns json-serializable list of the stored metrics pool""" - return [[key[0], key[1]._dict, value] for key, value in self.data.items()] + return [[key[0], key[1].dict(), value] for key, value in self.data.items()] def load(self, data): """Loads data from a list created previously using dump method""" @@ -64,7 +67,7 @@ def render(self, prefix: str = "", **kwargs): for key, value in self.data.items(): name, tags = key tstring = ", ".join( - f'{k}="{v}"' for k, v in {**tags._dict, **kwargs}.items() + f'{k}="{v}"' for k, v in {**tags.dict(), **kwargs}.items() ) result += f"{prefix}{name}{{{tstring}}} {value}\n" return result diff --git a/promexp/provider.py b/promexp/provider.py index 4bd2143..4c3b78e 100644 --- a/promexp/provider.py +++ b/promexp/provider.py @@ -5,7 +5,7 @@ def __init__(self, parent, settings): self.parent = parent self.logger = parent.logger self.enabled = settings is not None - if type(settings) == dict: + if isinstance(settings, dict): self.settings = settings else: self.logger.warning( diff --git a/promexp/providers/__init__.py b/promexp/providers/__init__.py index b13542d..8f6f056 100644 --- a/promexp/providers/__init__.py +++ b/promexp/providers/__init__.py @@ -1,15 +1,15 @@ +__all__ = ["registry"] + from .casparcg import CasparCGProvider from .network import NetworkProvider from .nvidia import NVIDIAProvider from .psutil import PSUtilProvider from .storage import StorageProvider -from .storagespaces import StorageSpacesProvider registry = [ PSUtilProvider, NVIDIAProvider, StorageProvider, NetworkProvider, - StorageSpacesProvider, CasparCGProvider, ] diff --git a/promexp/providers/casparcg/__init__.py b/promexp/providers/casparcg/__init__.py index ff2efbe..1c88d3d 100644 --- a/promexp/providers/casparcg/__init__.py +++ b/promexp/providers/casparcg/__init__.py @@ -216,6 +216,8 @@ def handle_osc(self, address, *args): class CasparCGHeartbeat(threading.Thread): + parent: "CasparCGProvider" + def run(self): while 1: response = self.parent.query("VERSION") diff --git a/promexp/providers/casparcg/osc_types.py b/promexp/providers/casparcg/osc_types.py index bbb8174..6f6d97a 100644 --- a/promexp/providers/casparcg/osc_types.py +++ b/promexp/providers/casparcg/osc_types.py @@ -81,9 +81,9 @@ def get_string(dgram: bytes, start_index: int) -> tuple[str, int]: data_str = dgram[start_index : start_index + offset] return data_str.replace(b"\x00", b"").decode("utf-8"), start_index + offset except IndexError as ie: - raise OSCParseError(f"Could not parse datagram {ie}") + raise OSCParseError(f"Could not parse datagram {ie}") from ie except TypeError as te: - raise OSCParseError(f"Could not parse datagram {te}") + raise OSCParseError(f"Could not parse datagram {te}") from te def get_int(dgram: bytes, start_index: int) -> tuple[int, int]: @@ -107,7 +107,7 @@ def get_int(dgram: bytes, start_index: int) -> tuple[int, int]: start_index + _INT_DGRAM_LEN, ) except (struct.error, TypeError) as e: - raise OSCParseError(f"Could not parse datagram {e}") + raise OSCParseError(f"Could not parse datagram {e}") from e def get_uint64(dgram: bytes, start_index: int) -> tuple[int, int]: @@ -133,7 +133,7 @@ def get_uint64(dgram: bytes, start_index: int) -> tuple[int, int]: start_index + _UINT64_DGRAM_LEN, ) except (struct.error, TypeError) as e: - raise OSCParseError(f"Could not parse datagram {e}") + raise OSCParseError(f"Could not parse datagram {e}") from e def get_timetag(dgram: bytes, start_index: int) -> tuple[datetime, int]: diff --git a/promexp/providers/storagespaces.py b/promexp/providers/storagespaces.py deleted file mode 100644 index 0cc9915..0000000 --- a/promexp/providers/storagespaces.py +++ /dev/null @@ -1,96 +0,0 @@ -__all__ = ["StorageSpacesProvider"] - -import os -import subprocess -import threading -import time - -from promexp.provider import BaseProvider - - -def get_ss_status(): - c = subprocess.Popen( - [ - "powershell.exe", - "Get-VirtualDisk | Format-Table FriendlyName,ResiliencySettingName,OperationalStatus, HealthStatus", - ], - stderr=subprocess.PIPE, - stdout=subprocess.PIPE, - ) - stdout, _ = c.communicate() - - bounds = False - result = [] - for line in stdout.decode("utf-8").split("\n"): - if line.startswith("---"): - bounds = [0] - while True: - pos = line.find(" -", bounds[-1]) - if pos == -1: - break - bounds.append(pos + 1) - continue - - if not bounds: - continue - - line = line.strip() - if not line: - continue - - title = line[bounds[0] : bounds[1]].strip() - mode = line[bounds[1] : bounds[2]].strip() - status = line[bounds[2] : bounds[3]].strip() - health = line[bounds[3] :].strip() - - result.append( - {"title": title, "mode": mode, "status": status, "health": health} - ) - return result - return None - - -class SSWorker(threading.Thread): - def run(self): - while 1: - try: - self.result = get_ss_status() - except Exception: - self.logger.exception("Unable to get storage spaces status") - - time.sleep(30) - - -class StorageSpacesProvider(BaseProvider): - name = "storagespaces" - - def __init__(self, parent, settings): - super().__init__(parent, settings) - - if os.name != "nt": - self.disable() - return - - try: - status = get_ss_status() - except Exception: - status = [] - - if not status: - self.disable() - - self.worker = SSWorker() - self.worker.logger = self.logger - self.worker.start() - - def collect(self): - try: - status = self.worker.result - except Exception: - status = [] - - for sspace in status: - tags = {"name": sspace["title"], "mode": sspace["mode"]} - self.add( - "storage_space_healthy", int(sspace["health"] == "Healthy"), **tags - ) diff --git a/pyproject.toml b/pyproject.toml index be8df11..bf6e8a1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,26 +33,28 @@ line-ending = "auto" select = ["ALL"] ignore = [ - "BLE", # FIXME: blind exceptions. I don't like them either, but they are useful in some cases + "BLE", # blind exceptions. I don't like them either, but they are useful in some cases "COM812", # OK: messes with formatter - "D", # FIXME: docstrings. Naaah. + "D", # docstrings. Naaah. "EM", # OK: String literals in exception messages (why not?) "FBT", # Boolean positional argument (WTF?) - "FIX002", # FIXME: TODOs + "FIX002", # TODOs "ISC001", # OK: Incompatible with formatting "PLR2004", # OK: Magic numbers (just a few) - "PTH", # FIXME: path-like objects + "PTH", # path-like objects "S101", # OK: I like asserts. They make mypy happy "S104", # OK: bind to all interfaces (We need this) "S110", # OK: except: pass - "S608", # FIXME: sql injections. let's wait for template strings + "S608", # sql injections. let's wait for template strings "SIM105", # OK: contextlib.suppress is ugly "TD002", # Author name in TODOs "TD003", # Issue link in TODOs "TRY003", # OK: Avoid specicying long messages outside of exceptions - "ANN", # FIXME: Any + "ANN", # Any "ERA", # Commented out code - "RUF012", # FIXME ClassVar + "RUF012", # ClassVar + "G004", # Logging messages using fstring + "S108", ] [tool.mypy] From 8df9a66a926454ad635e5fa04eba71f436d6708f Mon Sep 17 00:00:00 2001 From: Martastain Date: Sat, 18 Jul 2026 00:54:38 +0200 Subject: [PATCH 13/17] refactor: promexp.py file, logger --- promexp/__init__.py | 52 --------------------------- promexp/__main__.py | 85 ++++++++++++++------------------------------- promexp/promexp.py | 52 +++++++++++++++++++++++++++ promexp/provider.py | 8 ++--- 4 files changed, 83 insertions(+), 114 deletions(-) create mode 100644 promexp/promexp.py diff --git a/promexp/__init__.py b/promexp/__init__.py index 776756d..e69de29 100644 --- a/promexp/__init__.py +++ b/promexp/__init__.py @@ -1,52 +0,0 @@ -import logging - -from .metrics import Metrics -from .providers import registry - - -class Promexp: - def __init__(self, prefix="", tags=None, provider_settings=None, logger=None): - if provider_settings is None: - provider_settings = {} - if tags is None: - tags = {} - self.prefix = prefix - self.tags = tags - self.providers = {} - self._logger = logger - self.metrics = Metrics() - for pclass in registry: - self.add_provider(pclass, provider_settings.get(pclass.name, {})) - - def add_provider(self, pclass, psettings=None): - if psettings is None: - psettings = {} - if psettings is None: - return False - - if pclass.name in self.providers: - self.logger.warning( - f"Duplicate provider name {pclass.name}. Skipping initialization." - ) - return False - - self.providers[pclass.name] = pclass(self, psettings) - - if self.providers[pclass.name].enabled: - self.logger.info(f"Enabling {pclass.name} provider") - return None - - @property - def logger(self): - if self._logger is None: - self._logger = logging.getLogger("promexp") - return self._logger - - def collect(self): - for provider in self.providers.values(): - if provider.enabled: - provider.collect() - - def render(self): - self.collect() - return self.metrics.render(prefix=self.prefix, **self.tags) diff --git a/promexp/__main__.py b/promexp/__main__.py index 7377b57..578ccde 100755 --- a/promexp/__main__.py +++ b/promexp/__main__.py @@ -6,8 +6,8 @@ import typer -from promexp import Promexp -from promexp.logger import logger as logging +from promexp.logger import logger +from promexp.promexp import Promexp def str_to_bool(value: Any) -> bool: @@ -55,34 +55,13 @@ def parse_tags_option(val): return parsed -def merge_settings(base, override): - if not override: - return - for k, v in override.items(): - if k == "provider_settings" and isinstance(v, dict): - if "provider_settings" not in base: - base["provider_settings"] = {} - for pk, pv in v.items(): - if pk not in base["provider_settings"]: - base["provider_settings"][pk] = {} - if isinstance(pv, dict) and isinstance( - base["provider_settings"][pk], dict - ): - base["provider_settings"][pk].update(pv) - else: - base["provider_settings"][pk] = pv - elif k == "tags" and isinstance(v, dict): - if "tags" not in base: - base["tags"] = {} - base["tags"].update(v) - else: - base[k] = v - - class MetricsHandler(http.server.BaseHTTPRequestHandler): promexp: Promexp | None = None - def do_GET(self): + def log_message(self, msg: str, *args) -> None: + logger.debug(f"{msg % args} {self.client_address[0]}\n") + + def do_GET(self) -> None: if self.path == "/metrics": if self.promexp: try: @@ -98,7 +77,7 @@ def do_GET(self): else: return - logging.error("Error rendering metrics") + logger.error("Error rendering metrics") self.send_response(500) self.send_header("Content-Type", "text/plain; charset=utf-8") self.end_headers() @@ -111,17 +90,17 @@ def do_GET(self): class Server: - def __init__(self, app_name="promexp", logger=None): - self.logger = logger or logging + def __init__(self) -> None: + pass - def serve(self, host: str, port: int): - self.logger.info(f"Starting HTTP server at {host or '*'}:{port}") + def serve(self, host: str, port: int) -> None: + logger.info(f"Starting HTTP server at {host or '*'}:{port}") server_address = (host, port) httpd = http.server.HTTPServer(server_address, MetricsHandler) try: httpd.serve_forever() except KeyboardInterrupt: - self.logger.info("Keyboard interrupt. Shutting down...") + logger.info("Keyboard interrupt. Shutting down...") httpd.server_close() @@ -223,31 +202,13 @@ def main( envvar="PROMEXP_NETWORK_IGNORE_INACTIVE", ), ] = True, -): - settings_dict = { - "host": "", - "port": 9731, - "hostname": True, - "tags": {}, - "prefix": "nebula", - "provider_settings": { - "casparcg": { - "host": "127.0.0.1", - "port": 5250, - "osc_port": 6250, - "heartbeat_interval": 10, - "force": False, - } - }, - } +) -> None: override = {} if listen_address is not None: override["host"] = listen_address if listen_port is not None: override["port"] = listen_port - if prefix is not None: - override["prefix"] = prefix if hostname is not None: override["hostname"] = str_to_bool(hostname) @@ -262,6 +223,8 @@ def get_prov(name): prov_settings[name] = {} return prov_settings[name] + # CasparCG specific + if caspar_host: caspar_settings = {} caspar_settings["host"] = caspar_host @@ -271,6 +234,7 @@ def get_prov(name): prov_settings["casparcg"] = caspar_settings # NVIDIA specific + if prov_settings.get("nvidia") is not None: nvidia = get_prov("nvidia") if nvidia_smi_path is not None: @@ -290,29 +254,34 @@ def get_prov(name): # if network_ignore_inactive is not None: # network["ignore_inactive"] = network_ignore_inactive - if prov_settings: - override["provider_settings"] = prov_settings + # + # Metric tags + # tags = {} - # Apply hostname configuration + # Apply hostname to tags + if hostname is None: tags["hostname"] = socket.gethostname() elif hostname: tags["hostname"] = str(hostname) + # # Initialize promexp + # + promexp = Promexp( - prefix=settings_dict["prefix"], + prefix=prefix, tags=tags, provider_settings=prov_settings, - logger=logging, ) # Set promexp on MetricsHandler class + MetricsHandler.promexp = promexp - server = Server("promexp", logger=logging) + server = Server() server.serve(listen_address, listen_port) diff --git a/promexp/promexp.py b/promexp/promexp.py new file mode 100644 index 0000000..7ba184e --- /dev/null +++ b/promexp/promexp.py @@ -0,0 +1,52 @@ +from typing import Any + +from .logger import logger +from .metrics import Metrics +from .providers import registry + + +class Promexp: + def __init__( + self, + prefix: str = "", + tags: dict[str, Any] | None = None, + provider_settings: dict[str, Any] | None = None, + ): + if provider_settings is None: + provider_settings = {} + if tags is None: + tags = {} + + self.prefix = prefix + self.tags = tags + self.providers = {} + self.metrics = Metrics() + for pclass in registry: + self.add_provider(pclass, provider_settings.get(pclass.name, {})) + + def add_provider(self, pclass, psettings=None): + if psettings is None: + psettings = {} + if psettings is None: + return False + + if pclass.name in self.providers: + logger.warning( + f"Duplicate provider name {pclass.name}. Skipping initialization." + ) + return False + + self.providers[pclass.name] = pclass(self, psettings) + + if self.providers[pclass.name].enabled: + logger.info(f"Enabling {pclass.name} provider") + return None + + def collect(self): + for provider in self.providers.values(): + if provider.enabled: + provider.collect() + + def render(self): + self.collect() + return self.metrics.render(prefix=self.prefix, **self.tags) diff --git a/promexp/provider.py b/promexp/provider.py index 4c3b78e..019dba3 100644 --- a/promexp/provider.py +++ b/promexp/provider.py @@ -1,16 +1,16 @@ +from promexp.logger import logger + + class BaseProvider: name = "base" def __init__(self, parent, settings): self.parent = parent - self.logger = parent.logger self.enabled = settings is not None if isinstance(settings, dict): self.settings = settings else: - self.logger.warning( - f"Incorrect settings for {self.name} provider. Ignoring" - ) + logger.warning(f"Incorrect settings for {self.name} provider. Ignoring") self.settings = {} def __getitem__(self, key): From f7741b0e986b29cf9a3f9f3db8fb330093ba0935 Mon Sep 17 00:00:00 2001 From: Martastain Date: Sat, 18 Jul 2026 01:06:31 +0200 Subject: [PATCH 14/17] fix: provider constructor signature --- promexp/provider.py | 17 ++++++++++------- promexp/providers/network.py | 11 ++++++++++- promexp/providers/nvidia.py | 10 +++++++++- promexp/providers/psutil.py | 10 +++++++++- promexp/providers/storage.py | 24 ++++++++++++++++++++---- 5 files changed, 58 insertions(+), 14 deletions(-) diff --git a/promexp/provider.py b/promexp/provider.py index 019dba3..fe74bda 100644 --- a/promexp/provider.py +++ b/promexp/provider.py @@ -1,17 +1,20 @@ -from promexp.logger import logger +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from promexp.promexp import Promexp class BaseProvider: name = "base" - def __init__(self, parent, settings): + def __init__( + self, + parent: "Promexp", + settings: dict[str, Any] | None = None, + ) -> None: self.parent = parent self.enabled = settings is not None - if isinstance(settings, dict): - self.settings = settings - else: - logger.warning(f"Incorrect settings for {self.name} provider. Ignoring") - self.settings = {} + self.settings = settings or {} def __getitem__(self, key): return self.settings.get(key, None) diff --git a/promexp/providers/network.py b/promexp/providers/network.py index 0e08fd9..c523811 100644 --- a/promexp/providers/network.py +++ b/promexp/providers/network.py @@ -1,14 +1,23 @@ __all__ = ["NetworkProvider"] +from typing import TYPE_CHECKING, Any + import psutil from promexp.provider import BaseProvider +if TYPE_CHECKING: + from promexp.promexp import Promexp + class NetworkProvider(BaseProvider): name = "network" - def __init__(self, parent, settings): + def __init__( + self, + parent: "Promexp", + settings: dict[str, Any] | None = None, + ) -> None: super().__init__(parent, settings) self.interface_blacklist = ["lo"] diff --git a/promexp/providers/nvidia.py b/promexp/providers/nvidia.py index 2f50a5a..22c2a4e 100644 --- a/promexp/providers/nvidia.py +++ b/promexp/providers/nvidia.py @@ -2,10 +2,14 @@ import os import subprocess +from typing import TYPE_CHECKING, Any from xml.etree import ElementTree as ET from promexp.provider import BaseProvider +if TYPE_CHECKING: + from promexp.promexp import Promexp + def parse_number(string): try: @@ -17,7 +21,11 @@ def parse_number(string): class NVIDIAProvider(BaseProvider): name = "nvidia" - def __init__(self, parent, settings): + def __init__( + self, + parent: "Promexp", + settings: dict[str, Any] | None = None, + ) -> None: super().__init__(parent, settings) smi_paths = [ diff --git a/promexp/providers/psutil.py b/promexp/providers/psutil.py index 162c7c7..962def1 100644 --- a/promexp/providers/psutil.py +++ b/promexp/providers/psutil.py @@ -1,16 +1,24 @@ __all__ = ["PSUtilProvider"] import time +from typing import TYPE_CHECKING, Any import psutil from promexp.provider import BaseProvider +if TYPE_CHECKING: + from promexp.promexp import Promexp + class PSUtilProvider(BaseProvider): name = "psutil" - def __init__(self, parent, settings): + def __init__( + self, + parent: "Promexp", + settings: dict[str, Any] | None = None, + ) -> None: super().__init__(parent, settings) self.boot_time = psutil.boot_time() self.run_time = time.time() diff --git a/promexp/providers/storage.py b/promexp/providers/storage.py index 426be5c..134eda7 100644 --- a/promexp/providers/storage.py +++ b/promexp/providers/storage.py @@ -1,14 +1,24 @@ __all__ = ["StorageProvider"] +from typing import TYPE_CHECKING, Any + import psutil +from promexp.logger import logger from promexp.provider import BaseProvider +if TYPE_CHECKING: + from promexp.promexp import Promexp + class StorageProvider(BaseProvider): name = "storage" - def __init__(self, parent, settings): + def __init__( + self, + parent: "Promexp", + settings: dict[str, Any] | None = None, + ) -> None: super().__init__(parent, settings) mountpoint_blacklist = [ "/run", @@ -18,6 +28,7 @@ def __init__(self, parent, settings): "/snap", "/var/lib", "/tmp/snap", + "/tmp", "/boot", ] mountpoint_whitelist = self.get("storages", []) @@ -45,24 +56,29 @@ def __init__(self, parent, settings): } ) - def collect(self): + def collect(self) -> None: for storage in self.storages: if storage.get("disabled"): continue try: usage = psutil.disk_usage(storage["mountpoint"]) except PermissionError: - self.logger.warning( + logger.warning( f"Disabling {storage['mountpoint']} check due to permission error" ) storage["disabled"] = True continue - except Exception: + except Exception as e: + logger.warning( + f"Disabling {storage['mountpoint']} check due to error: {e}" + ) continue + tags = { "mountpoint": storage["mountpoint"].replace("\\", "/"), "fstype": storage["fstype"], } + self.add("storage_bytes_total", usage.total, **tags) self.add("storage_bytes_free", usage.free, **tags) self.add("storage_usage", usage.percent, **tags) From 037ec96560e6f9285459c7afdb0bd0cf5bab737d Mon Sep 17 00:00:00 2001 From: Martastain Date: Sat, 18 Jul 2026 21:17:35 +0200 Subject: [PATCH 15/17] fix: storage and network settings --- promexp/__main__.py | 47 +++++++++++++++++++++++++++++---------------- 1 file changed, 30 insertions(+), 17 deletions(-) diff --git a/promexp/__main__.py b/promexp/__main__.py index 578ccde..744cd0a 100755 --- a/promexp/__main__.py +++ b/promexp/__main__.py @@ -181,17 +181,17 @@ def main( envvar="PROMEXP_NVIDIA_SMI_PATH", ), ] = None, - storage: Annotated[ - list[str] | None, + storages: Annotated[ + str | None, typer.Option( - help="Storage mountpoint whitelist", + help="Comma separated storage mountpoint whitelist", envvar="PROMEXP_STORAGES", ), ] = None, - network_interface: Annotated[ - list[str] | None, + network_interfaces: Annotated[ + str | None, typer.Option( - help="Network interfaces whitelist", + help="Comma separated network interfaces whitelist", envvar="PROMEXP_NETWORK_INTERFACES", ), ] = None, @@ -240,19 +240,32 @@ def get_prov(name): if nvidia_smi_path is not None: nvidia["smi_path"] = nvidia_smi_path + # Network specific + + network_settings = {} + if isinstance(network_interfaces, str) and network_interfaces: + ifaces = network_interfaces.split(",") + network_settings["interfaces"] = [ + iface.strip() for iface in ifaces if iface.strip() + ] + + if not network_ignore_inactive: + network_settings["ignore_inactive"] = network_ignore_inactive + else: + network_settings["ignore_inactive"] = True + + prov_settings["network"] = network_settings + + # Storage specific - # if prov_settings.get("storage") is not None: - # storage = get_prov("storage") - # if storages - # storage["storages"] = parse_list_option(storages) - # Network specific - # if prov_settings.get("network") is not None: - # network = get_prov("network") - # if network_interfaces: - # network["interfaces"] = parse_list_option(network_interfaces) - # if network_ignore_inactive is not None: - # network["ignore_inactive"] = network_ignore_inactive + storage_settings = {} + if isinstance(storages, str) and storages: + storage_list = storages.split(",") + storage_settings["storages"] = [ + storage.strip() for storage in storage_list if storage.strip() + ] + prov_settings["storage"] = storage_settings # # Metric tags From 2d2d85394ae48b6a499a92dc2f634970cc6a08c0 Mon Sep 17 00:00:00 2001 From: Martastain Date: Sat, 18 Jul 2026 21:24:34 +0200 Subject: [PATCH 16/17] feat: versioning --- promexp/__main__.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/promexp/__main__.py b/promexp/__main__.py index 744cd0a..5ef0bd1 100755 --- a/promexp/__main__.py +++ b/promexp/__main__.py @@ -2,12 +2,14 @@ import http.server import socket +import sys from typing import Annotated, Any import typer from promexp.logger import logger from promexp.promexp import Promexp +from promexp.version import __version__ def str_to_bool(value: Any) -> bool: @@ -112,6 +114,14 @@ def serve(self, host: str, port: int) -> None: @app.command() def main( + version: Annotated[ + bool, + typer.Option( + "--version", + "-v", + help="Show version and exit", + ), + ] = False, listen_address: Annotated[ str, typer.Option( @@ -204,6 +214,11 @@ def main( ] = True, ) -> None: + if version: + sys.stdout.write(__version__) + sys.stdout.flush() + sys.exit(0) + override = {} if listen_address is not None: override["host"] = listen_address @@ -256,7 +271,6 @@ def get_prov(name): prov_settings["network"] = network_settings - # Storage specific storage_settings = {} From 6419d3b61682b5f86c78a5a4a24514c309d7bc2b Mon Sep 17 00:00:00 2001 From: Martastain Date: Sat, 18 Jul 2026 21:27:47 +0200 Subject: [PATCH 17/17] feat: version sync --- Makefile | 9 ++++++--- promexp/version.py | 1 + pyproject.toml | 2 +- uv.lock | 2 +- 4 files changed, 9 insertions(+), 5 deletions(-) create mode 100644 promexp/version.py diff --git a/Makefile b/Makefile index 7694139..a3885d9 100644 --- a/Makefile +++ b/Makefile @@ -1,14 +1,15 @@ -IMAGE_NAME=nebulabroadcast/promexp:latest +VERSION=$(shell uv run python -m promexp --version) +IMAGE_NAME=nebulabroadcast/promexp check: - #uv version $(VERSION) + uv version $(VERSION) uv run ruff format . uv run ruff check --fix . uv run mypy . build: - docker build -t $(IMAGE_NAME) . + docker build -t $(IMAGE_NAME):dev run: build docker run -it --rm \ @@ -18,3 +19,5 @@ run: build push: build docker push $(IMAGE_NAME) + + diff --git a/promexp/version.py b/promexp/version.py new file mode 100644 index 0000000..e28bc3c --- /dev/null +++ b/promexp/version.py @@ -0,0 +1 @@ +__version__ = "2.0.0-beta.1" diff --git a/pyproject.toml b/pyproject.toml index bf6e8a1..7202f97 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "promexp" -version = "2.0.0" +version = "2.0.0b1" description = "Simple Prometheus metrics exporter for broadcasters" readme = "README.md" requires-python = ">=3.12" diff --git a/uv.lock b/uv.lock index 4a4ba4f..184dc3a 100644 --- a/uv.lock +++ b/uv.lock @@ -240,7 +240,7 @@ wheels = [ [[package]] name = "promexp" -version = "2.0.0" +version = "2.0.0b1" source = { virtual = "." } dependencies = [ { name = "psutil" },