From 700ea8d6a46a57c6fe0ae043a56b0652509ad38f Mon Sep 17 00:00:00 2001 From: Andreas Wrede Date: Wed, 4 Feb 2026 12:45:35 -0500 Subject: [PATCH] refactor --- .hb.yaml | 21 + .vscode/launch.json | 56 +- README.md | 181 ++- build/lib/hbd/__init__.py | 11 + build/lib/hbd/cli.py | 45 + build/lib/hbd/config.py | 54 + build/lib/hbd/dns.py | 91 ++ build/lib/hbd/http.py | 235 ++++ build/lib/hbd/notify.py | 163 +++ build/lib/hbd/proto.py | 81 ++ build/lib/hbd/server.py | 128 +++ build/lib/hbd/udp.py | 235 ++++ build/lib/hbd/utils.py | 36 + build/lib/hbd/ws.py | 125 +++ daemon/__init__.py | 49 - daemon/_metadata.py | 155 --- daemon/daemon.py | 940 ---------------- daemon/pidfile.py | 67 -- daemon/runner.py | 322 ------ dist/heartbeat-0.1.0-py3-none-any.whl | Bin 0 -> 51346 bytes dist/heartbeat-0.1.0.tar.gz | Bin 0 -> 21238 bytes hbd | 1342 ----------------------- hbd/__init__.py | 11 + hbd/cli.py | 45 + hbd/config.py | 54 + hbd/dns.py | 91 ++ hbd/http.py | 236 ++++ hbd/notify.py | 163 +++ hbd/proto.py | 81 ++ hbd/server.py | 128 +++ hbd/udp.py | 235 ++++ hbd/utils.py | 36 + hbd/ws.py | 125 +++ heartbeat.egg-info/PKG-INFO | 193 ++++ heartbeat.egg-info/SOURCES.txt | 23 + heartbeat.egg-info/dependency_links.txt | 1 + heartbeat.egg-info/entry_points.txt | 2 + heartbeat.egg-info/requires.txt | 10 + heartbeat.egg-info/top_level.txt | 1 + poetry.lock | 1298 ++++++++++++++++++++++ pyproject.toml | 39 + requirements-dev.txt | 9 + templates/foot.html | 5 + templates/head.html | 7 + templates/live.html | 229 ++++ templates/menu.html | 20 + tests/test_dns.py | 128 +++ tests/test_handle_datagram.py | 47 + tests/test_proto.py | 25 + tests/test_udp.py | 14 + tox.ini | 26 + 51 files changed, 4715 insertions(+), 2904 deletions(-) create mode 100644 .hb.yaml create mode 100644 build/lib/hbd/__init__.py create mode 100644 build/lib/hbd/cli.py create mode 100644 build/lib/hbd/config.py create mode 100644 build/lib/hbd/dns.py create mode 100644 build/lib/hbd/http.py create mode 100644 build/lib/hbd/notify.py create mode 100644 build/lib/hbd/proto.py create mode 100644 build/lib/hbd/server.py create mode 100644 build/lib/hbd/udp.py create mode 100644 build/lib/hbd/utils.py create mode 100644 build/lib/hbd/ws.py delete mode 100644 daemon/__init__.py delete mode 100644 daemon/_metadata.py delete mode 100644 daemon/daemon.py delete mode 100644 daemon/pidfile.py delete mode 100644 daemon/runner.py create mode 100644 dist/heartbeat-0.1.0-py3-none-any.whl create mode 100644 dist/heartbeat-0.1.0.tar.gz delete mode 100755 hbd create mode 100644 hbd/__init__.py create mode 100644 hbd/cli.py create mode 100644 hbd/config.py create mode 100644 hbd/dns.py create mode 100644 hbd/http.py create mode 100644 hbd/notify.py create mode 100644 hbd/proto.py create mode 100644 hbd/server.py create mode 100644 hbd/udp.py create mode 100644 hbd/utils.py create mode 100644 hbd/ws.py create mode 100644 heartbeat.egg-info/PKG-INFO create mode 100644 heartbeat.egg-info/SOURCES.txt create mode 100644 heartbeat.egg-info/dependency_links.txt create mode 100644 heartbeat.egg-info/entry_points.txt create mode 100644 heartbeat.egg-info/requires.txt create mode 100644 heartbeat.egg-info/top_level.txt create mode 100644 poetry.lock create mode 100644 pyproject.toml create mode 100644 requirements-dev.txt create mode 100644 templates/foot.html create mode 100644 templates/head.html create mode 100644 templates/live.html create mode 100644 templates/menu.html create mode 100644 tests/test_dns.py create mode 100644 tests/test_handle_datagram.py create mode 100644 tests/test_proto.py create mode 100644 tests/test_udp.py create mode 100644 tox.ini diff --git a/.hb.yaml b/.hb.yaml new file mode 100644 index 0000000..b755b10 --- /dev/null +++ b/.hb.yaml @@ -0,0 +1,21 @@ +#name: "w02" +hb_port: 50003 +hbd_host: '' +#logfile: "/home/andreas/public_html/messages/andreas" +logfile: "/Users/andreas/public_html/messages/andreas" +logfmt: "msg" +grace: 40 +interval: 10 +watchhosts: +# "localhost": +# "haschloss" : +# "cotgate": +# "wentworth": + "winter": + notify: +14168226179 + src: "signal" +dyndnshosts: {"haschloss", "wayback", "wertvoll", "weekend", "cotgate", "rvgate", "draper", "eris"} +drophosts: {"unknown", "wookie15", "wort"} +nsupdate_bin: "/usr/local/bin/nsupdate" +pushsrv: "pushover" +dyndomains: {"wrede.org"} diff --git a/.vscode/launch.json b/.vscode/launch.json index 6f1c6b5..223906c 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,17 +1,41 @@ { - // Use IntelliSense to learn about possible attributes. - // Hover to view descriptions of existing attributes. - // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 - "version": "0.2.0", - "configurations": [ - { - "name": "Python: Current File", - "type": "python", - "request": "launch", - "program": "hbd", - "console": "integratedTerminal", - "args": ["-f"] - - } - ] -} \ No newline at end of file + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Python: Run hbd (module)", + "type": "debugpy", + "request": "launch", + "module": "hbd.cli", + "args": ["-c", ".hb.yaml", "-f", "-v", "-x", "-x", "-x"], + "cwd": "${workspaceFolder}", + "env": { + "PYTHONPATH": "${workspaceFolder}" + }, + "console": "integratedTerminal", + "justMyCode": false, + "subProcess": true + }, + { + "name": "Python: Attach (localhost:5678)", + "type": "debugpy", + "request": "attach", + "connect": { "host": "localhost", "port": 5678 }, + "pathMappings": [ + { "localRoot": "${workspaceFolder}", "remoteRoot": "${workspaceFolder}" } + ] + }, + { + "name": "Python: Run hbd with debugpy (listen)", + "type": "debugpy", + "request": "launch", + "module": "debugpy", + "args": ["--listen", "5678", "--wait-for-client", "-m", "hbd.cli", "-c", ".hb.yaml", "-f", "-v"], + "env": { "PYTHONPATH": "${workspaceFolder}" }, + "console": "integratedTerminal", + "justMyCode": false + } + ] +} diff --git a/README.md b/README.md index 33d6862..3ba374c 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,174 @@ -To obtain a DNS verified certificate for the websockert server: +# Heartbeat Daemon (hbd) ✅ -certbot certonly -d w02.wrede.ca -d ws.wrede.ca --dns-rfc2136 --dns-rfc2136-credentials /usr/local/etc/letsencrypt/certbot_dns_rfc2136.ini --dns-rfc2136-propagation-seconds 10 +A lightweight daemon that listens for UDP heartbeat messages and acts on them: keeps host state, optionally updates DNS records via `nsupdate`, forwards messages to WebSocket clients, and sends notifications (email, Pushover, Mattermost, Signal). It is a refactor of a previously monolithic script into a modular Python package (`hbd`). -and the rfc2136.ini file looks like: +--- -# Target DNS server -dns_rfc2136_server = 192.168.196.248 -# Target DNS port -dns_rfc2136_port = 53 -# TSIG key name -dns_rfc2136_name = tsig-key -# TSIG key secret -dns_rfc2136_secret = 1KsWP8ZkZxBDKS0RQ2n3bkz1xpVPtz3Tk1y3r/dF+4knwGBzscse8iewaEr/6jUtxaL1taGME6eqSDtV2SD8NQ== -# TSIG key algorithm -dns_rfc2136_algorithm = HMAC-SHA512 +## 📌 Features + +- Receive and parse heartbeat datagrams (text or zlib-compressed) ✅ +- Maintain host state and detect up/down transitions ✅ +- Queue DNS updates via `nsupdate` and run them in a background thread ✅ +- WebSocket API for live updates (hosts & messages) ✅ +- Notification pipeline (email, Pushover, Mattermost, Signal) ✅ +- Modular codebase suitable for unit testing and CI ✅ + +--- + +## ⚙️ Quickstart + +Prerequisites: +- Python 3.10+ (project uses language features from recent Python) +- `nsupdate` (for DNS updates) if using dynamic DNS + +Install dependencies (recommended into a venv): + +```bash +python3 -m venv .venv +source .venv/bin/activate +python -m pip install --upgrade pip +python -m pip install -r requirements.txt +# for development/testing tools +python -m pip install -r requirements-dev.txt +``` + +Run the daemon (example): + +```bash +# run with default config lookup (~/.hb.yaml) +PYTHONPATH=. hbd -c .hb.yaml -f -v +``` + +You can also run it directly via the package entrypoint after installation: + +```bash +python -m hbd.cli -c /path/to/config.yaml +``` + +## 🐞 Debugging in VS Code + +This repository includes a ready-to-use `.vscode/launch.json` with configurations to run or attach the VS Code debugger to `hbd`. + +- Ensure the **Python** extension is installed and select the project `.venv` as the interpreter (bottom-left of VS Code). +- Use **F5** and pick one of these configurations from the Run view: + - **Python: Run hbd (module)** — runs `hbd.cli` as a module and sets `PYTHONPATH` to the workspace root (recommended). + - **Python: Run hbd with debugpy (listen)** — launches `debugpy` and `hbd` together; useful when you want the process to listen for a debugger. + - **Python: Attach (localhost:5678)** — attach the debugger to a running process started with `debugpy`. + +To start `hbd` manually and wait for the debugger to attach, run: + +```bash +PYTHONPATH=. python -m debugpy --listen 5678 --wait-for-client -m hbd.cli -c .hb.yaml -f -v +``` + +Set breakpoints in modules such as `hbd/udp.py`, `hbd/dns.py`, or `hbd/server.py`, and use the **Attach** configuration to connect. Use `justMyCode: false` if you need to step into third-party code. + + +--- + +## 🛠 Configuration + +`hbd` reads YAML configuration (optional). If `PyYAML` is not installed, built-in defaults are used. Example configuration keys (see `hbd/config.py`): + +- `hb_port`: UDP port to listen for heartbeats (default: 50003) +- `hbd_port`: internal control port (default: 50004) +- `hbd_host`: bind address for HTTP/WSS +- `pickfile`: path for persisted state +- `logfile`: path to log file +- `logfmt`: `text` or `msg` +- `pushsrv`: push service (`pushover`|`mattermost`|`all`) +- `interval` / `grace`: heartbeat timing configuration +- `dyndomains`: list of dyndomains to update via `nsupdate` +- `nsupdate_bin`: path to nsupdate binary + +Example `.hb.yaml` (minimal): + +```yaml +hbd_host: 0.0.0.0 +hbd_port: 50004 +dyndomains: + - example.com +nsupdate_bin: /usr/bin/nsupdate +pushsrv: pushover +``` + +> Tip: `config.DEFAULTS` in `hbd/config.py` contains the canonical defaults and accepted configuration keys. + +--- + +## 🔧 Architecture & Modules + +- `hbd.proto` — serialization/deserialization of heartbeat messages (supports compressed payloads) +- `hbd.udp` — UDP parsing and `handle_datagram` implementation (main state machine) +- `hbd.dns` — `create_nsupdate_payload`, `nsupdate`, and a background DNS thread (`start_dns_thread`) +- `hbd.notify` — email and push notification helpers +- `hbd.ws` — WebSocket server and thread-safe broadcast helpers +- `hbd.http` — HTTP handler factory for the status UI/API +- `hbd.utils` — small utility helpers (`shortname`, `dur`, `initlog`) +- `hbd.cli` — CLI entrypoint and argument parsing +- `hbd.server` — async orchestration to run UDP/HTTP/WSS components + +This modular layout makes the code easier to test and maintain. + +--- + +## 🧪 Testing & Dev + +Tests are implemented using `unittest` and additional tests rely on `pytest` if you prefer. To run tests locally without installing anything beyond the dev requirements: + +```bash +# with project root on PYTHONPATH +PYTHONPATH=. python -m unittest discover -v +# or with pytest if installed +pytest -q +``` + +Developer tooling included: +- `pyproject.toml` — project metadata and dependencies +- `requirements-dev.txt` — dev/test dependencies +- `tox.ini` — convenience wrappers for running tests, lint, and mypy + +To run linters and type checks locally: + +```bash +# after installing dev deps +tox -e lint +tox -e mypy +``` + +--- + +## 🚀 Running in production + +- Use your system service manager (systemd, launchd, etc.) to run `hbd` in the background. +- Ensure `nsupdate` and necessary credentials are available for dynamic DNS updates. +- Configure TLS for WSS if you enable secure websockets. + +> Note: The project contains a small example for obtaining DNS-verified certs (certbot with RFC2136) — see earlier commit history or ask me to re-add the example to this README if you want it documented here. + +--- + +## 🤝 Contributing + +Contributions welcome! Please: +1. Open an issue to discuss larger changes. +2. Create a topic branch and a clear PR. +3. Add tests for new features and run linters. +4. Keep changes focused and documented. + +--- + +## 📜 License + +This repository is licensed under the MIT license. See `LICENSE` for details. + +--- + +If you'd like, I can also: +- add a **GitHub Actions** workflow that runs tests and lint on push/PR 🔁 +- add a `CONTRIBUTING.md` template for PRs and code style 💬 + +Which one should I do next? ✨ diff --git a/build/lib/hbd/__init__.py b/build/lib/hbd/__init__.py new file mode 100644 index 0000000..5896eaa --- /dev/null +++ b/build/lib/hbd/__init__.py @@ -0,0 +1,11 @@ +"""hbd package - scaffolding for heartbeat daemon + +This package contains the refactored modules for the original monolithic +`hbd` script. The initial implementation contains small scaffolds so you can +start moving functionality into the package. +""" + +__all__ = ["main", "__version__"] +__version__ = "0.1" + +from .cli import main diff --git a/build/lib/hbd/cli.py b/build/lib/hbd/cli.py new file mode 100644 index 0000000..82dbfd5 --- /dev/null +++ b/build/lib/hbd/cli.py @@ -0,0 +1,45 @@ +"""Command line interface for hbd package.""" +import argparse + +from .config import load_config +from .server import run as run_server + +PUSHSRVS = ["all", "pushover", "mattermost"] + + +def build_parser(): + parser = argparse.ArgumentParser( + prog="hbd", + description="HeartBeatDaemon - Wait for heartbeat messages and act on them (or their absence)", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("-c", "--config", dest="configfile", help="Config file path (YAML)") + parser.add_argument("-f", "--foreground", action="store_true", help="Run in foreground") + parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output") + parser.add_argument("-p", "--pushsrv", dest="pushsrv", choices=PUSHSRVS, help="Push service to use") + parser.add_argument("-x", "--debug", action="count", default=0, help="Increase debug level") + return parser + + +def main(argv=None): + parser = build_parser() + args = parser.parse_args(argv) + + config = load_config(args.configfile) + + # Apply CLI overrides + if args.foreground: + config["foreground"] = True + if args.verbose: + config["verbose"] = True + if args.pushsrv: + config["pushsrv"] = args.pushsrv + if args.debug: + config.setdefault("debug", 0) + config["debug"] += args.debug + + run_server(config) + + +if __name__ == "__main__": + main() diff --git a/build/lib/hbd/config.py b/build/lib/hbd/config.py new file mode 100644 index 0000000..c7a53b5 --- /dev/null +++ b/build/lib/hbd/config.py @@ -0,0 +1,54 @@ +"""Configuration loader and defaults for hbd.""" +import os + +try: + import yaml +except Exception: + yaml = None + +DEFAULTS = { + "hb_port": 50003, + "hbd_port": 50004, + "hbd_host": "", + "pickfile": "/tmp/hb.pick", + "logfile": "/var/log/heartbeat.log", + "logfmt": "text", + "pushsrv": "pushover", + "interval": 20, + "grace": 2, + "dyndomains": ["wrede.org"], + "watchhosts": [], + "dyndnshosts": [], + "drophosts": [], + "nsupdate_bin": "/usr/bin/nsupdate", + "foreground": False, + "verbose": False, + "debug": 0, +} + + +def load_config(path=None): + """Load configuration from a YAML file and merge with defaults. + + If YAML is not available or the file does not exist, defaults are returned. + """ + cfg = DEFAULTS.copy() + if not path: + # default path (~/.hb.yaml) + path = os.path.join(os.path.expanduser("~"), ".hb.yaml") + + if os.path.exists(path): + if yaml: + with open(path) as fh: + data = yaml.safe_load(fh) or {} + # only keep known keys + for k, v in data.items(): + if k in cfg: + cfg[k] = v + else: + # ignore unknown keys for now + pass + else: + # yaml not installed: do not attempt to parse; user must ensure defaults + pass + return cfg diff --git a/build/lib/hbd/dns.py b/build/lib/hbd/dns.py new file mode 100644 index 0000000..7088c85 --- /dev/null +++ b/build/lib/hbd/dns.py @@ -0,0 +1,91 @@ +"""DNS update helper and thread for heartbeat daemon.""" +from __future__ import annotations +import threading +import subprocess +from subprocess import Popen, PIPE, STDOUT +from typing import Optional + + +def create_nsupdate_payload(hostname: str, newip: str, dyndomain: str, dnsttl: str = "5") -> str: + D = {"domain": dyndomain, "fqdn": f"{hostname}.dy.{dyndomain}", "dnsttl": dnsttl, "newip": newip, "ts": __import__("time").strftime("%Y-%m-%d.%H:%M:%S", __import__("time").gmtime())} + if ":" in newip: + nsup = ( + """update delete %(fqdn)s AAAA +update add %(fqdn)s %(dnsttl)s AAAA %(newip)s +update delete %(fqdn)s TXT +update add %(fqdn)s %(dnsttl)s TXT "Created: %(ts)s" +send +answer + +""" % D + ) + else: + nsup = ( + """update delete %(fqdn)s A +update add %(fqdn)s %(dnsttl)s A %(newip)s +update delete %(fqdn)s TXT +update add %(fqdn)s %(dnsttl)s TXT "Created: %(ts)s" +send +answer + +""" % D + ) + return nsup + + +def nsupdate(hostname: str, newip: str, dyndomain: str, nsupdate_bin: str = "/usr/local/bin/nsupdate", rndc_key: str = "/etc/dhcpc/rndc-key") -> Optional[str]: + """Perform DNS update via nsupdate command. + + Returns None on success, else returns combined stdout/stderr as a string. + """ + nsup = create_nsupdate_payload(hostname, newip, dyndomain) + cmd = [nsupdate_bin, "-k", rndc_key, "-v"] + try: + p = Popen(cmd, shell=False, bufsize=0, stdin=PIPE, stdout=PIPE, stderr=STDOUT) + except OSError as e: + return f"nsupdate: execution failed: {e}" + except Exception as e: + return f"nsupdate: some error occured: {e}" + + (output, err) = p.communicate(nsup.encode()) + out = output.decode() if output else "" + if out.find("status: NOERROR") >= 0: + return None + return out + + +def dnsupdatethread(hbdclass, cfg: dict, log: Optional[callable] = None, email: Optional[callable] = None): + """Thread target: process dns update queue from hbdclass.Host.dnsQ. + + hbdclass: module with Host class that exposes dnsQ queue + cfg: configuration mapping with 'dyndomains' and 'nsupdate_bin' + log: callable(host, message) + email: callable(subject, message) + """ + while True: + name, addr = hbdclass.Host.dnsQ.get() + m = f"changed address to {addr}" + for dyndomain in cfg.get("dyndomains", []): + err = nsupdate(name, addr, dyndomain, nsupdate_bin=cfg.get("nsupdate_bin", "/usr/local/bin/nsupdate")) + if err: + m += f", DNS update failed: {err}" + if email: + try: + email("error: nsupdate failed", f"{name}.dy.{dyndomain}: {m}") + except Exception: + pass + else: + m += ", DNS updated." + hbdclass.Host.dnsQ.task_done() + if log: + try: + log(name, m) + except Exception: + pass + + +def start_dns_thread(hbdclass, cfg: dict, log: Optional[callable] = None, email: Optional[callable] = None) -> threading.Thread: + t = threading.Thread(target=dnsupdatethread, args=(hbdclass, cfg, log, email)) + t.daemon = True + t.start() + return t diff --git a/build/lib/hbd/http.py b/build/lib/hbd/http.py new file mode 100644 index 0000000..5eaf243 --- /dev/null +++ b/build/lib/hbd/http.py @@ -0,0 +1,235 @@ +"""HTTP server and handler scaffolds (thin wrappers around http.server).""" +from http import server +import json +import time +import urllib.parse + +from urllib3 import request +from fastapi.templating import Jinja2Templates + +class HttpServer(server.ThreadingHTTPServer): + allow_reuse_address = True + + def threaded(self): + pass + + +def make_handler_class( + config, + hbdclass, + msgs_getter, + log=None, + email=None, + pushmsg=None, + msg_to_websockets=None, + tcss=None, + DEBUG=0, + verbose=False, + get_now=None, + VER="", +): + """Return a BaseHTTPRequestHandler subclass bound to runtime objects. + + `msgs_getter` should be a callable that returns a list-like of messages. + """ + templates = Jinja2Templates(directory="templates") + get_now = get_now or (lambda: time.time()) + + class CustomHandler(server.BaseHTTPRequestHandler): + + server_version = f"HeartbeatHTTP/{VER}" + + def version_string(self): + return self.server_version + + def handle(self): + try: + return server.BaseHTTPRequestHandler.handle(self) + except Exception as e: + self.log_error("Request went away: %r", e) + self.close_connection = 1 + return + + def do_HEAD(self): + self.setheaders(200) + + def setheaders(self, code, headerdict={}): + self.send_response(code) + self.send_header( + "Last-Modified", + time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime(get_now())), + ) + for h in headerdict: + self.send_header(h, headerdict[h]) + self.end_headers() + + def buildhead(self, title="Heartbeat", refresh=None, extras=None): + res = [] + res.append('') + res.append("") + res.append("") + res.append("%s" % (title)) + if refresh: + res.append("\n" % refresh) + if extras: + res.append(extras) + res.append("") + res.append('') + return res + + def buildpage(self): + res = self.buildhead(refresh=60, extras=tcss) + res.append("

Heartbeat status %s

" % VER) + res += hbdclass.ubHost.buildhosttable() + res += hbdclass.ubHost.buildmsgtable(msgs_getter()) + res.append( + "

%s (%s)

" % (time.strftime("%H:%M:%S", time.localtime(get_now())), config.get("tz", "CET-1CDT")) + ) + res.append("") + return res + + def builderror(self, code, cause, lcause): + res = [] + res.append('') + res.append("") + res.append("%s %s" % (code, cause)) + res.append("") + res.append("

%s

" % (cause)) + res.append("

%s

" % lcause) + res.append("
") + res.append( + "
hbd (Unix) Server at %s:%s
" % (config.get("hbd_host"), config.get("hbd_port")) + ) + res.append("") + return code, res + + def do_GET(self): + xsig = 0 + rqAcceptEncoding = self.headers.get("Accept-encoding", {}) + headerdict = {"Content-Type": "text/html; charset = ISO-8859-1"} + qr = urllib.parse.urlparse(self.path) + qa = urllib.parse.parse_qs(qr.query) + + if qr.path == "/": + res = self.buildpage() + + elif qr.path == "/c": # command on host /c?h=melschserver&c=sudo%20ls + uname = qa.get("h", [None])[0] + ucmd = qa.get("c", [None])[0] + if not ucmd or not uname: + code, res = self.builderror(400, "Argument error", "need h= and c= arguments") + elif uname not in hbdclass.Host.hosts: + code, res = self.builderror(400, "Data error", "h=%s not found" % uname) + else: + hbdclass.Host.hosts[uname].cmds.append(("CMD", {"cmd": urllib.parse.unquote(ucmd)})) + res = self.buildhead() + res.append("cmd %s queued for host %s" % (uname, ucmd)) + + elif qr.path == "/d": # drop host /d?h=melschserver + uname = qa.get("h", [None])[0] + if not uname: + code, res = self.builderror(400, "Argument error", "need h= argument") + if uname not in hbdclass.Host.hosts: + code, res = self.builderror(400, "Data error", "h=%s not found" % uname) + else: + if log: + log(uname, "dropped") + del hbdclass.Host.hosts[uname] + res = self.buildhead() + res.append("Done") + + elif qr.path == "/n": # register name + uname = qa.get("h", [None])[0] + if not uname: + code, res = self.builderror(400, "Argument error", "need h= argument") + if uname not in hbdclass.Host.hosts: + code, res = self.builderror(400, "Data error", "h=%s not found" % uname) + else: + ll = hbdclass.Host.hosts[uname].registerDns() + res = self.buildhead() + res.append(ll) + if log: + log(uname, ll) + + elif qr.path == "/u": # update + uname = urllib.parse.unquote(qa.get("h", [None])[0]) + ucode = qa.get("c", [None])[0] + if not ucode or not uname: + code, res = self.builderror(400, "Argument error", "need h= and c= arguments") + elif uname != "All" and uname not in hbdclass.Host.hosts: + code, res = self.builderror(400, "Data error", "h=%s not found" % uname) + else: + res = self.buildhead() + if uname != "All": + names = [uname] + else: + names = [] + for n in hbdclass.Host.hosts: + if hbdclass.Host.hosts[n].cver >= 2: # earliest version that supports update + names.append(n) + for n in names: + err = None + try: + from hbd import proto + # read code from a file name, fallback to sending ucode as data + err = None + # attempt to send update command to host + r = {"csum": None, "code": ucode} + hbdclass.Host.hosts[n].cmds.append(("UPD", r)) + except Exception as e: + err = str(e) + res.append("update started for %s: %s
" % (n, err if err else "OK")) + res.append("Done") + + elif qr.path == "/api/0/hosts": # api access to host table + headerdict = {"Content-Type": "application/json; charset=utf-8"} + lst = [] + for h in hbdclass.Host.hosts: + lst.append(hbdclass.Host.hosts[h].jsons()) + res = ["[" + ",".join(lst) + "]"] + + elif qr.path == "/api/0/messages": # api access to host table + headerdict = {"Content-Type": "application/json; charset=utf-8"} + lst = msgs_getter()[-30:] + res = [json.dumps(lst)] + + elif qr.path == "/r": # restart + res = self.buildhead() + res.append("restart request") + xsig = 1 # signal.SIGHUP will be handled by application + if log: + log(None, "restart request") + elif qr.path == "/live": # show + heartbeat_ws_url = f"wss://{host}:50006/hbd" + res = templates.TemplateResponse( + "heartbeat.html", + { + "title": "Heartbeat", + "header": "Heartbeat", + "request": request, + "heartbeat_ws_url": heartbeat_ws_url, + "extra_scripts": extra_scripts, + }, + ) + else: + code, res = self.builderror(404, "Not Found", "requested URL was not found on this server.") + + if "deflate" in rqAcceptEncoding: + headerdict["Content-Encoding"] = "deflate" + towrite = __import__("zlib").compress("\n".join(res).encode(), 6) + else: + towrite = "\n".join(res) + headerdict["Content-Length"] = len(towrite) + headerdict["Cache-Control"] = "private, must-revalidate, max-age=0" + headerdict["Expires"] = "Thu, 01 Jan 1970 00:00:00 GMT" + self.setheaders(200 if 'res' in locals() else code, headerdict) + self.wfile.write(towrite if isinstance(towrite, bytes) else towrite.encode()) + + if xsig: + # inform application via setting a flag on the server instance + try: + self.server.xsig = xsig + except Exception: + pass + + return CustomHandler diff --git a/build/lib/hbd/notify.py b/build/lib/hbd/notify.py new file mode 100644 index 0000000..8c99e76 --- /dev/null +++ b/build/lib/hbd/notify.py @@ -0,0 +1,163 @@ +"""Notification helpers: email, pushover, mattermost, signal and dispatcher.""" +from typing import Optional +import http.client +import urllib.parse +import subprocess +import smtplib +import time +import traceback + +DEFAULT_PUSHPROVIDERS = ["all", "pushover", "mattermost", "signal"] + +# module-level configuration set via setup() +_config = {} + + +def setup(cfg: dict): + """Initialize notifier defaults from a configuration dict.""" + global _config + _config = dict(cfg) + + +def send_email(aemail, smtpserver, sender, subject, body, debug=0): + """Send a plain email via SMTP. Returns True on success.""" + try: + server = smtplib.SMTP(smtpserver) + if debug > 0: + server.set_debuglevel(1) + server.sendmail(sender, aemail, body) + except Exception as e: + if debug: + print("email send failed:", e) + try: + server.quit() + except Exception: + pass + return False + try: + server.quit() + except Exception: + pass + return True + + +def email(subject: str, msg: str, debug: int = 0) -> bool: + """Convenience wrapper exposed to the rest of the application. + + Uses module-level configuration to supply recipient list, smtp server + and sender address. + """ + toaddrs = _config.get("AEMAIL") or _config.get("aemail") or _config.get("email_to") or [] + fromemail = _config.get("fromemail") or _config.get("sender") or f"aew.heartbeat@{_config.get('domain','local') }" + smtpserver = _config.get("SMTPSERVER") or _config.get("smtpserver") or _config.get("SMTPSERVER", "localhost") + date = time.strftime("%a, %d %b %Y %H:%M:%S %z", time.localtime()) + body = "To: %s\nFrom: %s\nSubject: %s\nDate: %s\n\n%s" % ( + toaddrs[0] if toaddrs else "", + fromemail, + subject, + date, + msg, + ) + return send_email(toaddrs, smtpserver, fromemail, subject, body, debug=debug) + + +def pushover(token: str, user: str, msg: str, debug: int = 0) -> bool: + """Send message via Pushover API.""" + conn = http.client.HTTPSConnection("api.pushover.net:443") + try: + conn.request( + "POST", + "/1/messages.json", + urllib.parse.urlencode({"token": token, "user": user, "message": msg}), + {"Content-type": "application/x-www-form-urlencoded"}, + ) + r = conn.getresponse() + if debug: + print("pushover response:", r.status, r.reason) + return r.status == 200 + except Exception as e: + if debug: + print("pushover error:", e) + return False + + +def pushmattermost(host: str, token: str, channel: str, msg: str, username: str = "hbd", icon: Optional[str] = None, debug: int = 0) -> bool: + """Send a message to Mattermost via simple webhook driver if available. + + This helper tries to import mattermostdriver.Driver and uses webhooks if present. + If the import fails it returns False. + """ + try: + from mattermostdriver import Driver + except Exception: + return False + ses = {"url": host, "scheme": "http", "basepath": "/api/v4", "port": 8065} + mm = Driver(ses) + payload = {"text": msg, "channel": channel, "username": username} + if icon: + payload["icon_url"] = icon + try: + rc = mm.webhooks.call_webhook(token, payload) + if debug: + print("mattermost rc:", rc) + return bool(rc is None or rc == "") + except Exception as e: + if debug: + print("mattermost error:", e) + return False + + +def pushsignal(signal_cli_bin: str, user: str, recipient: str, msg: str, debug: int = 0) -> bool: + """Send a message via signal-cli (requires local installation). + + Uses subprocess to call signal-cli. Returns True if the command succeeded. + """ + CLI = [signal_cli_bin, "-u", user, "send", "-m", msg, recipient] + if debug: + print("signal cli: ", CLI) + try: + res = subprocess.run(CLI, capture_output=True) + if res.returncode != 0: + if debug: + print("signal failed:", res.stderr.decode()) + return False + if debug: + print("signal sent:", res.stdout.decode()) + return True + except Exception as e: + if debug: + print("signal exception:", e) + return False + + +def pushmsg(cfg: dict, msg: str, debug: int = 0): + """Dispatch push notifications according to `cfg['pushsrv']`. + + cfg is expected to contain keys for different services when needed, e.g. + - cfg['pushsrv'] : one of 'all', 'pushover', 'mattermost', 'signal' + - cfg['pushover_token'], cfg['pushover_user'] + - cfg['matter_host'], cfg['matter_token'], cfg['matter_channel'] + - cfg['signal_cli'], cfg['signal_user'], cfg['signal_recipient'] + + Returns a dict of results per provider. + """ + results = {} + p = cfg.get("pushsrv", "pushover") + if p in ("all", "pushover"): + ok = pushover(cfg.get("pushover_token", ""), cfg.get("pushover_user", ""), msg, debug=debug) + results["pushover"] = ok + if p in ("all", "mattermost"): + ok = pushmattermost(cfg.get("matter_host", ""), cfg.get("matter_token", ""), cfg.get("matter_channel", ""), msg, username=cfg.get("matter_username", "hbd"), icon=cfg.get("matter_icon"), debug=debug) + results["mattermost"] = ok + if p in ("all", "signal"): + ok = pushsignal(cfg.get("signal_cli", "/usr/local/bin/signal-cli"), cfg.get("signal_user", ""), cfg.get("signal_recipient", ""), msg, debug=debug) + results["signal"] = ok + if debug: + print("push results:", results) + return results + + +def pushmsg_from_config(msg: str, debug: int = 0) -> dict: + """Use the module-level configuration dict to dispatch a push message.""" + return pushmsg(_config, msg, debug=debug) + diff --git a/build/lib/hbd/proto.py b/build/lib/hbd/proto.py new file mode 100644 index 0000000..8212960 --- /dev/null +++ b/build/lib/hbd/proto.py @@ -0,0 +1,81 @@ +"""Message encoding/decoding utilities for hbd protocol.""" +from typing import Dict, Any +import zlib + + +def dicttos(ID: str, d: Dict[str, Any], compress: bool = False): + """Serialize a dict to protocol message bytes. + + If compress is True, the payload is zlib-compressed and the message is + prefixed with `!ID:` as the original script did. Otherwise the format is + `ID:key=value;...` (bytes). + """ + s = [] + for k in d: + v = d[k] + if isinstance(v, float): + s.append(f"{k}={v:0.5f}") + else: + s.append(f"{k}={v}") + pk = ";".join(s) + if compress: + zpk = zlib.compress(pk.encode(), 6) + hdr = ("!" + ID + ":").encode() + return hdr + zpk + else: + return (ID + ":" + pk).encode() + + +def stodict(msg: bytes): + """Deserialize a protocol message into a dict. + + Mirrors original behaviour: detects compressed messages starting with + '!' and decodes accordingly. Returns a dict with key 'ID' set to the + message ID and the parsed key/value pairs. + """ + d = {} + if len(msg) > 0 and chr(msg[0]) == "!": + # message is: b'!ID:' + compressed_payload + # original code used msg[1:4].decode() for ID (3 bytes including colon) + try: + pk = zlib.decompress(msg[5:]).decode() + except Exception: + # malformed compressed payload + return {} + d["ID"] = msg[1:4].decode() + else: + try: + r0 = msg.split(b":", 1) + pk = r0[1].decode() + d["ID"] = r0[0].decode() + except Exception: + return {} + if not pk: + return d + parts = pk.split(";") + for v in parts: + if not v: + continue + vr = v.split("=", 1) + k = vr[0].strip() + if len(vr) == 1: + d[k] = None + else: + val = vr[1].strip() + if val and val[0].isdigit(): + try: + val_e = eval(val) + except Exception: + val_e = val + d[k] = val_e + else: + d[k] = val + return d + + +def oldmtodict(msg: bytes): + """Compatibility wrapper for old-style messages (no ID prefix). + + The original implementation prefixed with 'HTB:' and called stodict. + """ + return stodict(b"HTB:" + msg) diff --git a/build/lib/hbd/server.py b/build/lib/hbd/server.py new file mode 100644 index 0000000..ae379c4 --- /dev/null +++ b/build/lib/hbd/server.py @@ -0,0 +1,128 @@ +"""Server runtime: starts UDP listener, HTTP server and websocket stubs.""" +import asyncio +import logging + +from . import udp + +logger = logging.getLogger(__name__) + + +async def _run_async(config): + loop = asyncio.get_running_loop() + + # shared runtime collections and helpers + msgs = [] + + # prepare runtime dependencies + import threading + import time + import hbdclass + from . import http as http_mod + from . import ws as ws_mod + from . import dns as dns_mod + from . import notify as notify_mod + + notify_mod.setup(config) + + def log(host, m, service=None): + ts = time.time() + s = f"{time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(ts))} {host or ''} {m}" + msgs.append(s) + logger.info(s) + + email = notify_mod.email + pushmsg = notify_mod.pushmsg_from_config + msg_to_websockets = ws_mod.broadcast + + # UDP server endpoint (handler wired to handle_datagram with context) + bind_addr = ("0.0.0.0", config.get("hb_port", 50003)) + logger.info("Starting UDP server on %s:%s", *bind_addr) + + def udp_handler(msg, addr, transport): + ctx = dict( + config=config, + hbdclass=hbdclass, + log=log, + email=email, + pushmsg=pushmsg, + msg_to_websockets=msg_to_websockets, + msgs=msgs, + DEBUG=config.get("debug", 0), + verbose=config.get("verbose", False), + ) + udp.handle_datagram(msg, addr, transport, ctx) + + transport, protocol = await loop.create_datagram_endpoint( + lambda: udp.EchoServerProtocol(config=config, handler=udp_handler), + local_addr=bind_addr, + ) + + # HTTP server (runs in its own thread) + try: + handler_cls = http_mod.make_handler_class( + config=config, + hbdclass=hbdclass, + msgs_getter=lambda: msgs, + log=log, + email=email, + pushmsg=pushmsg, + msg_to_websockets=msg_to_websockets, + tcss=None, + DEBUG=config.get("debug", 0), + verbose=config.get("verbose", False), + get_now=lambda: time.time(), + VER="", + ) + serv = http_mod.HttpServer((config.get("hbd_host", ""), config.get("hbd_port", 50004)), handler_cls) + http_thread = threading.Thread(target=serv.serve_forever, daemon=True) + http_thread.start() + logger.info("HTTP server started on %s:%s", config.get("hbd_host", ""), config.get("hbd_port", 50004)) + except Exception as e: + logger.exception("failed to start HTTP server: %s", e) + + # start dns update thread + dns_mod.start_dns_thread(hbdclass, config, log=log, email=email) + logger.info("dns update thread started") + + # Start the websocket servers as a background task + try: + ws_task = asyncio.create_task( + ws_mod.start( + host=config.get("hbd_host", ""), + ws_port=config.get("ws_port", 50005), + wss_port=config.get("wss_port", None), + ssl_context=None, + get_hosts=lambda: [hbdclass.Host.hosts[h].stateinfo() for h in sorted(hbdclass.Host.hosts)], + get_msgs=lambda: msgs, + verbose=config.get("verbose", False), + ) + ) + logger.info("WebSocket task started") + except Exception as e: + logger.exception("websocket server failed to start: %s", e) + + try: + # run forever + await asyncio.Future() + finally: + transport.close() + try: + serv.shutdown() + except Exception: + pass + try: + ws_task.cancel() + except Exception: + pass + + +def run(config): + """Start the hbd service (blocking). + + This is a thin wrapper around asyncio.run to host the async services. + """ + logging.basicConfig(level=logging.DEBUG if config.get("debug", 0) > 0 else logging.INFO) + try: + asyncio.run(_run_async(config)) + except KeyboardInterrupt: + logger.info("Shutting down (KeyboardInterrupt)") diff --git a/build/lib/hbd/udp.py b/build/lib/hbd/udp.py new file mode 100644 index 0000000..8004ac3 --- /dev/null +++ b/build/lib/hbd/udp.py @@ -0,0 +1,235 @@ +"""UDP listener and datagram processing.""" +import asyncio +from compression import zlib +import logging + +logger = logging.getLogger(__name__) + + +from .proto import stodict, oldmtodict +from hbd.utils import dur + + +class EchoServerProtocol(asyncio.DatagramProtocol): + def __init__(self, config=None, handler=None): + super().__init__() + self.config = config or {} + self.handler = handler + + def connection_made(self, transport): + self.transport = transport + logger.info("UDP Server listening...") + + def datagram_received(self, data, addr): + logger.debug("Received from %s", addr) + try: + msg = parse_message(data) + if self.handler: + # handler can be a callable provided by the application + # pass the transport so handlers can send replies (ACKs/commands) + self.handler(msg, addr, self.transport) + except Exception: + logger.exception("Error while processing datagram from %s", addr) + + +def parse_message(data: bytes): + """Parse a raw datagram into a message dict. + + Uses the protocol decoding helpers and falls back to old format when + decoding returns an empty dict (compat with older clients). + """ + msg = stodict(data) + if not msg: + # fallback to old format + msg = oldmtodict(data) + return msg + +def dicttos(ID, d, compress=False): + s = [] + for k in d: + if isinstance(d[k], float): + s.append("%s=%0.5f" % (k, d[k])) + else: + s.append("%s=%s" % (k, d[k])) + pk = ";".join(s) + if compress: + zpk = zlib.compress(pk.encode(), 6) + ID = "!" + ID + ":" + opk = ID.encode() + zpk + else: + zpk = pk + opk = ID + ":" + zpk + return opk + +def handle_datagram(msg: dict, addr, transport, ctx: dict): + """Handle a parsed datagram message. + + ctx is a dictionary with runtime dependencies: + - config: dict of configuration + - hbdclass: module providing Host/Connection classes + - log: callable(loghost, message) + - email: callable(subject, message) + - pushmsg: callable(message) + - msg_to_websockets: callable(typ, data) + - msgs: list for storing message strings + - DEBUG, verbose + """ + if not msg: + return + now = __import__("time").time() + cfg = ctx.get("config", {}) + hbdcls = ctx.get("hbdclass") + log = ctx.get("log") + email = ctx.get("email") + pushmsg = ctx.get("pushmsg") + msg_to_websockets = ctx.get("msg_to_websockets") + msgs = ctx.get("msgs") + DEBUG = ctx.get("DEBUG", 0) + verbose = ctx.get("verbose", False) + + # normalize addr (ip, port) + ip = addr[0] if isinstance(addr, (list, tuple)) else addr + name = msg.get("name", "unknown") + from hbd.utils import shortname + uname = shortname(name) + + if uname not in hbdcls.Host.hosts: + host = hbdcls.Host(uname) + host.dyn = uname in cfg.get("dyndnshosts", []) + if verbose: + print(("XX: New host, num now %s" % (len(hbdcls.Host.hosts)))) + newh = True + else: + host = hbdcls.Host.hosts[uname] + newh = False + + cid = msg.get("id", 0) + try: + rtt = float(msg.get("rtt", None)) + except Exception: + rtt = None + + if msg.get("ID") == "HTB": + host.doesack = msg.get("acks", -1) + host.setcver(msg.get("ver", 0)) + + try: + conn, res = host.conndata(cid, ip, rtt, now) + except Exception as e: + if DEBUG > 0: + print("conndata failed: %s" % e) + return + + if res: + if log: + log(uname, res) + if uname in cfg.get("watchhosts", []): + if email: + email("address change", "%s %s" % (host.name, res)) + if pushmsg: + pushmsg("%s %s" % (host.name, res)) + + interval = int(msg.get("interval", 0) or 0) + shutdown = msg.get("shutdown", 0) + service = msg.get("service", "unknown") + message = msg.get("msg", None) + boot = msg.get("boot", 0) + + if boot: + if log: + log(uname, "booted") + if uname in cfg.get("watchhosts", []): + m = "%s booted" % (host.name) + if email: + email("booted", m) + if pushmsg: + pushmsg(m) + if message: + if log: + log(uname, "msg: %s" % message, service=service) + if uname in cfg.get("watchhosts", []): + if email: + email("msg", message) + if pushmsg: + pushmsg(message) + + if conn.getstate() != hbdcls.Connection.UP: + lasts = conn.state + d = conn.newstate(hbdcls.Connection.UP, now) + m = "%s back after being %s for %s" % (conn.afam, lasts, dur(d)) + if log: + log(uname, m) + if uname in cfg.get("watchhosts", []): + if email: + email("%s back" % conn.afam, uname) + if pushmsg: + pushmsg("%s %s is back" % (uname, conn.afam)) + + if boot or newh: + host.upcount = host.doesack + else: + host.upcount += 1 + + if shutdown: + if log: + log(uname, "%s shutdown" % conn.afam) + if uname in cfg.get("watchhosts", []): + if email: + email("shutdown", "%s %s shutdown" % (uname, conn.afam)) + if pushmsg: + pushmsg("%s %s shutdown" % (uname, conn.afam)) + conn.newstate(hbdcls.Connection.DOWN, now) + + if interval > 0: + host.interval = interval + + # send ACK back + rmsg = {"time": __import__("time").time()} + if host.cver < 1: + opkt = b"ACK" + else: + opkt = dicttos("ACK", rmsg, host.cver > 1) + try: + transport.sendto(opkt, addr) + except Exception as e: + if DEBUG > 0: + print(("cannot send ack: %s" % e)) + + # send any commands we have queued + while len(host.cmds): + op, rmsg = host.cmds[0] + if op == "CMD": + if email: + email("%s cmd exec" % uname, "command '%s' sent" % rmsg) + del host.cmds[0] + if log: + log(uname, "command sent") + if host.cver < 1: + rmsg = rmsg["cmd"] + elif op == "UPD": + del host.cmds[0] + if log: + log(uname, "update initiated") + if host.cver < 1: + if log: + log(uname, " ver 0 does not support UPD") + continue + if host.cver < 1: + opkt = rmsg if isinstance(rmsg, (bytes, str)) else str(rmsg) + if isinstance(opkt, str): + opkt = opkt.encode() + else: + opkt = dicttos(op, rmsg, True) + try: + transport.sendto(opkt, addr) + except Exception as e: + if DEBUG > 0: + print(("cannot send cmd/update: %s" % e)) + + if msg_to_websockets: + try: + msg_to_websockets("host", host.stateinfo()) + except Exception: + pass + + diff --git a/build/lib/hbd/utils.py b/build/lib/hbd/utils.py new file mode 100644 index 0000000..7188dd9 --- /dev/null +++ b/build/lib/hbd/utils.py @@ -0,0 +1,36 @@ +"""Utility helpers extracted from the original script.""" +import time + + +def shortname(name: str) -> str: + return name.split(".")[0] + + +def dur(sec: int) -> str: + sec = int(sec) + h = int(sec / 3600) + m = int((sec - h * 3600) / 60) + s = int((sec - h * 3600) % 60) + if h > 0: + return "%d:%02d:%02d" % (h, m, s) + if m > 0: + return "%d:%02d" % (m, s) + return "0:%02d" % s + + +def initlog(logfile: str): + """Open logfile for appending; fall back to creating it or returning stderr. + + This mirrors the original behaviour from the monolithic script. + """ + try: + return open(logfile, "a+") + except Exception: + pass + try: + return open(logfile, "w") + except Exception as e: + import sys + + print(f"cannot open logfile {logfile}, using STDERR: {e}") + return sys.stderr diff --git a/build/lib/hbd/ws.py b/build/lib/hbd/ws.py new file mode 100644 index 0000000..6f85cdb --- /dev/null +++ b/build/lib/hbd/ws.py @@ -0,0 +1,125 @@ +"""WebSocket server and broadcast helpers for hbd. + +Provides an asyncio-based WebSocket server and a thread-safe broadcast +function that other threads or synchronous code can call. +""" +import asyncio +import json +import logging +from typing import Callable, Iterable, Optional + +import websockets + +logger = logging.getLogger(__name__) + +_connections = set() +_loop: Optional[asyncio.AbstractEventLoop] = None +_get_hosts: Optional[Callable[[], Iterable]] = None +_get_msgs: Optional[Callable[[], Iterable]] = None +_verbose = False + + +async def _handler(websocket, path): + global _connections + _connections.add(websocket) + remote_address = websocket.remote_address + if _verbose: + logger.info("DBG ws_serve: %s: %s", remote_address, path) + try: + # send initial hosts + if _get_hosts: + for h in _get_hosts(): + jmsg = json.dumps({"type": "host", "data": h}) + await websocket.send(jmsg) + # send recent messages + if _get_msgs: + for m in list(_get_msgs())[-100:]: + jmsg = json.dumps({"type": "message", "data": m}) + await websocket.send(jmsg) + + # keep connection open until client disconnects + async for _ in websocket: + # we don't expect meaningful incoming messages besides the initial + # client 'hello' that some clients send; ignore for now + if _verbose: + logger.debug("received ws data: %s", _) + + except (websockets.exceptions.ConnectionClosedOK, websockets.exceptions.ConnectionClosedError) as e: + if _verbose: + logger.info("ws closed: %r", e) + except Exception as e: + logger.exception("ws handler exception: %s", e) + finally: + try: + _connections.remove(websocket) + except KeyError: + pass + await websocket.wait_closed() + + +async def start(host: str, ws_port: int, wss_port: Optional[int] = None, ssl_context=None, get_hosts: Optional[Callable] = None, get_msgs: Optional[Callable] = None, verbose: bool = False): + """Start WebSocket servers and block until cancelled. + + This is intended to be awaited inside the main asyncio event loop. + If `wss_port` and `ssl_context` are provided, a WSS server will also be + started. + """ + global _loop, _get_hosts, _get_msgs, _verbose + _loop = asyncio.get_running_loop() + _get_hosts = get_hosts + _get_msgs = get_msgs + _verbose = verbose + + servers = [] + # plain WebSocket + ws_server = websockets.serve(_handler, host, ws_port, subprotocols=["hbd"]) + servers.append(ws_server) + + # secure WebSocket (optional) + if wss_port and ssl_context: + wss_server = websockets.serve(_handler, host, wss_port, ssl=ssl_context, subprotocols=["hbd"]) + servers.append(wss_server) + + # await starting of all servers + for srv in servers: + await srv + + if _verbose: + logger.info("WebSocket server started on port %s (wss %s)", ws_port, wss_port) + + # block forever (until loop is stopped or cancelled) + await asyncio.Future() + + +def broadcast(typ: str, data) -> bool: + """Thread-safe broadcast helper. + + Schedules coroutine(s) on the running loop to send message to all + connected websockets. Returns False if server was not running. + """ + global _loop + if not _loop: + return False + jmsg = json.dumps({"type": typ, "data": data}) + to_close = [] + for ws in list(_connections): + if ws.closed: + to_close.append(ws) + continue + try: + asyncio.run_coroutine_threadsafe(ws.send(jmsg), _loop) + except Exception: + to_close.append(ws) + logger.debug("ws.send exception: closed") + for ws in to_close: + try: + asyncio.run_coroutine_threadsafe(ws.wait_closed(), _loop) + except Exception: + pass + if ws in _connections: + _connections.remove(ws) + return True + + +def connection_count() -> int: + return len(_connections) diff --git a/daemon/__init__.py b/daemon/__init__.py deleted file mode 100644 index 9d10dda..0000000 --- a/daemon/__init__.py +++ /dev/null @@ -1,49 +0,0 @@ -# -*- coding: utf-8 -*- - -# daemon/__init__.py -# Part of ‘python-daemon’, an implementation of PEP 3143. -# -# Copyright © 2009–2015 Ben Finney -# Copyright © 2006 Robert Niederreiter -# -# This is free software: you may copy, modify, and/or distribute this work -# under the terms of the Apache License, version 2.0 as published by the -# Apache Software Foundation. -# No warranty expressed or implied. See the file ‘LICENSE.ASF-2’ for details. - -""" Library to implement a well-behaved Unix daemon process. - - This library implements the well-behaved daemon specification of - :pep:`3143`, “Standard daemon process library”. - - A well-behaved Unix daemon process is tricky to get right, but the - required steps are much the same for every daemon program. A - `DaemonContext` instance holds the behaviour and configured - process environment for the program; use the instance as a context - manager to enter a daemon state. - - Simple example of usage:: - - import daemon - - from spam import do_main_program - - with daemon.DaemonContext(): - do_main_program() - - Customisation of the steps to become a daemon is available by - setting options on the `DaemonContext` instance; see the - documentation for that class for each option. - - """ - -from __future__ import absolute_import, unicode_literals - -from .daemon import DaemonContext - - -# Local variables: -# coding: utf-8 -# mode: python -# End: -# vim: fileencoding=utf-8 filetype=python : diff --git a/daemon/_metadata.py b/daemon/_metadata.py deleted file mode 100644 index 32b26fd..0000000 --- a/daemon/_metadata.py +++ /dev/null @@ -1,155 +0,0 @@ -# -*- coding: utf-8 -*- - -# daemon/_metadata.py -# Part of ‘python-daemon’, an implementation of PEP 3143. -# -# Copyright © 2008–2015 Ben Finney -# -# This is free software: you may copy, modify, and/or distribute this work -# under the terms of the Apache License, version 2.0 as published by the -# Apache Software Foundation. -# No warranty expressed or implied. See the file ‘LICENSE.ASF-2’ for details. - -""" Package metadata for the ‘python-daemon’ distribution. """ - -from __future__ import absolute_import, unicode_literals - -import json -import re -import collections -import datetime - -import pkg_resources - - -distribution_name = "python-daemon" -version_info_filename = "version_info.json" - - -def get_distribution_version_info(filename=version_info_filename): - """ Get the version info from the installed distribution. - - :param filename: Base filename of the version info resource. - :return: The version info as a mapping of fields. If the - distribution is not available, the mapping is empty. - - The version info is stored as a metadata file in the - distribution. - - """ - version_info = { - "release_date": "UNKNOWN", - "version": "UNKNOWN", - "maintainer": "UNKNOWN", - } - - try: - distribution = pkg_resources.get_distribution(distribution_name) - except pkg_resources.DistributionNotFound: - distribution = None - - if distribution is not None: - if distribution.has_metadata(version_info_filename): - content = distribution.get_metadata(version_info_filename) - version_info = json.loads(content) - - return version_info - - -version_info = get_distribution_version_info() - -version_installed = version_info["version"] - - -rfc822_person_regex = re.compile("^(?P[^<]+) <(?P[^>]+)>$") - -ParsedPerson = collections.namedtuple("ParsedPerson", ["name", "email"]) - - -def parse_person_field(value): - """ Parse a person field into name and email address. - - :param value: The text value specifying a person. - :return: A 2-tuple (name, email) for the person's details. - - If the `value` does not match a standard person with email - address, the `email` item is ``None``. - - """ - result = (None, None) - - match = rfc822_person_regex.match(value) - if len(value): - if match is not None: - result = ParsedPerson(name=match.group("name"), email=match.group("email")) - else: - result = ParsedPerson(name=value, email=None) - - return result - - -author_name = "Ben Finney" -author_email = "ben+python@benfinney.id.au" -author = "{name} <{email}>".format(name=author_name, email=author_email) - - -class YearRange: - """ A range of years spanning a period. """ - - def __init__(self, begin, end=None): - self.begin = begin - self.end = end - - def __unicode__(self): - text = "{range.begin:04d}".format(range=self) - if self.end is not None: - if self.end > self.begin: - text = "{range.begin:04d}–{range.end:04d}".format(range=self) - return text - - __str__ = __unicode__ - - -def make_year_range(begin_year, end_date=None): - """ Construct the year range given a start and possible end date. - - :param begin_date: The beginning year (text) for the range. - :param end_date: The end date (text, ISO-8601 format) for the - range, or a non-date token string. - :return: The range of years as a `YearRange` instance. - - If the `end_date` is not a valid ISO-8601 date string, the - range has ``None`` for the end year. - - """ - begin_year = int(begin_year) - - try: - end_date = datetime.datetime.strptime(end_date, "%Y-%m-%d") - except (TypeError, ValueError): - # Specified end_date value is not a valid date. - end_year = None - else: - end_year = end_date.year - - year_range = YearRange(begin=begin_year, end=end_year) - - return year_range - - -copyright_year_begin = "2001" -build_date = version_info["release_date"] -copyright_year_range = make_year_range(copyright_year_begin, build_date) - -copyright = "Copyright © {year_range} {author} and others".format( - year_range=copyright_year_range, author=author -) -license = "Apache-2" -url = "https://alioth.debian.org/projects/python-daemon/" - - -# Local variables: -# coding: utf-8 -# mode: python -# End: -# vim: fileencoding=utf-8 filetype=python : diff --git a/daemon/daemon.py b/daemon/daemon.py deleted file mode 100644 index d997c1b..0000000 --- a/daemon/daemon.py +++ /dev/null @@ -1,940 +0,0 @@ -# -*- coding: utf-8 -*- - -# daemon/daemon.py -# Part of ‘python-daemon’, an implementation of PEP 3143. -# -# Copyright © 2008–2015 Ben Finney -# Copyright © 2007–2008 Robert Niederreiter, Jens Klein -# Copyright © 2004–2005 Chad J. Schroeder -# Copyright © 2003 Clark Evans -# Copyright © 2002 Noah Spurrier -# Copyright © 2001 Jürgen Hermann -# -# This is free software: you may copy, modify, and/or distribute this work -# under the terms of the Apache License, version 2.0 as published by the -# Apache Software Foundation. -# No warranty expressed or implied. See the file ‘LICENSE.ASF-2’ for details. - -""" Daemon process behaviour. - """ - -from __future__ import absolute_import, unicode_literals - -import os -import sys -import resource -import errno -import signal -import socket -import atexit - -try: - # Python 2 has both ‘str’ (bytes) and ‘unicode’ (text). - basestring = basestring - unicode = unicode -except NameError: - # Python 3 names the Unicode data type ‘str’. - basestring = str - unicode = str - - -class DaemonError(Exception): - """ Base exception class for errors from this module. """ - - def __init__(self, *args, **kwargs): - self._chain_from_context() - - super(DaemonError, self).__init__(*args, **kwargs) - - def _chain_from_context(self): - _chain_exception_from_existing_exception_context(self, as_cause=True) - - -class DaemonOSEnvironmentError(DaemonError, OSError): - """ Exception raised when daemon OS environment setup receives error. """ - - -class DaemonProcessDetachError(DaemonError, OSError): - """ Exception raised when process detach fails. """ - - -class DaemonContext: - """ Context for turning the current program into a daemon process. - - A `DaemonContext` instance represents the behaviour settings and - process context for the program when it becomes a daemon. The - behaviour and environment is customised by setting options on the - instance, before calling the `open` method. - - Each option can be passed as a keyword argument to the `DaemonContext` - constructor, or subsequently altered by assigning to an attribute on - the instance at any time prior to calling `open`. That is, for - options named `wibble` and `wubble`, the following invocation:: - - foo = daemon.DaemonContext(wibble=bar, wubble=baz) - foo.open() - - is equivalent to:: - - foo = daemon.DaemonContext() - foo.wibble = bar - foo.wubble = baz - foo.open() - - The following options are defined. - - `files_preserve` - :Default: ``None`` - - List of files that should *not* be closed when starting the - daemon. If ``None``, all open file descriptors will be closed. - - Elements of the list are file descriptors (as returned by a file - object's `fileno()` method) or Python `file` objects. Each - specifies a file that is not to be closed during daemon start. - - `chroot_directory` - :Default: ``None`` - - Full path to a directory to set as the effective root directory of - the process. If ``None``, specifies that the root directory is not - to be changed. - - `working_directory` - :Default: ``'/'`` - - Full path of the working directory to which the process should - change on daemon start. - - Since a filesystem cannot be unmounted if a process has its - current working directory on that filesystem, this should either - be left at default or set to a directory that is a sensible “home - directory” for the daemon while it is running. - - `umask` - :Default: ``0`` - - File access creation mask (“umask”) to set for the process on - daemon start. - - A daemon should not rely on the parent process's umask value, - which is beyond its control and may prevent creating a file with - the required access mode. So when the daemon context opens, the - umask is set to an explicit known value. - - If the conventional value of 0 is too open, consider setting a - value such as 0o022, 0o027, 0o077, or another specific value. - Otherwise, ensure the daemon creates every file with an - explicit access mode for the purpose. - - `pidfile` - :Default: ``None`` - - Context manager for a PID lock file. When the daemon context opens - and closes, it enters and exits the `pidfile` context manager. - - `detach_process` - :Default: ``None`` - - If ``True``, detach the process context when opening the daemon - context; if ``False``, do not detach. - - If unspecified (``None``) during initialisation of the instance, - this will be set to ``True`` by default, and ``False`` only if - detaching the process is determined to be redundant; for example, - in the case when the process was started by `init`, by `initd`, or - by `inetd`. - - `signal_map` - :Default: system-dependent - - Mapping from operating system signals to callback actions. - - The mapping is used when the daemon context opens, and determines - the action for each signal's signal handler: - - * A value of ``None`` will ignore the signal (by setting the - signal action to ``signal.SIG_IGN``). - - * A string value will be used as the name of an attribute on the - ``DaemonContext`` instance. The attribute's value will be used - as the action for the signal handler. - - * Any other value will be used as the action for the - signal handler. See the ``signal.signal`` documentation - for details of the signal handler interface. - - The default value depends on which signals are defined on the - running system. Each item from the list below whose signal is - actually defined in the ``signal`` module will appear in the - default map: - - * ``signal.SIGTTIN``: ``None`` - - * ``signal.SIGTTOU``: ``None`` - - * ``signal.SIGTSTP``: ``None`` - - * ``signal.SIGTERM``: ``'terminate'`` - - Depending on how the program will interact with its child - processes, it may need to specify a signal map that - includes the ``signal.SIGCHLD`` signal (received when a - child process exits). See the specific operating system's - documentation for more detail on how to determine what - circumstances dictate the need for signal handlers. - - `uid` - :Default: ``os.getuid()`` - - `gid` - :Default: ``os.getgid()`` - - The user ID (“UID”) value and group ID (“GID”) value to switch - the process to on daemon start. - - The default values, the real UID and GID of the process, will - relinquish any effective privilege elevation inherited by the - process. - - `prevent_core` - :Default: ``True`` - - If true, prevents the generation of core files, in order to avoid - leaking sensitive information from daemons run as `root`. - - `stdin` - :Default: ``None`` - - `stdout` - :Default: ``None`` - - `stderr` - :Default: ``None`` - - Each of `stdin`, `stdout`, and `stderr` is a file-like object - which will be used as the new file for the standard I/O stream - `sys.stdin`, `sys.stdout`, and `sys.stderr` respectively. The file - should therefore be open, with a minimum of mode 'r' in the case - of `stdin`, and mimimum of mode 'w+' in the case of `stdout` and - `stderr`. - - If the object has a `fileno()` method that returns a file - descriptor, the corresponding file will be excluded from being - closed during daemon start (that is, it will be treated as though - it were listed in `files_preserve`). - - If ``None``, the corresponding system stream is re-bound to the - file named by `os.devnull`. - - """ - - __metaclass__ = type - - def __init__( - self, - chroot_directory=None, - working_directory="/", - umask=0, - uid=None, - gid=None, - prevent_core=True, - detach_process=None, - files_preserve=None, - pidfile=None, - stdin=None, - stdout=None, - stderr=None, - signal_map=None, - ): - """ Set up a new instance. """ - self.chroot_directory = chroot_directory - self.working_directory = working_directory - self.umask = umask - self.prevent_core = prevent_core - self.files_preserve = files_preserve - self.pidfile = pidfile - self.stdin = stdin - self.stdout = stdout - self.stderr = stderr - - if uid is None: - uid = os.getuid() - self.uid = uid - if gid is None: - gid = os.getgid() - self.gid = gid - - if detach_process is None: - detach_process = is_detach_process_context_required() - self.detach_process = detach_process - - if signal_map is None: - signal_map = make_default_signal_map() - self.signal_map = signal_map - - self._is_open = False - - @property - def is_open(self): - """ ``True`` if the instance is currently open. """ - return self._is_open - - def open(self): - """ Become a daemon process. - - :return: ``None``. - - Open the daemon context, turning the current program into a daemon - process. This performs the following steps: - - * If this instance's `is_open` property is true, return - immediately. This makes it safe to call `open` multiple times on - an instance. - - * If the `prevent_core` attribute is true, set the resource limits - for the process to prevent any core dump from the process. - - * If the `chroot_directory` attribute is not ``None``, set the - effective root directory of the process to that directory (via - `os.chroot`). - - This allows running the daemon process inside a “chroot gaol” - as a means of limiting the system's exposure to rogue behaviour - by the process. Note that the specified directory needs to - already be set up for this purpose. - - * Set the process UID and GID to the `uid` and `gid` attribute - values. - - * Close all open file descriptors. This excludes those listed in - the `files_preserve` attribute, and those that correspond to the - `stdin`, `stdout`, or `stderr` attributes. - - * Change current working directory to the path specified by the - `working_directory` attribute. - - * Reset the file access creation mask to the value specified by - the `umask` attribute. - - * If the `detach_process` option is true, detach the current - process into its own process group, and disassociate from any - controlling terminal. - - * Set signal handlers as specified by the `signal_map` attribute. - - * If any of the attributes `stdin`, `stdout`, `stderr` are not - ``None``, bind the system streams `sys.stdin`, `sys.stdout`, - and/or `sys.stderr` to the files represented by the - corresponding attributes. Where the attribute has a file - descriptor, the descriptor is duplicated (instead of re-binding - the name). - - * If the `pidfile` attribute is not ``None``, enter its context - manager. - - * Mark this instance as open (for the purpose of future `open` and - `close` calls). - - * Register the `close` method to be called during Python's exit - processing. - - When the function returns, the running program is a daemon - process. - - """ - if self.is_open: - return - - if self.chroot_directory is not None: - change_root_directory(self.chroot_directory) - - if self.prevent_core: - prevent_core_dump() - - change_file_creation_mask(self.umask) - change_working_directory(self.working_directory) - change_process_owner(self.uid, self.gid) - - if self.detach_process: - detach_process_context() - - signal_handler_map = self._make_signal_handler_map() - set_signal_handlers(signal_handler_map) - - exclude_fds = self._get_exclude_file_descriptors() - close_all_open_files(exclude=exclude_fds) - - redirect_stream(sys.stdin, self.stdin) - redirect_stream(sys.stdout, self.stdout) - redirect_stream(sys.stderr, self.stderr) - - if self.pidfile is not None: - self.pidfile.__enter__() - - self._is_open = True - - register_atexit_function(self.close) - - def __enter__(self): - """ Context manager entry point. """ - self.open() - return self - - def close(self): - """ Exit the daemon process context. - - :return: ``None``. - - Close the daemon context. This performs the following steps: - - * If this instance's `is_open` property is false, return - immediately. This makes it safe to call `close` multiple times - on an instance. - - * If the `pidfile` attribute is not ``None``, exit its context - manager. - - * Mark this instance as closed (for the purpose of future `open` - and `close` calls). - - """ - if not self.is_open: - return - - if self.pidfile is not None: - # Follow the interface for telling a context manager to exit, - # . - self.pidfile.__exit__(None, None, None) - - self._is_open = False - - def __exit__(self, exc_type, exc_value, traceback): - """ Context manager exit point. """ - self.close() - - def terminate(self, signal_number, stack_frame): - """ Signal handler for end-process signals. - - :param signal_number: The OS signal number received. - :param stack_frame: The frame object at the point the - signal was received. - :return: ``None``. - - Signal handler for the ``signal.SIGTERM`` signal. Performs the - following step: - - * Raise a ``SystemExit`` exception explaining the signal. - - """ - exception = SystemExit( - "Terminating on signal {signal_number!r}".format( - signal_number=signal_number - ) - ) - raise exception - - def _get_exclude_file_descriptors(self): - """ Get the set of file descriptors to exclude closing. - - :return: A set containing the file descriptors for the - files to be preserved. - - The file descriptors to be preserved are those from the - items in `files_preserve`, and also each of `stdin`, - `stdout`, and `stderr`. For each item: - - * If the item is ``None``, it is omitted from the return - set. - - * If the item's ``fileno()`` method returns a value, that - value is in the return set. - - * Otherwise, the item is in the return set verbatim. - - """ - files_preserve = self.files_preserve - if files_preserve is None: - files_preserve = [] - files_preserve.extend( - item - for item in [self.stdin, self.stdout, self.stderr] - if hasattr(item, "fileno") - ) - - exclude_descriptors = set() - for item in files_preserve: - if item is None: - continue - file_descriptor = _get_file_descriptor(item) - if file_descriptor is not None: - exclude_descriptors.add(file_descriptor) - else: - exclude_descriptors.add(item) - - return exclude_descriptors - - def _make_signal_handler(self, target): - """ Make the signal handler for a specified target object. - - :param target: A specification of the target for the - handler; see below. - :return: The value for use by `signal.signal()`. - - If `target` is ``None``, return ``signal.SIG_IGN``. If `target` - is a text string, return the attribute of this instance named - by that string. Otherwise, return `target` itself. - - """ - if target is None: - result = signal.SIG_IGN - elif isinstance(target, basestring): - name = target - result = getattr(self, name) - else: - result = target - - return result - - def _make_signal_handler_map(self): - """ Make the map from signals to handlers for this instance. - - :return: The constructed signal map for this instance. - - Construct a map from signal numbers to handlers for this - context instance, suitable for passing to - `set_signal_handlers`. - - """ - signal_handler_map = dict( - (signal_number, self._make_signal_handler(target)) - for (signal_number, target) in self.signal_map.items() - ) - return signal_handler_map - - -def _get_file_descriptor(obj): - """ Get the file descriptor, if the object has one. - - :param obj: The object expected to be a file-like object. - :return: The file descriptor iff the file supports it; otherwise - ``None``. - - The object may be a non-file object. It may also be a - file-like object with no support for a file descriptor. In - either case, return ``None``. - - """ - file_descriptor = None - if hasattr(obj, "fileno"): - try: - file_descriptor = obj.fileno() - except ValueError: - # The item doesn't support a file descriptor. - pass - - return file_descriptor - - -def change_working_directory(directory): - """ Change the working directory of this process. - - :param directory: The target directory path. - :return: ``None``. - - """ - try: - os.chdir(directory) - except Exception as exc: - error = DaemonOSEnvironmentError( - "Unable to change working directory ({exc})".format(exc=exc) - ) - raise error - - -def change_root_directory(directory): - """ Change the root directory of this process. - - :param directory: The target directory path. - :return: ``None``. - - Set the current working directory, then the process root directory, - to the specified `directory`. Requires appropriate OS privileges - for this process. - - """ - try: - os.chdir(directory) - os.chroot(directory) - except Exception as exc: - error = DaemonOSEnvironmentError( - "Unable to change root directory ({exc})".format(exc=exc) - ) - raise error - - -def change_file_creation_mask(mask): - """ Change the file creation mask for this process. - - :param mask: The numeric file creation mask to set. - :return: ``None``. - - """ - try: - os.umask(mask) - except Exception as exc: - error = DaemonOSEnvironmentError( - "Unable to change file creation mask ({exc})".format(exc=exc) - ) - raise error - - -def change_process_owner(uid, gid): - """ Change the owning UID and GID of this process. - - :param uid: The target UID for the daemon process. - :param gid: The target GID for the daemon process. - :return: ``None``. - - Set the GID then the UID of the process (in that order, to avoid - permission errors) to the specified `gid` and `uid` values. - Requires appropriate OS privileges for this process. - - """ - try: - os.setgid(gid) - os.setuid(uid) - except Exception as exc: - error = DaemonOSEnvironmentError( - "Unable to change process owner ({exc})".format(exc=exc) - ) - raise error - - -def prevent_core_dump(): - """ Prevent this process from generating a core dump. - - :return: ``None``. - - Set the soft and hard limits for core dump size to zero. On Unix, - this entirely prevents the process from creating core dump. - - """ - core_resource = resource.RLIMIT_CORE - - try: - # Ensure the resource limit exists on this platform, by requesting - # its current value. - core_limit_prev = resource.getrlimit(core_resource) - except ValueError as exc: - error = DaemonOSEnvironmentError( - "System does not support RLIMIT_CORE resource limit" - " ({exc})".format(exc=exc) - ) - raise error - - # Set hard and soft limits to zero, i.e. no core dump at all. - core_limit = (0, 0) - resource.setrlimit(core_resource, core_limit) - - -def detach_process_context(): - """ Detach the process context from parent and session. - - :return: ``None``. - - Detach from the parent process and session group, allowing the - parent to exit while this process continues running. - - Reference: “Advanced Programming in the Unix Environment”, - section 13.3, by W. Richard Stevens, published 1993 by - Addison-Wesley. - - """ - - def fork_then_exit_parent(error_message): - """ Fork a child process, then exit the parent process. - - :param error_message: Message for the exception in case of a - detach failure. - :return: ``None``. - :raise DaemonProcessDetachError: If the fork fails. - - """ - try: - pid = os.fork() - if pid > 0: - os._exit(0) - except OSError as exc: - error = DaemonProcessDetachError( - "{message}: [{exc.errno:d}] {exc.strerror}".format( - message=error_message, exc=exc - ) - ) - raise error - - fork_then_exit_parent(error_message="Failed first fork") - os.setsid() - fork_then_exit_parent(error_message="Failed second fork") - - -def is_process_started_by_init(): - """ Determine whether the current process is started by `init`. - - :return: ``True`` iff the parent process is `init`; otherwise - ``False``. - - The `init` process is the one with process ID of 1. - - """ - result = False - - init_pid = 1 - if os.getppid() == init_pid: - result = True - - return result - - -def is_socket(fd): - """ Determine whether the file descriptor is a socket. - - :param fd: The file descriptor to interrogate. - :return: ``True`` iff the file descriptor is a socket; otherwise - ``False``. - - Query the socket type of `fd`. If there is no error, the file is a - socket. - - """ - result = False - - file_socket = socket.fromfd(fd, socket.AF_INET, socket.SOCK_RAW) - - try: - socket_type = file_socket.getsockopt(socket.SOL_SOCKET, socket.SO_TYPE) - except socket.error as exc: - exc_errno = exc.args[0] - if exc_errno == errno.ENOTSOCK: - # Socket operation on non-socket. - pass - else: - # Some other socket error. - result = True - else: - # No error getting socket type. - result = True - - return result - - -def is_process_started_by_superserver(): - """ Determine whether the current process is started by the superserver. - - :return: ``True`` if this process was started by the internet - superserver; otherwise ``False``. - - The internet superserver creates a network socket, and - attaches it to the standard streams of the child process. If - that is the case for this process, return ``True``, otherwise - ``False``. - - """ - result = False - - stdin_fd = sys.__stdin__.fileno() - if is_socket(stdin_fd): - result = True - - return result - - -def is_detach_process_context_required(): - """ Determine whether detaching the process context is required. - - :return: ``True`` iff the process is already detached; otherwise - ``False``. - - The process environment is interrogated for the following: - - * Process was started by `init`; or - - * Process was started by `inetd`. - - If any of the above are true, the process is deemed to be already - detached. - - """ - result = True - if is_process_started_by_init() or is_process_started_by_superserver(): - result = False - - return result - - -def close_file_descriptor_if_open(fd): - """ Close a file descriptor if already open. - - :param fd: The file descriptor to close. - :return: ``None``. - - Close the file descriptor `fd`, suppressing an error in the - case the file was not open. - - """ - try: - os.close(fd) - except EnvironmentError as exc: - if exc.errno == errno.EBADF: - # File descriptor was not open. - pass - else: - error = DaemonOSEnvironmentError( - "Failed to close file descriptor {fd:d} ({exc})".format(fd=fd, exc=exc) - ) - raise error - - -MAXFD = 2048 - - -def get_maximum_file_descriptors(): - """ Get the maximum number of open file descriptors for this process. - - :return: The number (integer) to use as the maximum number of open - files for this process. - - The maximum is the process hard resource limit of maximum number of - open file descriptors. If the limit is “infinity”, a default value - of ``MAXFD`` is returned. - - """ - limits = resource.getrlimit(resource.RLIMIT_NOFILE) - result = limits[1] - if result == resource.RLIM_INFINITY: - result = MAXFD - return result - - -def close_all_open_files(exclude=set()): - """ Close all open file descriptors. - - :param exclude: Collection of file descriptors to skip when closing - files. - :return: ``None``. - - Closes every file descriptor (if open) of this process. If - specified, `exclude` is a set of file descriptors to *not* - close. - - """ - maxfd = get_maximum_file_descriptors() - for fd in reversed(range(maxfd)): - if fd not in exclude: - close_file_descriptor_if_open(fd) - - -def redirect_stream(system_stream, target_stream): - """ Redirect a system stream to a specified file. - - :param standard_stream: A file object representing a standard I/O - stream. - :param target_stream: The target file object for the redirected - stream, or ``None`` to specify the null device. - :return: ``None``. - - `system_stream` is a standard system stream such as - ``sys.stdout``. `target_stream` is an open file object that - should replace the corresponding system stream object. - - If `target_stream` is ``None``, defaults to opening the - operating system's null device and using its file descriptor. - - """ - if target_stream is None: - target_fd = os.open(os.devnull, os.O_RDWR) - else: - target_fd = target_stream.fileno() - os.dup2(target_fd, system_stream.fileno()) - - -def make_default_signal_map(): - """ Make the default signal map for this system. - - :return: A mapping from signal number to handler object. - - The signals available differ by system. The map will not contain - any signals not defined on the running system. - - """ - name_map = { - "SIGTSTP": None, - "SIGTTIN": None, - "SIGTTOU": None, - "SIGTERM": "terminate", - } - signal_map = dict( - (getattr(signal, name), target) - for (name, target) in name_map.items() - if hasattr(signal, name) - ) - - return signal_map - - -def set_signal_handlers(signal_handler_map): - """ Set the signal handlers as specified. - - :param signal_handler_map: A map from signal number to handler - object. - :return: ``None``. - - See the `signal` module for details on signal numbers and signal - handlers. - - """ - for (signal_number, handler) in signal_handler_map.items(): - signal.signal(signal_number, handler) - - -def register_atexit_function(func): - """ Register a function for processing at program exit. - - :param func: A callable function expecting no arguments. - :return: ``None``. - - The function `func` is registered for a call with no arguments - at program exit. - - """ - atexit.register(func) - - -def _chain_exception_from_existing_exception_context(exc, as_cause=False): - """ Decorate the specified exception with the existing exception context. - - :param exc: The exception instance to decorate. - :param as_cause: If true, the existing context is declared to be - the cause of the exception. - :return: ``None``. - - :PEP:`344` describes syntax and attributes (`__traceback__`, - `__context__`, `__cause__`) for use in exception chaining. - - Python 2 does not have that syntax, so this function decorates - the exception with values from the current exception context. - - """ - (existing_exc_type, existing_exc, existing_traceback) = sys.exc_info() - if as_cause: - exc.__cause__ = existing_exc - else: - exc.__context__ = existing_exc - exc.__traceback__ = existing_traceback - - -# Local variables: -# coding: utf-8 -# mode: python -# End: -# vim: fileencoding=utf-8 filetype=python : diff --git a/daemon/pidfile.py b/daemon/pidfile.py deleted file mode 100644 index 781bea9..0000000 --- a/daemon/pidfile.py +++ /dev/null @@ -1,67 +0,0 @@ -# -*- coding: utf-8 -*- - -# daemon/pidfile.py -# Part of ‘python-daemon’, an implementation of PEP 3143. -# -# Copyright © 2008–2015 Ben Finney -# -# This is free software: you may copy, modify, and/or distribute this work -# under the terms of the Apache License, version 2.0 as published by the -# Apache Software Foundation. -# No warranty expressed or implied. See the file ‘LICENSE.ASF-2’ for details. - -""" Lockfile behaviour implemented via Unix PID files. - """ - -from __future__ import absolute_import, unicode_literals - -from lockfile.pidlockfile import PIDLockFile - - -class TimeoutPIDLockFile(PIDLockFile, object): - """ Lockfile with default timeout, implemented as a Unix PID file. - - This uses the ``PIDLockFile`` implementation, with the - following changes: - - * The `acquire_timeout` parameter to the initialiser will be - used as the default `timeout` parameter for the `acquire` - method. - - """ - - def __init__(self, path, acquire_timeout=None, *args, **kwargs): - """ Set up the parameters of a TimeoutPIDLockFile. - - :param path: Filesystem path to the PID file. - :param acquire_timeout: Value to use by default for the - `acquire` call. - :return: ``None``. - - """ - self.acquire_timeout = acquire_timeout - super(TimeoutPIDLockFile, self).__init__(path, *args, **kwargs) - - def acquire(self, timeout=None, *args, **kwargs): - """ Acquire the lock. - - :param timeout: Specifies the timeout; see below for valid - values. - :return: ``None``. - - The `timeout` defaults to the value set during - initialisation with the `acquire_timeout` parameter. It is - passed to `PIDLockFile.acquire`; see that method for - details. - - """ - if timeout is None: - timeout = self.acquire_timeout - super(TimeoutPIDLockFile, self).acquire(timeout, *args, **kwargs) - - -# Local variables: -# coding: utf-8 -# mode: python -# End: -# vim: fileencoding=utf-8 filetype=python : diff --git a/daemon/runner.py b/daemon/runner.py deleted file mode 100644 index 0733b52..0000000 --- a/daemon/runner.py +++ /dev/null @@ -1,322 +0,0 @@ -# -*- coding: utf-8 -*- - -# daemon/runner.py -# Part of ‘python-daemon’, an implementation of PEP 3143. -# -# Copyright © 2009–2015 Ben Finney -# Copyright © 2007–2008 Robert Niederreiter, Jens Klein -# Copyright © 2003 Clark Evans -# Copyright © 2002 Noah Spurrier -# Copyright © 2001 Jürgen Hermann -# -# This is free software: you may copy, modify, and/or distribute this work -# under the terms of the Apache License, version 2.0 as published by the -# Apache Software Foundation. -# No warranty expressed or implied. See the file ‘LICENSE.ASF-2’ for details. - -""" Daemon runner library. - """ - -from __future__ import absolute_import, unicode_literals - -import sys -import os -import signal -import errno - -try: - # Python 3 standard library. - ProcessLookupError -except NameError: - # No such class in Python 2. - ProcessLookupError = NotImplemented - -import lockfile - -from . import pidfile -from .daemon import basestring, unicode -from .daemon import DaemonContext -from .daemon import _chain_exception_from_existing_exception_context - - -class DaemonRunnerError(Exception): - """ Abstract base class for errors from DaemonRunner. """ - - def __init__(self, *args, **kwargs): - self._chain_from_context() - - super(DaemonRunnerError, self).__init__(*args, **kwargs) - - def _chain_from_context(self): - _chain_exception_from_existing_exception_context(self, as_cause=True) - - -class DaemonRunnerInvalidActionError(DaemonRunnerError, ValueError): - """ Raised when specified action for DaemonRunner is invalid. """ - - def _chain_from_context(self): - # This exception is normally not caused by another. - _chain_exception_from_existing_exception_context(self, as_cause=False) - - -class DaemonRunnerStartFailureError(DaemonRunnerError, RuntimeError): - """ Raised when failure starting DaemonRunner. """ - - -class DaemonRunnerStopFailureError(DaemonRunnerError, RuntimeError): - """ Raised when failure stopping DaemonRunner. """ - - -class DaemonRunner: - """ Controller for a callable running in a separate background process. - - The first command-line argument is the action to take: - - * 'start': Become a daemon and call `app.run()`. - * 'stop': Exit the daemon process specified in the PID file. - * 'restart': Stop, then start. - - """ - - __metaclass__ = type - - start_message = "started with pid {pid:d}" - - def __init__(self, app): - """ Set up the parameters of a new runner. - - :param app: The application instance; see below. - :return: ``None``. - - The `app` argument must have the following attributes: - - * `stdin_path`, `stdout_path`, `stderr_path`: Filesystem paths - to open and replace the existing `sys.stdin`, `sys.stdout`, - `sys.stderr`. - - * `pidfile_path`: Absolute filesystem path to a file that will - be used as the PID file for the daemon. If ``None``, no PID - file will be used. - - * `pidfile_timeout`: Used as the default acquisition timeout - value supplied to the runner's PID lock file. - - * `run`: Callable that will be invoked when the daemon is - started. - - """ - self.parse_args() - self.app = app - self.daemon_context = DaemonContext() - self.daemon_context.stdin = open(app.stdin_path, "rt") - self.daemon_context.stdout = open(app.stdout_path, "w+t") - self.daemon_context.stderr = open(app.stderr_path, "w+t", buffering=0) - - self.pidfile = None - if app.pidfile_path is not None: - self.pidfile = make_pidlockfile(app.pidfile_path, app.pidfile_timeout) - self.daemon_context.pidfile = self.pidfile - - def _usage_exit(self, argv): - """ Emit a usage message, then exit. - - :param argv: The command-line arguments used to invoke the - program, as a sequence of strings. - :return: ``None``. - - """ - progname = os.path.basename(argv[0]) - usage_exit_code = 2 - action_usage = "|".join(self.action_funcs.keys()) - message = "usage: {progname} {usage}".format( - progname=progname, usage=action_usage - ) - emit_message(message) - sys.exit(usage_exit_code) - - def parse_args(self, argv=None): - """ Parse command-line arguments. - - :param argv: The command-line arguments used to invoke the - program, as a sequence of strings. - - :return: ``None``. - - The parser expects the first argument as the program name, the - second argument as the action to perform. - - If the parser fails to parse the arguments, emit a usage - message and exit the program. - - """ - if argv is None: - argv = sys.argv - - min_args = 2 - if len(argv) < min_args: - self._usage_exit(argv) - - self.action = unicode(argv[1]) - if self.action not in self.action_funcs: - self._usage_exit(argv) - - def _start(self): - """ Open the daemon context and run the application. - - :return: ``None``. - :raises DaemonRunnerStartFailureError: If the PID file cannot - be locked by this process. - - """ - if is_pidfile_stale(self.pidfile): - self.pidfile.break_lock() - - try: - self.daemon_context.open() - except lockfile.AlreadyLocked: - error = DaemonRunnerStartFailureError( - "PID file {pidfile.path!r} already locked".format(pidfile=self.pidfile) - ) - raise error - - pid = os.getpid() - message = self.start_message.format(pid=pid) - emit_message(message) - - self.app.run() - - def _terminate_daemon_process(self): - """ Terminate the daemon process specified in the current PID file. - - :return: ``None``. - :raises DaemonRunnerStopFailureError: If terminating the daemon - fails with an OS error. - - """ - pid = self.pidfile.read_pid() - try: - os.kill(pid, signal.SIGTERM) - except OSError as exc: - error = DaemonRunnerStopFailureError( - "Failed to terminate {pid:d}: {exc}".format(pid=pid, exc=exc) - ) - raise error - - def _stop(self): - """ Exit the daemon process specified in the current PID file. - - :return: ``None``. - :raises DaemonRunnerStopFailureError: If the PID file is not - already locked. - - """ - if not self.pidfile.is_locked(): - error = DaemonRunnerStopFailureError( - "PID file {pidfile.path!r} not locked".format(pidfile=self.pidfile) - ) - raise error - - if is_pidfile_stale(self.pidfile): - self.pidfile.break_lock() - else: - self._terminate_daemon_process() - - def _restart(self): - """ Stop, then start. - """ - self._stop() - self._start() - - action_funcs = { - "start": _start, - "stop": _stop, - "restart": _restart, - } - - def _get_action_func(self): - """ Get the function for the specified action. - - :return: The function object corresponding to the specified - action. - :raises DaemonRunnerInvalidActionError: if the action is - unknown. - - The action is specified by the `action` attribute, which is set - during `parse_args`. - - """ - try: - func = self.action_funcs[self.action] - except KeyError: - error = DaemonRunnerInvalidActionError( - "Unknown action: {action!r}".format(action=self.action) - ) - raise error - return func - - def do_action(self): - """ Perform the requested action. - - :return: ``None``. - - The action is specified by the `action` attribute, which is set - during `parse_args`. - - """ - func = self._get_action_func() - func(self) - - -def emit_message(message, stream=None): - """ Emit a message to the specified stream (default `sys.stderr`). """ - if stream is None: - stream = sys.stderr - stream.write("{message}\n".format(message=message)) - stream.flush() - - -def make_pidlockfile(path, acquire_timeout): - """ Make a PIDLockFile instance with the given filesystem path. """ - if not isinstance(path, basestring): - error = ValueError("Not a filesystem path: {path!r}".format(path=path)) - raise error - if not os.path.isabs(path): - error = ValueError("Not an absolute path: {path!r}".format(path=path)) - raise error - lockfile = pidfile.TimeoutPIDLockFile(path, acquire_timeout) - - return lockfile - - -def is_pidfile_stale(pidfile): - """ Determine whether a PID file is stale. - - :return: ``True`` iff the PID file is stale; otherwise ``False``. - - The PID file is “stale” if its contents are valid but do not - match the PID of a currently-running process. - - """ - result = False - - pidfile_pid = pidfile.read_pid() - if pidfile_pid is not None: - try: - os.kill(pidfile_pid, signal.SIG_DFL) - except ProcessLookupError: - # The specified PID does not exist. - result = True - except OSError as exc: - if exc.errno == errno.ESRCH: - # Under Python 2, process lookup error is an OSError. - # The specified PID does not exist. - result = True - - return result - - -# Local variables: -# coding: utf-8 -# mode: python -# End: -# vim: fileencoding=utf-8 filetype=python : diff --git a/dist/heartbeat-0.1.0-py3-none-any.whl b/dist/heartbeat-0.1.0-py3-none-any.whl new file mode 100644 index 0000000000000000000000000000000000000000..9187da94f990bbd529fcd03cd89d1001018f81ee GIT binary patch literal 51346 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 $EHqB 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 Dinf6h literal 0 HcmV?d00001 diff --git a/hbd b/hbd deleted file mode 100755 index 7c6fdac..0000000 --- a/hbd +++ /dev/null @@ -1,1342 +0,0 @@ -#!/usr/bin/env python3 -# $Id: hbd,v 1.38 2013/07/14 02:25:05 andreas Exp $ -# Wait for heartbeat messages and act on them (or their absence) -# -import time -import os -import sys -import socket -import ssl -import pathlib -import atexit -import select -import socketserver -import http.server -import getopt -import signal -import pickle -import smtplib -import traceback -import urllib.request -import urllib.error -import urllib.parse -import http.client -import threading -import subprocess -from hashlib import md5 -import json -import zlib -import codecs - -import asyncio -import websockets - -from subprocess import Popen, STDOUT, PIPE - -# from hbdclass import * -import hbdclass - -VER = 4.4 - -CERT_PATH = "/usr/local/etc/letsencrypt/live/hbd.wrede.ca/" -# CERT_PATH = "./test/" -WSS_PEM = CERT_PATH + "fullchain.pem" -WSS_KEY = CERT_PATH + "privkey.pem" - -NSUPDATE_BIN = "/usr/local/bin/nsupdate" # override in .hbrc possible - -SEND_EMAIL = False -SEND_PUSHOVER = True - -DEBUG = 0 -hbdclass.DEBUG = DEBUG - -MAXRECV = 32767 -LOGFILE = "/home/andreas/public_html/messages/andreas" -PICKFILE = "/var/tmp/hbd.pick" -AEMAIL = ["andreas@wrede.ca"] -NAME = "heatbeat" -SMTPSERVER = "localhost" - -msgs = [] - -# AEW upcount = 0 -PORT = 50003 -TPORT = 50004 -THOST = "" -WSPORT = 50005 -WSSPORT = 50006 - -verbose = False - -INTERVAL = 10 -GRACE = 2 -DROPOVERDUE = 7 * 24 * 3600 - -os.environ["TZ"] = "EST5EDT" - -tsfm = ["%H", "%d", "%U"] -lastfm = ["", "", ""] - - -# tcss = """ -tcss = """ - """ - - -def handler(signum, frame): - global running, sig - sig = signum - if not running: - if verbose: - sys.stderr.write("NOT runing signal: %s running: %d" % (sig, running)) - sys.exit(2) - if verbose: - sys.stderr.write("signal: %s running: %s frame: %s" % (sig, running, frame)) - - -def shortname(name): - r = name.split(".") - return r[0] - - -class NullDevice: - def write(self, s): - pass - - -class LogDevice: - def __init__(self): - self.fh = open("/tmp/log1", "a") - - def write(self, s): - self.fh.write(s) - self.fh.flush() - - -def dicttos(ID, d, compress=False): - s = [] - for k in d: - if isinstance(d[k], float): - s.append("%s=%0.5f" % (k, d[k])) - else: - s.append("%s=%s" % (k, d[k])) - pk = ";".join(s) - if compress: - zpk = zlib.compress(pk.encode(), 6) - ID = "!" + ID + ":" - opk = ID.encode() + zpk - else: - zpk = pk - opk = ID + ":" + zpk - return opk - - -def stodict(msg): - d = {} - if len(msg) > 0 and chr(msg[0]) == "!": - pk = zlib.decompress(msg[5:]).decode() - d["ID"] = msg[1:4].decode() - else: - r0 = msg.split(":", 1) - pk = r0[1] - d["ID"] = r0[0] - r = pk.split(";") - for v in r: - vr = v.split("=", 1) - k = vr[0].strip() - if len(vr) == 1: - d[k] = None - else: - v = vr[1].strip() - if v[0].isdigit(): - v = eval(v) - d[k] = v - return d - - -def oldmtodict(msg): - return stodict("HTB:" + msg) - - -def email(s, msg): - if not SEND_EMAIL: - return - ret = "OK" - toaddrs = AEMAIL - fromemail = "aew.heartbeat@wrede.ca" - subj = "Info from %s: %s" % (NAME, s) - date = time.strftime("%a, %d %b %Y %H:%M:%S %z", time.localtime()) - body = "To: %s\nFrom: %s\nSubject: %s\nDate: %s\n\n%s" % ( - toaddrs[0], - fromemail, - subj, - date, - msg, - ) - try: - server = smtplib.SMTP(SMTPSERVER) - if DEBUG > 0: - server.set_debuglevel(1) - server.sendmail(fromemail, toaddrs, body) - except smtplib.SMTPRecipientsRefused as errs: - log(None, "cannot send email: %s\n" % (errs)) - ret = "Fail" - except Exception as e: - print(f"smtp error: {e}") - ret = "Fail" - saveandrestart() - try: - server.quit() - except: - pass - return ret - - -def pushmsg(msg): - if pushsrv in ["all", "pushover"]: - pushover(msg) - if pushsrv in ["all", "mattermost"]: - pushmattermost(msg) - if pushsrv in ["all", "signal"]: - pushsignal(msg) - if pushsrv in ["all"]: - print("notice:", msg) - - -def pushover(msg): - if not SEND_PUSHOVER: - return - conn = http.client.HTTPSConnection("api.pushover.net:443") - try: - conn.request( - "POST", - "/1/messages.json", - urllib.parse.urlencode( - { - "token": "ac7NLX2rPjXFareeDgLpXNoDf4iFmf", - "user": "uDhH33UjQQDYtNzJb1ThRiWb9ingGK", - "message": msg, - } - ), - {"Content-type": "application/x-www-form-urlencoded"}, - ) - conn.getresponse() - except: - pass - - -CHANNEL = "Monitoring" -TOKEN = "rxz6b3886iygxnhbzpmgbsrocy" -HOST = "192.168.10.101" -ICON = "https://in-transit.ca/HeartBeat.png" -USERNAME = "admin" - - -def pushmattermost(msg): - - ses = { - "url": HOST, - "scheme": "http", - "basepath": "/api/v4", - "port": 8065, - } - mm = Driver(ses) - - msg = {"text": msg, "channel": CHANNEL, "username": USERNAME, "icon_url": ICON} - - try: - rc = mm.webhooks.call_webhook(TOKEN, msg) - except Exception as e: - rc = str(e) - if not rc: - print(rc) - - -USER = "+16472472447" -RECIPIENT = "+14168226179" - - -def pushsignal(msg, title="hbd", recipient=RECIPIENT): - - message = f"{title}: {msg}" - CLI = [ - "/usr/local/bin/sudo", - "-u", - "andreas", - "/usr/local/bin/signal-cli", - "-u", - USER, - "send", - "-m", - message, - # "-g", GROUP, - recipient, - ] - - if verbose: - print(f"DBG cli: {CLI}") - res = subprocess.run(CLI, shell=False, capture_output=True) - rc = res.returncode == 0 - print(res.stdout.decode()) - - if not rc: - print(f"signalcli failed: {res.stderr.decode()}") - else: - if verbose: - print(f"signalcli msg sent, res {res.stdout.decode()}") - return rc - - -# nsupdate: set the DNS A record for a fqdn -# return: None if ok, else error text -def nsupdate(hostname, newip, dyndomain): - D = {} - D["domain"] = dyndomain - D["fqdn"] = "%s.dy.%s" % (hostname, dyndomain) - D["dnsttl"] = "5" - D["newip"] = newip - D["ts"] = time.strftime("%Y-%m-%d.%H:%M:%S", time.gmtime()) - if newip.find(":") > 0: - nsup = ( - """update delete %(fqdn)s AAAA -update add %(fqdn)s %(dnsttl)s AAAA %(newip)s -update delete %(fqdn)s TXT -update add %(fqdn)s %(dnsttl)s TXT "Created: %(ts)s" -send -answer - -""" - % D - ) - else: - nsup = ( - """update delete %(fqdn)s A -update add %(fqdn)s %(dnsttl)s A %(newip)s -update delete %(fqdn)s TXT -update add %(fqdn)s %(dnsttl)s TXT "Created: %(ts)s" -send -answer - -""" - % D - ) - - if DEBUG > 0: - log(None, "DBG: nsup %s" % nsup) -# cmd = [nsupdate_bin, "-k", "/etc/dhcpc/Kdy.%(domain)s.+157+00000." % D, "-v"] - cmd = [nsupdate_bin, "-k", "/etc/dhcpc/rndc-key", "-v"] - if DEBUG > 0: - log(None, "DBG: cmd %s" % cmd) - try: - p = Popen(cmd, shell=False, bufsize=0, stdin=PIPE, stdout=PIPE, stderr=STDOUT) - except OSError as e: - return "nsupdate: execution failed: %s" % e - except: - return "nsupdate: some error occured" - - (output, err) = p.communicate(nsup.encode()) - if output.decode().find("status: NOERROR") >= 0: - return None - if not err is None: - ex = err.decode() - else: - ex = "noerr" - - return output.decode() + ex - - -# -def dur(sec): - sec = int(sec) - h = int(sec / 3600) - m = int((sec - h * 3600) / 60) - s = int((sec - h * 3600) % 60) - if h > 0: - return "%d:%02d:%02d" % (h, m, s) - if m > 0: - return "%d:%02d" % (m, s) - return "0:%02d" % s - - -def fixsort(): - s = list(hbdclass.Host.hosts.keys()) - s.sort() - x = 0 - for n in s: - hbdclass.Host.hosts[n].num = x - x += 1 - - -# -def on_exit(): - if DEBUG > 0: - sys.stderr.write("on_exit\n") - try: - logf.close() - except: - pass - print("exit") - - -def initlog(logfile): - try: - return open(logfile, "a+") - except: - pass - try: - return open(logfile, "w") - except Exception as e: - print("cannot open loffile %s, using STDERR: %s" % (logfile, e)) - return sys.stderr - - -# -# -def checkoverdue(): - now = time.time() - for h in list(hbdclass.Host.hosts.keys()): - pmsg = [] - for c in hbdclass.Host.hosts[h].connections: - conn = hbdclass.Host.hosts[h].connections[c] - if conn.state == hbdclass.Connection.DOWN: - continue - timeout = hbdclass.Host.hosts[h].interval + grace - if conn.state == hbdclass.Connection.UP and (now - conn.lastbeat) > timeout: - conn.newstate(hbdclass.Connection.OVERDUE, now, grace) - pmsg.append(conn.afam) - if ( - conn.state == hbdclass.Connection.OVERDUE and (now - conn.lastbeat) > DROPOVERDUE - ): - conn.newstate(hbdclass.Connection.UNKNOWN, conn.lastbeat) - if pmsg != []: - if h in watchhosts: - email("overdue", "%s overdue" % " and ".join(pmsg)) - pushmsg("%s %s overdue" % (h, " and ".join(pmsg))) - log(h, "%s overdue" % " and ".join(pmsg)) - msg_to_websockets("host", hbdclass.Host.hosts[h].stateinfo()) - - -def log(host, m, service=None): - if DEBUG > 0: - print("Log: %s %s" % (host, m)) - now = time.time() - ts = time.strftime("%b %d %H:%M:%S", time.localtime(now)) - if service: - srv = "service %s: " % service - else: - srv = "" - if host: - hst = "%s " % host - else: - hst = "" - - msg = "%s: %s%s%s" % (ts, hst, srv, m) - msgs.append(msg + "\n") - msg_to_websockets("message", msg) - - if logfmt == "msg": - m2 = "%d|%s|%s\n" % (now, hst, m) - else: - m2 = msg + "\n" - logf.write(m2) - logf.flush() - pickleit() - - -def dnsupdatethread(): - while True: - name, addr = hbdclass.Host.dnsQ.get() - m = "changed address to %s" % (addr) - for dyndomain in dyndomains: - err = nsupdate(name, addr, dyndomain) - if err: - m += ", DNS update failed: %s" % err - email("error: nsupdate failed", "%s.dy.%s: %s" % (name, dyndomain, m)) - else: - m += ", DNS updated." - hbdclass.Host.dnsQ.task_done() - log(name, m) - - -# -# -# -# -def readsock(sock): - global now - if DEBUG > 3: - sys.stderr.write("readsock recfrom start") - now = time.time() - data, addrp = sock.recvfrom(MAXRECV) - if DEBUG > 3: - sys.stderr.write("readsock = %s, %s\n" % (data, addrp)) - try: - msg = stodict(data) - except: - return - if DEBUG > 3: - sys.stderr.write("msg is %s" % str(msg)) - if not msg: # Old hbc client - if verbose: - print(("old hbc:", data)) - msg = oldmtodict(data) - if DEBUG > 2: - print(("readsock = %s, %s" % (msg, addrp))) - - addr = addrp[0:2] - name = shortname(msg.get("name", "unknown")) - if name not in hbdclass.Host.hosts: # was: hosts.has_key(name): - host = hbdclass.Host(name) - host.dyn = name in dyndnshosts - if verbose: - print(("XX: New host, num now %s" % (len(hbdclass.Host.hosts)))) - newh = True - else: - host = hbdclass.Host.hosts[name] - newh = False - - cid = msg.get("id", 0) - try: - rtt = float(msg.get("rtt", None)) - except: - rtt = None - - if msg["ID"] == "HTB": - host.doesack = msg.get("acks", -1) - host.setcver(msg.get("ver", 0)) - - interval = int(msg.get("interval", 0)) - shutdown = msg.get("shutdown", 0) - service = msg.get("service", "unknown") - message = msg.get("msg", None) - boot = msg.get("boot", 0) - - conn, res = host.conndata(cid, addr[0], rtt, now) - if res: - log(name, res) - if name in watchhosts: - email("address change", "%s %s" % (host.name, res)) - pushmsg("%s %s" % (host.name, res)) - - if boot: - log(name, "booted") - if name in watchhosts: - m = "%s booted" % (host.name) - email("booted", m) - pushmsg(m) - if message: - log(name, "msg: %s" % message, service=service) - if name in watchhosts: - email("msg", message) - pushmsg(message) - - if conn.getstate() != hbdclass.Connection.UP: # XXX and interval > 0: - lasts = conn.state - d = conn.newstate(hbdclass.Connection.UP, now) - m = "%s back after being %s for %s" % (conn.afam, lasts, dur(d)) - log(name, m) - if name in watchhosts: - email("%s back" % conn.afam, name) - pushmsg("%s %s is back" % (name, conn.afam)) - - if boot or newh: - host.upcount = host.doesack - else: - host.upcount += 1 - - if shutdown: - log(name, "%s shutdown" % conn.afam) - if name in watchhosts: - email("shutdown", "%s %s shutdown" % (name, conn.afam)) - pushmsg("%s %s shutdown" % (name, conn.afam)) - conn.newstate(hbdclass.Connection.DOWN, now) - - if interval > 0: - host.interval = interval - - rmsg = {"time": time.time()} - op = "ACK" - if host.cver < 1: - opkt = "ACK" - rmsg = "ACK" - else: - opkt = dicttos("ACK", rmsg, host.cver > 1) # clients w/ ver 2+ can cope - try: - ss = sock.sendto(opkt, addr) - except: - pass # XXX return pkg failes - if DEBUG > 2: - print(("sendto1: %s (%s) %s %s" % (addr, len(opkt), op, str(rmsg)[:50]))) - - # send any commands we have queued - while len(host.cmds): - op, rmsg = host.cmds[0] - if op == "CMD": - email("%s cmd exec" % name, "command '%s' sent" % rmsg) - del host.cmds[0] - log(name, "command sent") - if host.cver < 1: - rmsg = rmsg["cmd"] - elif op == "UPD": - del host.cmds[0] - log(name, "update initiated") - if host.cver < 1: - log(name, " ver 0 does not support UPD") - continue - if host.cver < 1: - opkt = rmsg - op = "" - else: - opkt = dicttos(op, rmsg, True) - try: - ss = sock.sendto(opkt, addr) - except Exception as e: - print(("opkt len is %s" % len(opkt))) - print(("cannot send: %s" % e)) - - if verbose: - print(("sendto2: %s (%s) %s %s" % (addr, len(opkt), op, str(rmsg)[:50]))) - if DEBUG > 2: - print(("msg from %s,%s, sent %s bytes back" % (addr[0], addr[1], ss))) - - msg_to_websockets("host", host.stateinfo()) - - -def updatecode(ucode, uname): - - fail = None - try: - fh = open(ucode, "r") - new_code = fh.read() - fh.close() - except Exception as e: - fail = "cannot read new code: %s" % e - if not fail: - m = md5() - new_codeE = new_code.encode() - m.update(new_codeE) - icsum = m.hexdigest() - rmsg = {"csum": icsum, "code": codecs.encode(new_codeE, "base64")} - hbdclass.Host.hosts[uname].cmds.append(("UPD", rmsg)) - return fail - - -# -# Web Server -# -class HttpServer(socketserver.ThreadingMixIn, http.server.HTTPServer): - allow_reuse_address = True - - def threaded(self): - pass - - -# -# -class HttpHandler(http.server.BaseHTTPRequestHandler): - - server_version = "HeartbeatHTTP/%s" % VER - - def version_string(self): - return self.server_version - - def handle(self): - # return http.server.BaseHTTPRequestHandler.handle(self) - try: - return http.server.BaseHTTPRequestHandler.handle(self) - except Exception as e: - self.log_error("Request went away: %r", e) - self.close_connection = 1 - return - - def do_HEAD(self): - self.setheaders(200) - - def setheaders(self, code, headerdict={}): - self.send_response(code) - self.send_header( - "Last-Modified", - time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime(now)), - ) - # self.send_header("Accept-Ranges","bytes") - # self.send_header("hbdclass.Connection","close") - for h in headerdict: - self.send_header(h, headerdict[h]) - self.end_headers() - - def buildhead(self, title="Heartbeat", refresh=None, extras=None): - res = [] - res.append('') - res.append("") - res.append("") - res.append("%s" % (title)) - if refresh: - res.append("\n" % refresh) - if extras: - res.append(extras) - res.append("") - res.append('') - return res - - def buildpage(self): - res = self.buildhead(refresh=60, extras=tcss) - res.append("

Heartbeat status %s

" % VER) - res += hbdclass.ubHost.buildhosttable() - res += hbdclass.ubHost.buildmsgtable(msgs) - res.append( - "

%s (%s)

" - % ( - time.strftime("%H:%M:%S", time.localtime(now)), - os.environ.get("TZ", "CET-1CDT"), - ) - ) - res.append("") - return res - - def builderror(self, code, cause, lcause): - res = [] - res.append('') - res.append("") - res.append("%s %s" % (code, cause)) - res.append("") - res.append("

%s

" % (cause)) - res.append("

%s

" % lcause) - res.append("
") - res.append( - "
hbd (Unix) Server at %s:%s
" % (hbd_host, hbd_port) - ) - res.append("") - return code, res - - def do_GET(self): - global sig - code = 200 - xsig = 0 - rqAcceptEncoding = self.headers.get("Accept-encoding", {}) - headerdict = {"Content-Type": "text/html; charset = ISO-8859-1"} - if DEBUG > 2: - sys.stderr.write("handle\n") - qr = urllib.parse.urlparse(self.path) - qa = urllib.parse.parse_qs(qr.query) - - if DEBUG > 2: - sys.stderr.write("handle = %s\n" % (qr.geturl())) - if qr.path == "/": - res = self.buildpage() - - elif qr.path == "/c": # command on host /c?h=melschserver&c=sudo%20ls - uname = qa.get("h", [None])[0] - ucmd = qa.get("c", [None])[0] - if not ucmd or not uname: - code, res = self.builderror( - 400, "Argument error", "need h= and c= arguments" - ) - elif uname not in hbdclass.Host.hosts: - code, res = self.builderror(400, "Data error", "h=%s not found" % uname) - else: - hbdclass.Host.hosts[uname].cmds.append( - ("CMD", {"cmd": urllib.parse.unquote(ucmd)}) - ) - res = self.buildhead() - res.append("cmd %s queued for host %s" % (uname, ucmd)) - - elif qr.path == "/d": # drop host /d?h=melschserver - uname = qa.get("h", [None])[0] - if not uname: - code, res = self.builderror(400, "Argument error", "need h= argument") - if uname not in hbdclass.Host.hosts: - code, res = self.builderror(400, "Data error", "h=%s not found" % uname) - else: - log(uname, "dropped") - # for addr in hbdclass.Host.hosts[uname].0i - # TODO: send message to websocket about dropped host - del hbdclass.Host.hosts[uname] - res = self.buildhead() - res.append("Done") - - elif qr.path == "/n": # register name - uname = qa.get("h", [None])[0] - if not uname: - code, res = self.builderror(400, "Argument error", "need h= argument") - if uname not in hbdclass.Host.hosts: - code, res = self.builderror(400, "Data error", "h=%s not found" % uname) - else: - ll = hbdclass.Host.hosts[uname].registerDns() - res.append(ll) - log(uname, ll) - - elif qr.path == "/u": # update - uname = urllib.parse.unquote(qa.get("h", [None])[0]) - ucode = qa.get("c", [None])[0] - if not ucode or not uname: - code, res = self.builderror( - 400, "Argument error", "need h= and c= arguments" - ) - elif uname != "All" and uname not in hbdclass.Host.hosts: - code, res = self.builderror(400, "Data error", "h=%s not found" % uname) - else: - res = self.buildhead() - if uname != "All": - names = [uname] - else: - names = [] - for n in hbdclass.Host.hosts: - if ( - hbdclass.Host.hosts[n].cver >= 2 - ): # earliest version that supports update - names.append(n) - for n in names: - err = updatecode(ucode, n) - res.append( - "update started for %s: %s
" % (n, err if err else "OK") - ) - res.append("Done") - - elif qr.path == "/api/0/hosts": # api access to host table - headerdict = {"Content-Type": "application/json; charset=utf-8"} - lst = [] - for h in hbdclass.Host.hosts: - lst.append(hbdclass.Host.hosts[h].jsons()) - res = ["[" + ",".join(lst) + "]"] - - elif qr.path == "/api/0/messages": # api access to host table - headerdict = {"Content-Type": "application/json; charset=utf-8"} - lst = msgs[len(msgs) - 30:] - res = [json.dumps(lst)] - - elif qr.path == "/r": # restart - res = self.buildhead() - res.append("restart request") - xsig = signal.SIGHUP - log(None, "restart request") - - else: - code, res = self.builderror( - 404, "Not Found", "requested URL was not found on this server." - ) - - if "deflate" in rqAcceptEncoding: - headerdict["Content-Encoding"] = "deflate" - towrite = zlib.compress("\n".join(res).encode(), 6) - else: - towrite = "\n".join(res) - headerdict["Content-Length"] = len(towrite) - headerdict["Cache-Control"] = "private, must-revalidate, max-age=0" - headerdict["Expires"] = "Thu, 01 Jan 1970 00:00:00 GMT" - self.setheaders(code, headerdict) - self.wfile.write(towrite) - - if xsig: - sig = xsig - - -def setrunning(new): - global running - if DEBUG > 0: - sys.stderr.write("running is now = %s\n" % (new)) - running = new - - -def closeup(): - setrunning(False) - try: - sock.close() - except: - pass - try: - sock6.close() - except: - pass - - if DEBUG > 0: - sys.stderr.write("asking http server to stop\n") - try: - serv.shutdown() - if DEBUG > 0: - sys.stderr.write("http server stopped\n") - except Exception as e: - if DEBUG > 0: - sys.stderr.write("http server did NOT stop: %s\n" % str(e)) - - try: - serv.server_close() - except: - pass - - log(None, "restarting") - try: - logf.close() - except: - pass - - # signal.signal(signal.SIGTERM, 0) - signal.signal(signal.SIGHUP, 0) - - -def restart(): - if verbose: - print(("execv %s %s" % (sys.argv[0], [sys.argv[0]] + cmdargs))) - os.execv(sys.argv[0], [sys.argv[0]] + cmdargs) - print("should not be here") - - -def saveandrestart(): - closeup() - restart() - - -def pickleit(): - pickf = open(pickfile, "wb") - pick = pickle.Pickler(pickf) - pick.dump(hbdclass.Host.hosts) - pick.dump(msgs) - pick.dump(lastfm) - pickf.close() - - -# Websockets stuff - -ws_connections = {} - - -async def ws_serve(websocket, path): - - ws_connections[websocket] = path - remote_address = websocket.remote_address - if verbose: - print(f"DBG ws_serve: {remote_address}: {path}") - while True: - try: - name = await websocket.recv() - if verbose: - print(f"DBG ws_serve: receive {name}") - except ( - websockets.exceptions.ConnectionClosedOK, - websockets.exceptions.ConnectionClosedError, - ) as e: - if verbose: - print(f"ws closed: {e}") - break - if verbose: - print(f"initial {name} at {path}") - # send initial set of hosts and messages - # hosts in sorted order - for h in sorted(hbdclass.Host.hosts): - jmsg = json.dumps( - {"type": "host", "data": hbdclass.Host.hosts[h].stateinfo()} - ) - await websocket.send(jmsg) - # messages in reverse order - for m in msgs[len(msgs) - 100:]: - jmsg = json.dumps({"type": "message", "data": m}) - await websocket.send(jmsg) - - if verbose: - print(f"DBG ws_serve: close {remote_address}") - await websocket.wait_closed() - - -def websocketupdater(): - pass - - -def msg_to_websockets(typ: str, msg: str): - jmsg = json.dumps({"type": typ, "data": msg}) - to_close = [] - for ws in ws_connections: - if ws.closed: - to_close.append(ws) - continue - try: - asyncio.run_coroutine_threadsafe(ws.send(jmsg), loop) - except Exception: - to_close.append(ws) - print("ws.send exception: closed") - - for ws in to_close: - asyncio.run_coroutine_threadsafe(ws.wait_closed(), loop) - if ws in ws_connections: - del ws_connections[ws] - - -# -# Main -# -PUSHSRVS = ["all", "pushover", "mattermost"] -helpflag = False -foreground = False -pushsrv = "pushover" # mattermost -dyndomains = ["wrede.org"] -optlist = [] -args = [] -home = os.environ["HOME"] -cmdargs = [] -configfile = "%s/.hbrc" % home - -try: - optlist, args = getopt.getopt(sys.argv[1:], "c:dfh:p:vx") -except: - helpflag = True - -for o, a in optlist: - if o == "-c": - configfile = a - cmdargs += [o, a] - if o == "-f": - foreground = True - cmdargs += [o] - elif o == "-h": - helpflag = True - elif o == "-v": - verbose = True - cmdargs += [o] - elif o == "-p": - if a in PUSHSRVS: - pushsrv = a - cmdargs += [o, a] - else: - print("invalid push service, use of of %s" % PUSHSRVS) - helpflag = True - elif o == "-x": - DEBUG += 1 - cmdargs += [o] - - -if helpflag: - print("hbc HeartBeatDaemon") - print("usage: hbd [-dfhvx] [-c configfile]") - print() - print(" -c configfile") - print(" -d display") - print(" -f run in foreground") - print(" -h this help") - print(" -v verbose") - print(" -x increase debug lvl") - print() - print( - """ config file can contain -logfile = /var/log/heartbeat.log -logfmt = [text|msg] -hb_port = 50003 -interval = 20 -hbd_port = 50004 -hbd_host = www.domain.com -grace = 2 -""" - ) - - sys.exit(1) - -# -# set defaults - -hb_port = PORT -hbd_host = THOST -hbd_port = TPORT -pickfile = PICKFILE -logfile = LOGFILE -logfmt = "text" -interval = INTERVAL -grace = GRACE -watchhosts = [] -dyndnshosts = [] -drophosts = [] -nsupdate_bin = NSUPDATE_BIN - -try: - f = open(configfile, "r") - if verbose: - print(("notice: using config file %s" % configfile)) -except: - print(("warning: running without config file: %s" % configfile)) - f = None - -if f: - while 1: - ls = f.readline() - if len(ls) == 0: - break - ln = ls[:-1].strip() - if len(ln) == 0 or ln[0] == "#": - continue - if verbose: - print((" %s" % ln)) - r = ln.split("=") - o = r[0].strip() - try: - a = eval(r[1].strip()) - except Exception as e: - print("error: %s %s" % (e, str(r))) - sys.exit(1) - if o == "interval": - interval = a - elif o == "grace": - grace = a - elif o == "hbd_port": - hbd_port = a - elif o == "hbd_host": - hbd_host = a - elif o == "pickfile": - pickfile = a - elif o == "hb_port": - hb_port = a - elif o == "logfile": - logfile = a - elif o == "logfmt": - logfmt = a - elif o == "watchhosts": - watchhosts = a - elif o == "dyndnshosts": - dyndnshosts = a - elif o == "drophosts": - drophosts = a - elif o == "nsupdate_bin": - nsupdate_bin = a - elif o == "pushsrv": - pushsrv = a - elif o == "dyndomains": - dyndomains = a - f.close() - -if len(args) != 0: - print("error: args") - sys.exit(1) - -if pushsrv in ["all", "mattermost"]: - try: - from mattermostdriver import Driver - except: - print("warning: mattermostdriver python module missing, reverting to pushover") - pushsrv = "pushover" - - -if verbose: - print("notice: logging to %s" % logfile) - print("notice: push service is %s" % pushsrv) -logf = initlog(logfile) - -if 1 and os.path.exists(pickfile): - if verbose: - print(("opening pickls %s" % pickfile)) - pickf = open(pickfile, "rb") - pick = pickle.Unpickler(pickf) - try: - hbdclass.Host.hosts = pick.load() - msgs = pick.load() - try: - lastfm = pick.load() - except: - lastfm = ["", "", ""] - pickf.close() - except Exception as e: - print(("load pickled failed: %s" % e)) - os.unlink(pickfile) - hbdclass.Connection.htab = {} - for h in list(hbdclass.Host.hosts.keys()): - hbdclass.Host.hosts[h].dyn = h in dyndnshosts - hbdclass.Host.hosts[h].watched = h in watchhosts - hbdclass.Host.hosts[h].fixup() - for h in drophosts: - if h in hbdclass.Host.hosts: - del hbdclass.Host.hosts[h] - if verbose: - print(("%s pickled hosts loaded" % len(hbdclass.Host.hosts))) -else: - if verbose: - print("no pickled data") - - -now = time.time() -startsec = int(now) % interval - - -log(None, "Starting %s" % VER) -atexit.register(on_exit) - -ilist = [] - -sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) -sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) -sock.bind(("", hb_port)) -ilist.append(sock) - -sock6 = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM) -sock6.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) -sock6.bind(("", hb_port)) -ilist.append(sock6) - -# ilist.append(serv.fileno()) - -if not foreground: - pid = os.fork() - if pid > 0: - if verbose: - print(("daemoinizing... pid = %d" % pid)) - sys.exit(0) - - verbose = False - os.close(0) - os.close(1) - os.close(2) - sys.stdin.close() - if DEBUG > 0: - sys.stdout = LogDevice() - sys.stderr = LogDevice() - else: - sys.stdout = NullDevice() - sys.stderr = NullDevice() - os.chdir("/tmp") - os.setsid() - os.umask(0) - -try: - serv = HttpServer((hbd_host, hbd_port), HttpHandler) -except: - print(("failed to start server on %s:%s" % (hbd_host, hbd_port))) - sys.exit(1) - -if verbose: - print("http server started") -# - -loop = asyncio.new_event_loop() -asyncio.set_event_loop(loop) -if verbose: - print("asyncio event lop at %s" % loop) - - -ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) -wss_pem = pathlib.Path(WSS_PEM) -wss_key = pathlib.Path(WSS_KEY) -try: - ssl_context.load_cert_chain(wss_pem, keyfile=wss_key) -except FileNotFoundError: - print(("warning: missing %s or %s" % (wss_pem, wss_key))) - sys.exit(1) - -servthread = threading.Thread(target=serv.serve_forever) -servthread.daemon = True -servthread.start() - -dnsT = threading.Thread(target=dnsupdatethread) -dnsT.daemon = True -dnsT.start() - -wsT = threading.Thread(target=websocketupdater) -wsT.daemon = True -wsT.start() -loop.run_forever() -if verbose: - print("run_forever()") - -wss_start_server = websockets.serve( - ws_serve, hbd_host, WSSPORT, ssl=ssl_context, loop=loop, subprotocols=["hbd"] -) -loop.run_until_complete(wss_start_server) - -ws_start_server = websockets.serve( - ws_serve, hbd_host, WSPORT, loop=loop, subprotocols=["hbd"] -) -loop.run_until_complete(ws_start_server) - -running = True -sig = 0 -signal.signal(signal.SIGTERM, handler) -signal.signal(signal.SIGHUP, handler) - -rnext = int(now) + 15 # 15 seconds time to settle after (re-)start -sleep = 1 -firstcheck = int(now) + 15 - -while running: - sr = None - if DEBUG > 3: - sys.stderr.write("about to sleep = %s\n" % (sleep)) - try: - sr = select.select(ilist, [], [], sleep) - now = time.time() - except KeyboardInterrupt: - sys.stderr.write("Keyboard Interrupt!\n") - running = False - closeup() - continue - except OSError as value: - if value.errno != 4: # interrupted system call - sys.stderr.write("select err %s %s" % (select.error, value)) - # raise os.error, value - continue - continue - except Exception as e: - if DEBUG > 2: - sys.stderr.write("select exception %s\n" % (str(e))) - sys.exit(1) - if DEBUG > 3: - sys.stderr.write("woke from sleep = %s (%s)\n" % (str(sr), str(ilist))) - for fh in sr[0]: - if fh in [sock, sock6]: - readsock(fh) - # elif fh == serv.fileno(): - # serv.handle_request() - else: - sys.stderr.write("what happend just now?\n") - if DEBUG > 3: - sys.stderr.write("done handling, running is %s, sig is %s\n" % (running, sig)) - - # check hour/day/week - for v in range(3): - fm = tsfm[v] - ts = time.strftime(tsfm[v], time.localtime(now)) - if ts != lastfm[v]: - lastfm[v] = ts - for h in list(hbdclass.Host.hosts.keys()): - hbdclass.Host.hosts[h].hdwcounts[v] = [ - hbdclass.Host.hosts[h].doesack, - hbdclass.Host.hosts[h].upcount, - ] - - if now >= rnext and now >= firstcheck: - rnext = now + 1 - checkoverdue() - - sleep = rnext - now - if sleep < 0: - sys.stderr.write("sleep is negative! %s next = %s\n" % (sleep, rnext)) - sleep = 0 - if DEBUG > 3: - sys.stderr.write("sleep = %s next = %s\n" % (sleep, rnext)) - - if sig != 0: - setrunning(False) - - -if sig == signal.SIGHUP: - if DEBUG > 0: - sys.stderr.write("signal 1 saveandrestart\n") - saveandrestart() diff --git a/hbd/__init__.py b/hbd/__init__.py new file mode 100644 index 0000000..5896eaa --- /dev/null +++ b/hbd/__init__.py @@ -0,0 +1,11 @@ +"""hbd package - scaffolding for heartbeat daemon + +This package contains the refactored modules for the original monolithic +`hbd` script. The initial implementation contains small scaffolds so you can +start moving functionality into the package. +""" + +__all__ = ["main", "__version__"] +__version__ = "0.1" + +from .cli import main diff --git a/hbd/cli.py b/hbd/cli.py new file mode 100644 index 0000000..82dbfd5 --- /dev/null +++ b/hbd/cli.py @@ -0,0 +1,45 @@ +"""Command line interface for hbd package.""" +import argparse + +from .config import load_config +from .server import run as run_server + +PUSHSRVS = ["all", "pushover", "mattermost"] + + +def build_parser(): + parser = argparse.ArgumentParser( + prog="hbd", + description="HeartBeatDaemon - Wait for heartbeat messages and act on them (or their absence)", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("-c", "--config", dest="configfile", help="Config file path (YAML)") + parser.add_argument("-f", "--foreground", action="store_true", help="Run in foreground") + parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output") + parser.add_argument("-p", "--pushsrv", dest="pushsrv", choices=PUSHSRVS, help="Push service to use") + parser.add_argument("-x", "--debug", action="count", default=0, help="Increase debug level") + return parser + + +def main(argv=None): + parser = build_parser() + args = parser.parse_args(argv) + + config = load_config(args.configfile) + + # Apply CLI overrides + if args.foreground: + config["foreground"] = True + if args.verbose: + config["verbose"] = True + if args.pushsrv: + config["pushsrv"] = args.pushsrv + if args.debug: + config.setdefault("debug", 0) + config["debug"] += args.debug + + run_server(config) + + +if __name__ == "__main__": + main() diff --git a/hbd/config.py b/hbd/config.py new file mode 100644 index 0000000..c7a53b5 --- /dev/null +++ b/hbd/config.py @@ -0,0 +1,54 @@ +"""Configuration loader and defaults for hbd.""" +import os + +try: + import yaml +except Exception: + yaml = None + +DEFAULTS = { + "hb_port": 50003, + "hbd_port": 50004, + "hbd_host": "", + "pickfile": "/tmp/hb.pick", + "logfile": "/var/log/heartbeat.log", + "logfmt": "text", + "pushsrv": "pushover", + "interval": 20, + "grace": 2, + "dyndomains": ["wrede.org"], + "watchhosts": [], + "dyndnshosts": [], + "drophosts": [], + "nsupdate_bin": "/usr/bin/nsupdate", + "foreground": False, + "verbose": False, + "debug": 0, +} + + +def load_config(path=None): + """Load configuration from a YAML file and merge with defaults. + + If YAML is not available or the file does not exist, defaults are returned. + """ + cfg = DEFAULTS.copy() + if not path: + # default path (~/.hb.yaml) + path = os.path.join(os.path.expanduser("~"), ".hb.yaml") + + if os.path.exists(path): + if yaml: + with open(path) as fh: + data = yaml.safe_load(fh) or {} + # only keep known keys + for k, v in data.items(): + if k in cfg: + cfg[k] = v + else: + # ignore unknown keys for now + pass + else: + # yaml not installed: do not attempt to parse; user must ensure defaults + pass + return cfg diff --git a/hbd/dns.py b/hbd/dns.py new file mode 100644 index 0000000..7088c85 --- /dev/null +++ b/hbd/dns.py @@ -0,0 +1,91 @@ +"""DNS update helper and thread for heartbeat daemon.""" +from __future__ import annotations +import threading +import subprocess +from subprocess import Popen, PIPE, STDOUT +from typing import Optional + + +def create_nsupdate_payload(hostname: str, newip: str, dyndomain: str, dnsttl: str = "5") -> str: + D = {"domain": dyndomain, "fqdn": f"{hostname}.dy.{dyndomain}", "dnsttl": dnsttl, "newip": newip, "ts": __import__("time").strftime("%Y-%m-%d.%H:%M:%S", __import__("time").gmtime())} + if ":" in newip: + nsup = ( + """update delete %(fqdn)s AAAA +update add %(fqdn)s %(dnsttl)s AAAA %(newip)s +update delete %(fqdn)s TXT +update add %(fqdn)s %(dnsttl)s TXT "Created: %(ts)s" +send +answer + +""" % D + ) + else: + nsup = ( + """update delete %(fqdn)s A +update add %(fqdn)s %(dnsttl)s A %(newip)s +update delete %(fqdn)s TXT +update add %(fqdn)s %(dnsttl)s TXT "Created: %(ts)s" +send +answer + +""" % D + ) + return nsup + + +def nsupdate(hostname: str, newip: str, dyndomain: str, nsupdate_bin: str = "/usr/local/bin/nsupdate", rndc_key: str = "/etc/dhcpc/rndc-key") -> Optional[str]: + """Perform DNS update via nsupdate command. + + Returns None on success, else returns combined stdout/stderr as a string. + """ + nsup = create_nsupdate_payload(hostname, newip, dyndomain) + cmd = [nsupdate_bin, "-k", rndc_key, "-v"] + try: + p = Popen(cmd, shell=False, bufsize=0, stdin=PIPE, stdout=PIPE, stderr=STDOUT) + except OSError as e: + return f"nsupdate: execution failed: {e}" + except Exception as e: + return f"nsupdate: some error occured: {e}" + + (output, err) = p.communicate(nsup.encode()) + out = output.decode() if output else "" + if out.find("status: NOERROR") >= 0: + return None + return out + + +def dnsupdatethread(hbdclass, cfg: dict, log: Optional[callable] = None, email: Optional[callable] = None): + """Thread target: process dns update queue from hbdclass.Host.dnsQ. + + hbdclass: module with Host class that exposes dnsQ queue + cfg: configuration mapping with 'dyndomains' and 'nsupdate_bin' + log: callable(host, message) + email: callable(subject, message) + """ + while True: + name, addr = hbdclass.Host.dnsQ.get() + m = f"changed address to {addr}" + for dyndomain in cfg.get("dyndomains", []): + err = nsupdate(name, addr, dyndomain, nsupdate_bin=cfg.get("nsupdate_bin", "/usr/local/bin/nsupdate")) + if err: + m += f", DNS update failed: {err}" + if email: + try: + email("error: nsupdate failed", f"{name}.dy.{dyndomain}: {m}") + except Exception: + pass + else: + m += ", DNS updated." + hbdclass.Host.dnsQ.task_done() + if log: + try: + log(name, m) + except Exception: + pass + + +def start_dns_thread(hbdclass, cfg: dict, log: Optional[callable] = None, email: Optional[callable] = None) -> threading.Thread: + t = threading.Thread(target=dnsupdatethread, args=(hbdclass, cfg, log, email)) + t.daemon = True + t.start() + return t diff --git a/hbd/http.py b/hbd/http.py new file mode 100644 index 0000000..eb701b1 --- /dev/null +++ b/hbd/http.py @@ -0,0 +1,236 @@ +"""HTTP server and handler scaffolds (thin wrappers around http.server).""" +from http import server +import json +import time +import urllib.parse + +from urllib3 import request +from fastapi.templating import Jinja2Templates + +class HttpServer(server.ThreadingHTTPServer): + allow_reuse_address = True + + def threaded(self): + pass + + +def make_handler_class( + config, + hbdclass, + msgs_getter, + log=None, + email=None, + pushmsg=None, + msg_to_websockets=None, + tcss=None, + DEBUG=0, + verbose=False, + get_now=None, + VER="", +): + """Return a BaseHTTPRequestHandler subclass bound to runtime objects. + + `msgs_getter` should be a callable that returns a list-like of messages. + """ + templates = Jinja2Templates(directory="templates") + get_now = get_now or (lambda: time.time()) + + class CustomHandler(server.BaseHTTPRequestHandler): + + server_version = f"HeartbeatHTTP/{VER}" + + def version_string(self): + return self.server_version + + def handle(self): + try: + return server.BaseHTTPRequestHandler.handle(self) + except Exception as e: + self.log_error("Request went away: %r", e) + self.close_connection = 1 + return + + def do_HEAD(self): + self.setheaders(200) + + def setheaders(self, code, headerdict={}): + self.send_response(code) + self.send_header( + "Last-Modified", + time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime(get_now())), + ) + for h in headerdict: + self.send_header(h, headerdict[h]) + self.end_headers() + + def buildhead(self, title="Heartbeat", refresh=None, extras=None): + res = [] + res.append('') + res.append("") + res.append("") + res.append("%s" % (title)) + if refresh: + res.append("\n" % refresh) + if extras: + res.append(extras) + res.append("") + res.append('') + return res + + def buildpage(self): + res = self.buildhead(refresh=60, extras=tcss) + res.append("

Heartbeat status %s

" % VER) + res += hbdclass.ubHost.buildhosttable() + res += hbdclass.ubHost.buildmsgtable(msgs_getter()) + res.append( + "

%s (%s)

" % (time.strftime("%H:%M:%S", time.localtime(get_now())), config.get("tz", "CET-1CDT")) + ) + res.append("") + return res + + def builderror(self, code, cause, lcause): + res = [] + res.append('') + res.append("") + res.append("%s %s" % (code, cause)) + res.append("") + res.append("

%s

" % (cause)) + res.append("

%s

" % lcause) + res.append("
") + res.append( + "
hbd (Unix) Server at %s:%s
" % (config.get("hbd_host"), config.get("hbd_port")) + ) + res.append("") + return code, res + + def do_GET(self): + xsig = 0 + rqAcceptEncoding = self.headers.get("Accept-encoding", {}) + headerdict = {"Content-Type": "text/html; charset = ISO-8859-1"} + qr = urllib.parse.urlparse(self.path) + qa = urllib.parse.parse_qs(qr.query) + + if qr.path == "/": + res = self.buildpage() + + elif qr.path == "/c": # command on host /c?h=melschserver&c=sudo%20ls + uname = qa.get("h", [None])[0] + ucmd = qa.get("c", [None])[0] + if not ucmd or not uname: + code, res = self.builderror(400, "Argument error", "need h= and c= arguments") + elif uname not in hbdclass.Host.hosts: + code, res = self.builderror(400, "Data error", "h=%s not found" % uname) + else: + hbdclass.Host.hosts[uname].cmds.append(("CMD", {"cmd": urllib.parse.unquote(ucmd)})) + res = self.buildhead() + res.append("cmd %s queued for host %s" % (uname, ucmd)) + + elif qr.path == "/d": # drop host /d?h=melschserver + uname = qa.get("h", [None])[0] + if not uname: + code, res = self.builderror(400, "Argument error", "need h= argument") + if uname not in hbdclass.Host.hosts: + code, res = self.builderror(400, "Data error", "h=%s not found" % uname) + else: + if log: + log(uname, "dropped") + del hbdclass.Host.hosts[uname] + res = self.buildhead() + res.append("Done") + + elif qr.path == "/n": # register name + uname = qa.get("h", [None])[0] + if not uname: + code, res = self.builderror(400, "Argument error", "need h= argument") + if uname not in hbdclass.Host.hosts: + code, res = self.builderror(400, "Data error", "h=%s not found" % uname) + else: + ll = hbdclass.Host.hosts[uname].registerDns() + res = self.buildhead() + res.append(ll) + if log: + log(uname, ll) + + elif qr.path == "/u": # update + uname = urllib.parse.unquote(qa.get("h", [None])[0]) + ucode = qa.get("c", [None])[0] + if not ucode or not uname: + code, res = self.builderror(400, "Argument error", "need h= and c= arguments") + elif uname != "All" and uname not in hbdclass.Host.hosts: + code, res = self.builderror(400, "Data error", "h=%s not found" % uname) + else: + res = self.buildhead() + if uname != "All": + names = [uname] + else: + names = [] + for n in hbdclass.Host.hosts: + if hbdclass.Host.hosts[n].cver >= 2: # earliest version that supports update + names.append(n) + for n in names: + err = None + try: + from hbd import proto + # read code from a file name, fallback to sending ucode as data + err = None + # attempt to send update command to host + r = {"csum": None, "code": ucode} + hbdclass.Host.hosts[n].cmds.append(("UPD", r)) + except Exception as e: + err = str(e) + res.append("update started for %s: %s
" % (n, err if err else "OK")) + res.append("Done") + + elif qr.path == "/api/0/hosts": # api access to host table + headerdict = {"Content-Type": "application/json; charset=utf-8"} + lst = [] + for h in hbdclass.Host.hosts: + lst.append(hbdclass.Host.hosts[h].jsons()) + res = ["[" + ",".join(lst) + "]"] + + elif qr.path == "/api/0/messages": # api access to host table + headerdict = {"Content-Type": "application/json; charset=utf-8"} + lst = msgs_getter()[-30:] + res = [json.dumps(lst)] + + elif qr.path == "/r": # restart + res = self.buildhead() + res.append("restart request") + xsig = 1 # signal.SIGHUP will be handled by application + if log: + log(None, "restart request") + elif qr.path == "/live": # show live view with websockets + host = config.get("hb_host", "localhost") + extra_scripts = '' # '' + heartbeat_ws_url = f"ws://{host}:50005/hbd" + res = templates.TemplateResponse( + "live.html ", + { + "title": "Heartbeat", + "header": "Heartbeat", + "heartbeat_ws_url": heartbeat_ws_url, + "extra_scripts": extra_scripts, + }, + ) + else: + code, res = self.builderror(404, "Not Found", "requested URL was not found on this server.") + + if "deflate" in rqAcceptEncoding: + headerdict["Content-Encoding"] = "deflate" + towrite = __import__("zlib").compress("\n".join(res).encode(), 6) + else: + towrite = "\n".join(res) + headerdict["Content-Length"] = len(towrite) + headerdict["Cache-Control"] = "private, must-revalidate, max-age=0" + headerdict["Expires"] = "Thu, 01 Jan 1970 00:00:00 GMT" + self.setheaders(200 if 'res' in locals() else code, headerdict) + self.wfile.write(towrite if isinstance(towrite, bytes) else towrite.encode()) + + if xsig: + # inform application via setting a flag on the server instance + try: + self.server.xsig = xsig + except Exception: + pass + + return CustomHandler diff --git a/hbd/notify.py b/hbd/notify.py new file mode 100644 index 0000000..8c99e76 --- /dev/null +++ b/hbd/notify.py @@ -0,0 +1,163 @@ +"""Notification helpers: email, pushover, mattermost, signal and dispatcher.""" +from typing import Optional +import http.client +import urllib.parse +import subprocess +import smtplib +import time +import traceback + +DEFAULT_PUSHPROVIDERS = ["all", "pushover", "mattermost", "signal"] + +# module-level configuration set via setup() +_config = {} + + +def setup(cfg: dict): + """Initialize notifier defaults from a configuration dict.""" + global _config + _config = dict(cfg) + + +def send_email(aemail, smtpserver, sender, subject, body, debug=0): + """Send a plain email via SMTP. Returns True on success.""" + try: + server = smtplib.SMTP(smtpserver) + if debug > 0: + server.set_debuglevel(1) + server.sendmail(sender, aemail, body) + except Exception as e: + if debug: + print("email send failed:", e) + try: + server.quit() + except Exception: + pass + return False + try: + server.quit() + except Exception: + pass + return True + + +def email(subject: str, msg: str, debug: int = 0) -> bool: + """Convenience wrapper exposed to the rest of the application. + + Uses module-level configuration to supply recipient list, smtp server + and sender address. + """ + toaddrs = _config.get("AEMAIL") or _config.get("aemail") or _config.get("email_to") or [] + fromemail = _config.get("fromemail") or _config.get("sender") or f"aew.heartbeat@{_config.get('domain','local') }" + smtpserver = _config.get("SMTPSERVER") or _config.get("smtpserver") or _config.get("SMTPSERVER", "localhost") + date = time.strftime("%a, %d %b %Y %H:%M:%S %z", time.localtime()) + body = "To: %s\nFrom: %s\nSubject: %s\nDate: %s\n\n%s" % ( + toaddrs[0] if toaddrs else "", + fromemail, + subject, + date, + msg, + ) + return send_email(toaddrs, smtpserver, fromemail, subject, body, debug=debug) + + +def pushover(token: str, user: str, msg: str, debug: int = 0) -> bool: + """Send message via Pushover API.""" + conn = http.client.HTTPSConnection("api.pushover.net:443") + try: + conn.request( + "POST", + "/1/messages.json", + urllib.parse.urlencode({"token": token, "user": user, "message": msg}), + {"Content-type": "application/x-www-form-urlencoded"}, + ) + r = conn.getresponse() + if debug: + print("pushover response:", r.status, r.reason) + return r.status == 200 + except Exception as e: + if debug: + print("pushover error:", e) + return False + + +def pushmattermost(host: str, token: str, channel: str, msg: str, username: str = "hbd", icon: Optional[str] = None, debug: int = 0) -> bool: + """Send a message to Mattermost via simple webhook driver if available. + + This helper tries to import mattermostdriver.Driver and uses webhooks if present. + If the import fails it returns False. + """ + try: + from mattermostdriver import Driver + except Exception: + return False + ses = {"url": host, "scheme": "http", "basepath": "/api/v4", "port": 8065} + mm = Driver(ses) + payload = {"text": msg, "channel": channel, "username": username} + if icon: + payload["icon_url"] = icon + try: + rc = mm.webhooks.call_webhook(token, payload) + if debug: + print("mattermost rc:", rc) + return bool(rc is None or rc == "") + except Exception as e: + if debug: + print("mattermost error:", e) + return False + + +def pushsignal(signal_cli_bin: str, user: str, recipient: str, msg: str, debug: int = 0) -> bool: + """Send a message via signal-cli (requires local installation). + + Uses subprocess to call signal-cli. Returns True if the command succeeded. + """ + CLI = [signal_cli_bin, "-u", user, "send", "-m", msg, recipient] + if debug: + print("signal cli: ", CLI) + try: + res = subprocess.run(CLI, capture_output=True) + if res.returncode != 0: + if debug: + print("signal failed:", res.stderr.decode()) + return False + if debug: + print("signal sent:", res.stdout.decode()) + return True + except Exception as e: + if debug: + print("signal exception:", e) + return False + + +def pushmsg(cfg: dict, msg: str, debug: int = 0): + """Dispatch push notifications according to `cfg['pushsrv']`. + + cfg is expected to contain keys for different services when needed, e.g. + - cfg['pushsrv'] : one of 'all', 'pushover', 'mattermost', 'signal' + - cfg['pushover_token'], cfg['pushover_user'] + - cfg['matter_host'], cfg['matter_token'], cfg['matter_channel'] + - cfg['signal_cli'], cfg['signal_user'], cfg['signal_recipient'] + + Returns a dict of results per provider. + """ + results = {} + p = cfg.get("pushsrv", "pushover") + if p in ("all", "pushover"): + ok = pushover(cfg.get("pushover_token", ""), cfg.get("pushover_user", ""), msg, debug=debug) + results["pushover"] = ok + if p in ("all", "mattermost"): + ok = pushmattermost(cfg.get("matter_host", ""), cfg.get("matter_token", ""), cfg.get("matter_channel", ""), msg, username=cfg.get("matter_username", "hbd"), icon=cfg.get("matter_icon"), debug=debug) + results["mattermost"] = ok + if p in ("all", "signal"): + ok = pushsignal(cfg.get("signal_cli", "/usr/local/bin/signal-cli"), cfg.get("signal_user", ""), cfg.get("signal_recipient", ""), msg, debug=debug) + results["signal"] = ok + if debug: + print("push results:", results) + return results + + +def pushmsg_from_config(msg: str, debug: int = 0) -> dict: + """Use the module-level configuration dict to dispatch a push message.""" + return pushmsg(_config, msg, debug=debug) + diff --git a/hbd/proto.py b/hbd/proto.py new file mode 100644 index 0000000..8212960 --- /dev/null +++ b/hbd/proto.py @@ -0,0 +1,81 @@ +"""Message encoding/decoding utilities for hbd protocol.""" +from typing import Dict, Any +import zlib + + +def dicttos(ID: str, d: Dict[str, Any], compress: bool = False): + """Serialize a dict to protocol message bytes. + + If compress is True, the payload is zlib-compressed and the message is + prefixed with `!ID:` as the original script did. Otherwise the format is + `ID:key=value;...` (bytes). + """ + s = [] + for k in d: + v = d[k] + if isinstance(v, float): + s.append(f"{k}={v:0.5f}") + else: + s.append(f"{k}={v}") + pk = ";".join(s) + if compress: + zpk = zlib.compress(pk.encode(), 6) + hdr = ("!" + ID + ":").encode() + return hdr + zpk + else: + return (ID + ":" + pk).encode() + + +def stodict(msg: bytes): + """Deserialize a protocol message into a dict. + + Mirrors original behaviour: detects compressed messages starting with + '!' and decodes accordingly. Returns a dict with key 'ID' set to the + message ID and the parsed key/value pairs. + """ + d = {} + if len(msg) > 0 and chr(msg[0]) == "!": + # message is: b'!ID:' + compressed_payload + # original code used msg[1:4].decode() for ID (3 bytes including colon) + try: + pk = zlib.decompress(msg[5:]).decode() + except Exception: + # malformed compressed payload + return {} + d["ID"] = msg[1:4].decode() + else: + try: + r0 = msg.split(b":", 1) + pk = r0[1].decode() + d["ID"] = r0[0].decode() + except Exception: + return {} + if not pk: + return d + parts = pk.split(";") + for v in parts: + if not v: + continue + vr = v.split("=", 1) + k = vr[0].strip() + if len(vr) == 1: + d[k] = None + else: + val = vr[1].strip() + if val and val[0].isdigit(): + try: + val_e = eval(val) + except Exception: + val_e = val + d[k] = val_e + else: + d[k] = val + return d + + +def oldmtodict(msg: bytes): + """Compatibility wrapper for old-style messages (no ID prefix). + + The original implementation prefixed with 'HTB:' and called stodict. + """ + return stodict(b"HTB:" + msg) diff --git a/hbd/server.py b/hbd/server.py new file mode 100644 index 0000000..ae379c4 --- /dev/null +++ b/hbd/server.py @@ -0,0 +1,128 @@ +"""Server runtime: starts UDP listener, HTTP server and websocket stubs.""" +import asyncio +import logging + +from . import udp + +logger = logging.getLogger(__name__) + + +async def _run_async(config): + loop = asyncio.get_running_loop() + + # shared runtime collections and helpers + msgs = [] + + # prepare runtime dependencies + import threading + import time + import hbdclass + from . import http as http_mod + from . import ws as ws_mod + from . import dns as dns_mod + from . import notify as notify_mod + + notify_mod.setup(config) + + def log(host, m, service=None): + ts = time.time() + s = f"{time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(ts))} {host or ''} {m}" + msgs.append(s) + logger.info(s) + + email = notify_mod.email + pushmsg = notify_mod.pushmsg_from_config + msg_to_websockets = ws_mod.broadcast + + # UDP server endpoint (handler wired to handle_datagram with context) + bind_addr = ("0.0.0.0", config.get("hb_port", 50003)) + logger.info("Starting UDP server on %s:%s", *bind_addr) + + def udp_handler(msg, addr, transport): + ctx = dict( + config=config, + hbdclass=hbdclass, + log=log, + email=email, + pushmsg=pushmsg, + msg_to_websockets=msg_to_websockets, + msgs=msgs, + DEBUG=config.get("debug", 0), + verbose=config.get("verbose", False), + ) + udp.handle_datagram(msg, addr, transport, ctx) + + transport, protocol = await loop.create_datagram_endpoint( + lambda: udp.EchoServerProtocol(config=config, handler=udp_handler), + local_addr=bind_addr, + ) + + # HTTP server (runs in its own thread) + try: + handler_cls = http_mod.make_handler_class( + config=config, + hbdclass=hbdclass, + msgs_getter=lambda: msgs, + log=log, + email=email, + pushmsg=pushmsg, + msg_to_websockets=msg_to_websockets, + tcss=None, + DEBUG=config.get("debug", 0), + verbose=config.get("verbose", False), + get_now=lambda: time.time(), + VER="", + ) + serv = http_mod.HttpServer((config.get("hbd_host", ""), config.get("hbd_port", 50004)), handler_cls) + http_thread = threading.Thread(target=serv.serve_forever, daemon=True) + http_thread.start() + logger.info("HTTP server started on %s:%s", config.get("hbd_host", ""), config.get("hbd_port", 50004)) + except Exception as e: + logger.exception("failed to start HTTP server: %s", e) + + # start dns update thread + dns_mod.start_dns_thread(hbdclass, config, log=log, email=email) + logger.info("dns update thread started") + + # Start the websocket servers as a background task + try: + ws_task = asyncio.create_task( + ws_mod.start( + host=config.get("hbd_host", ""), + ws_port=config.get("ws_port", 50005), + wss_port=config.get("wss_port", None), + ssl_context=None, + get_hosts=lambda: [hbdclass.Host.hosts[h].stateinfo() for h in sorted(hbdclass.Host.hosts)], + get_msgs=lambda: msgs, + verbose=config.get("verbose", False), + ) + ) + logger.info("WebSocket task started") + except Exception as e: + logger.exception("websocket server failed to start: %s", e) + + try: + # run forever + await asyncio.Future() + finally: + transport.close() + try: + serv.shutdown() + except Exception: + pass + try: + ws_task.cancel() + except Exception: + pass + + +def run(config): + """Start the hbd service (blocking). + + This is a thin wrapper around asyncio.run to host the async services. + """ + logging.basicConfig(level=logging.DEBUG if config.get("debug", 0) > 0 else logging.INFO) + try: + asyncio.run(_run_async(config)) + except KeyboardInterrupt: + logger.info("Shutting down (KeyboardInterrupt)") diff --git a/hbd/udp.py b/hbd/udp.py new file mode 100644 index 0000000..8004ac3 --- /dev/null +++ b/hbd/udp.py @@ -0,0 +1,235 @@ +"""UDP listener and datagram processing.""" +import asyncio +from compression import zlib +import logging + +logger = logging.getLogger(__name__) + + +from .proto import stodict, oldmtodict +from hbd.utils import dur + + +class EchoServerProtocol(asyncio.DatagramProtocol): + def __init__(self, config=None, handler=None): + super().__init__() + self.config = config or {} + self.handler = handler + + def connection_made(self, transport): + self.transport = transport + logger.info("UDP Server listening...") + + def datagram_received(self, data, addr): + logger.debug("Received from %s", addr) + try: + msg = parse_message(data) + if self.handler: + # handler can be a callable provided by the application + # pass the transport so handlers can send replies (ACKs/commands) + self.handler(msg, addr, self.transport) + except Exception: + logger.exception("Error while processing datagram from %s", addr) + + +def parse_message(data: bytes): + """Parse a raw datagram into a message dict. + + Uses the protocol decoding helpers and falls back to old format when + decoding returns an empty dict (compat with older clients). + """ + msg = stodict(data) + if not msg: + # fallback to old format + msg = oldmtodict(data) + return msg + +def dicttos(ID, d, compress=False): + s = [] + for k in d: + if isinstance(d[k], float): + s.append("%s=%0.5f" % (k, d[k])) + else: + s.append("%s=%s" % (k, d[k])) + pk = ";".join(s) + if compress: + zpk = zlib.compress(pk.encode(), 6) + ID = "!" + ID + ":" + opk = ID.encode() + zpk + else: + zpk = pk + opk = ID + ":" + zpk + return opk + +def handle_datagram(msg: dict, addr, transport, ctx: dict): + """Handle a parsed datagram message. + + ctx is a dictionary with runtime dependencies: + - config: dict of configuration + - hbdclass: module providing Host/Connection classes + - log: callable(loghost, message) + - email: callable(subject, message) + - pushmsg: callable(message) + - msg_to_websockets: callable(typ, data) + - msgs: list for storing message strings + - DEBUG, verbose + """ + if not msg: + return + now = __import__("time").time() + cfg = ctx.get("config", {}) + hbdcls = ctx.get("hbdclass") + log = ctx.get("log") + email = ctx.get("email") + pushmsg = ctx.get("pushmsg") + msg_to_websockets = ctx.get("msg_to_websockets") + msgs = ctx.get("msgs") + DEBUG = ctx.get("DEBUG", 0) + verbose = ctx.get("verbose", False) + + # normalize addr (ip, port) + ip = addr[0] if isinstance(addr, (list, tuple)) else addr + name = msg.get("name", "unknown") + from hbd.utils import shortname + uname = shortname(name) + + if uname not in hbdcls.Host.hosts: + host = hbdcls.Host(uname) + host.dyn = uname in cfg.get("dyndnshosts", []) + if verbose: + print(("XX: New host, num now %s" % (len(hbdcls.Host.hosts)))) + newh = True + else: + host = hbdcls.Host.hosts[uname] + newh = False + + cid = msg.get("id", 0) + try: + rtt = float(msg.get("rtt", None)) + except Exception: + rtt = None + + if msg.get("ID") == "HTB": + host.doesack = msg.get("acks", -1) + host.setcver(msg.get("ver", 0)) + + try: + conn, res = host.conndata(cid, ip, rtt, now) + except Exception as e: + if DEBUG > 0: + print("conndata failed: %s" % e) + return + + if res: + if log: + log(uname, res) + if uname in cfg.get("watchhosts", []): + if email: + email("address change", "%s %s" % (host.name, res)) + if pushmsg: + pushmsg("%s %s" % (host.name, res)) + + interval = int(msg.get("interval", 0) or 0) + shutdown = msg.get("shutdown", 0) + service = msg.get("service", "unknown") + message = msg.get("msg", None) + boot = msg.get("boot", 0) + + if boot: + if log: + log(uname, "booted") + if uname in cfg.get("watchhosts", []): + m = "%s booted" % (host.name) + if email: + email("booted", m) + if pushmsg: + pushmsg(m) + if message: + if log: + log(uname, "msg: %s" % message, service=service) + if uname in cfg.get("watchhosts", []): + if email: + email("msg", message) + if pushmsg: + pushmsg(message) + + if conn.getstate() != hbdcls.Connection.UP: + lasts = conn.state + d = conn.newstate(hbdcls.Connection.UP, now) + m = "%s back after being %s for %s" % (conn.afam, lasts, dur(d)) + if log: + log(uname, m) + if uname in cfg.get("watchhosts", []): + if email: + email("%s back" % conn.afam, uname) + if pushmsg: + pushmsg("%s %s is back" % (uname, conn.afam)) + + if boot or newh: + host.upcount = host.doesack + else: + host.upcount += 1 + + if shutdown: + if log: + log(uname, "%s shutdown" % conn.afam) + if uname in cfg.get("watchhosts", []): + if email: + email("shutdown", "%s %s shutdown" % (uname, conn.afam)) + if pushmsg: + pushmsg("%s %s shutdown" % (uname, conn.afam)) + conn.newstate(hbdcls.Connection.DOWN, now) + + if interval > 0: + host.interval = interval + + # send ACK back + rmsg = {"time": __import__("time").time()} + if host.cver < 1: + opkt = b"ACK" + else: + opkt = dicttos("ACK", rmsg, host.cver > 1) + try: + transport.sendto(opkt, addr) + except Exception as e: + if DEBUG > 0: + print(("cannot send ack: %s" % e)) + + # send any commands we have queued + while len(host.cmds): + op, rmsg = host.cmds[0] + if op == "CMD": + if email: + email("%s cmd exec" % uname, "command '%s' sent" % rmsg) + del host.cmds[0] + if log: + log(uname, "command sent") + if host.cver < 1: + rmsg = rmsg["cmd"] + elif op == "UPD": + del host.cmds[0] + if log: + log(uname, "update initiated") + if host.cver < 1: + if log: + log(uname, " ver 0 does not support UPD") + continue + if host.cver < 1: + opkt = rmsg if isinstance(rmsg, (bytes, str)) else str(rmsg) + if isinstance(opkt, str): + opkt = opkt.encode() + else: + opkt = dicttos(op, rmsg, True) + try: + transport.sendto(opkt, addr) + except Exception as e: + if DEBUG > 0: + print(("cannot send cmd/update: %s" % e)) + + if msg_to_websockets: + try: + msg_to_websockets("host", host.stateinfo()) + except Exception: + pass + + diff --git a/hbd/utils.py b/hbd/utils.py new file mode 100644 index 0000000..7188dd9 --- /dev/null +++ b/hbd/utils.py @@ -0,0 +1,36 @@ +"""Utility helpers extracted from the original script.""" +import time + + +def shortname(name: str) -> str: + return name.split(".")[0] + + +def dur(sec: int) -> str: + sec = int(sec) + h = int(sec / 3600) + m = int((sec - h * 3600) / 60) + s = int((sec - h * 3600) % 60) + if h > 0: + return "%d:%02d:%02d" % (h, m, s) + if m > 0: + return "%d:%02d" % (m, s) + return "0:%02d" % s + + +def initlog(logfile: str): + """Open logfile for appending; fall back to creating it or returning stderr. + + This mirrors the original behaviour from the monolithic script. + """ + try: + return open(logfile, "a+") + except Exception: + pass + try: + return open(logfile, "w") + except Exception as e: + import sys + + print(f"cannot open logfile {logfile}, using STDERR: {e}") + return sys.stderr diff --git a/hbd/ws.py b/hbd/ws.py new file mode 100644 index 0000000..6f85cdb --- /dev/null +++ b/hbd/ws.py @@ -0,0 +1,125 @@ +"""WebSocket server and broadcast helpers for hbd. + +Provides an asyncio-based WebSocket server and a thread-safe broadcast +function that other threads or synchronous code can call. +""" +import asyncio +import json +import logging +from typing import Callable, Iterable, Optional + +import websockets + +logger = logging.getLogger(__name__) + +_connections = set() +_loop: Optional[asyncio.AbstractEventLoop] = None +_get_hosts: Optional[Callable[[], Iterable]] = None +_get_msgs: Optional[Callable[[], Iterable]] = None +_verbose = False + + +async def _handler(websocket, path): + global _connections + _connections.add(websocket) + remote_address = websocket.remote_address + if _verbose: + logger.info("DBG ws_serve: %s: %s", remote_address, path) + try: + # send initial hosts + if _get_hosts: + for h in _get_hosts(): + jmsg = json.dumps({"type": "host", "data": h}) + await websocket.send(jmsg) + # send recent messages + if _get_msgs: + for m in list(_get_msgs())[-100:]: + jmsg = json.dumps({"type": "message", "data": m}) + await websocket.send(jmsg) + + # keep connection open until client disconnects + async for _ in websocket: + # we don't expect meaningful incoming messages besides the initial + # client 'hello' that some clients send; ignore for now + if _verbose: + logger.debug("received ws data: %s", _) + + except (websockets.exceptions.ConnectionClosedOK, websockets.exceptions.ConnectionClosedError) as e: + if _verbose: + logger.info("ws closed: %r", e) + except Exception as e: + logger.exception("ws handler exception: %s", e) + finally: + try: + _connections.remove(websocket) + except KeyError: + pass + await websocket.wait_closed() + + +async def start(host: str, ws_port: int, wss_port: Optional[int] = None, ssl_context=None, get_hosts: Optional[Callable] = None, get_msgs: Optional[Callable] = None, verbose: bool = False): + """Start WebSocket servers and block until cancelled. + + This is intended to be awaited inside the main asyncio event loop. + If `wss_port` and `ssl_context` are provided, a WSS server will also be + started. + """ + global _loop, _get_hosts, _get_msgs, _verbose + _loop = asyncio.get_running_loop() + _get_hosts = get_hosts + _get_msgs = get_msgs + _verbose = verbose + + servers = [] + # plain WebSocket + ws_server = websockets.serve(_handler, host, ws_port, subprotocols=["hbd"]) + servers.append(ws_server) + + # secure WebSocket (optional) + if wss_port and ssl_context: + wss_server = websockets.serve(_handler, host, wss_port, ssl=ssl_context, subprotocols=["hbd"]) + servers.append(wss_server) + + # await starting of all servers + for srv in servers: + await srv + + if _verbose: + logger.info("WebSocket server started on port %s (wss %s)", ws_port, wss_port) + + # block forever (until loop is stopped or cancelled) + await asyncio.Future() + + +def broadcast(typ: str, data) -> bool: + """Thread-safe broadcast helper. + + Schedules coroutine(s) on the running loop to send message to all + connected websockets. Returns False if server was not running. + """ + global _loop + if not _loop: + return False + jmsg = json.dumps({"type": typ, "data": data}) + to_close = [] + for ws in list(_connections): + if ws.closed: + to_close.append(ws) + continue + try: + asyncio.run_coroutine_threadsafe(ws.send(jmsg), _loop) + except Exception: + to_close.append(ws) + logger.debug("ws.send exception: closed") + for ws in to_close: + try: + asyncio.run_coroutine_threadsafe(ws.wait_closed(), _loop) + except Exception: + pass + if ws in _connections: + _connections.remove(ws) + return True + + +def connection_count() -> int: + return len(_connections) diff --git a/heartbeat.egg-info/PKG-INFO b/heartbeat.egg-info/PKG-INFO new file mode 100644 index 0000000..687dd07 --- /dev/null +++ b/heartbeat.egg-info/PKG-INFO @@ -0,0 +1,193 @@ +Metadata-Version: 2.4 +Name: heartbeat +Version: 0.1.0 +Summary: Heartbeat daemon (hbd) — receive heartbeats and act on them +Author: heartbeat contributors +License: MIT +Keywords: heartbeat,monitoring,dns,websocket +Requires-Python: >=3.10 +Description-Content-Type: text/markdown +Requires-Dist: websockets>=13.2 +Requires-Dist: mattermostdriver>=7.3.0 +Requires-Dist: PyYAML>=6.0 +Requires-Dist: fastapi>=0.95.0 +Provides-Extra: dev +Requires-Dist: pytest>=7.0; extra == "dev" +Requires-Dist: pytest-cov>=4.0; extra == "dev" +Requires-Dist: flake8>=5.0; extra == "dev" +Requires-Dist: mypy>=1.10; extra == "dev" + + + +# Heartbeat Daemon (hbd) ✅ + +A lightweight daemon that listens for UDP heartbeat messages and acts on them: keeps host state, optionally updates DNS records via `nsupdate`, forwards messages to WebSocket clients, and sends notifications (email, Pushover, Mattermost, Signal). It is a refactor of a previously monolithic script into a modular Python package (`hbd`). + +--- + +## 📌 Features + +- Receive and parse heartbeat datagrams (text or zlib-compressed) ✅ +- Maintain host state and detect up/down transitions ✅ +- Queue DNS updates via `nsupdate` and run them in a background thread ✅ +- WebSocket API for live updates (hosts & messages) ✅ +- Notification pipeline (email, Pushover, Mattermost, Signal) ✅ +- Modular codebase suitable for unit testing and CI ✅ + +--- + +## ⚙️ Quickstart + +Prerequisites: +- Python 3.10+ (project uses language features from recent Python) +- `nsupdate` (for DNS updates) if using dynamic DNS + +Install dependencies (recommended into a venv): + +```bash +python3 -m venv .venv +source .venv/bin/activate +python -m pip install --upgrade pip +python -m pip install -r requirements.txt +# for development/testing tools +python -m pip install -r requirements-dev.txt +``` + +Run the daemon (example): + +```bash +# run with default config lookup (~/.hb.yaml) +PYTHONPATH=. hbd -c .hb.yaml -f -v +``` + +You can also run it directly via the package entrypoint after installation: + +```bash +python -m hbd.cli -c /path/to/config.yaml +``` + +## 🐞 Debugging in VS Code + +This repository includes a ready-to-use `.vscode/launch.json` with configurations to run or attach the VS Code debugger to `hbd`. + +- Ensure the **Python** extension is installed and select the project `.venv` as the interpreter (bottom-left of VS Code). +- Use **F5** and pick one of these configurations from the Run view: + - **Python: Run hbd (module)** — runs `hbd.cli` as a module and sets `PYTHONPATH` to the workspace root (recommended). + - **Python: Run hbd with debugpy (listen)** — launches `debugpy` and `hbd` together; useful when you want the process to listen for a debugger. + - **Python: Attach (localhost:5678)** — attach the debugger to a running process started with `debugpy`. + +To start `hbd` manually and wait for the debugger to attach, run: + +```bash +PYTHONPATH=. python -m debugpy --listen 5678 --wait-for-client -m hbd.cli -c .hb.yaml -f -v +``` + +Set breakpoints in modules such as `hbd/udp.py`, `hbd/dns.py`, or `hbd/server.py`, and use the **Attach** configuration to connect. Use `justMyCode: false` if you need to step into third-party code. + + +--- + +## 🛠 Configuration + +`hbd` reads YAML configuration (optional). If `PyYAML` is not installed, built-in defaults are used. Example configuration keys (see `hbd/config.py`): + +- `hb_port`: UDP port to listen for heartbeats (default: 50003) +- `hbd_port`: internal control port (default: 50004) +- `hbd_host`: bind address for HTTP/WSS +- `pickfile`: path for persisted state +- `logfile`: path to log file +- `logfmt`: `text` or `msg` +- `pushsrv`: push service (`pushover`|`mattermost`|`all`) +- `interval` / `grace`: heartbeat timing configuration +- `dyndomains`: list of dyndomains to update via `nsupdate` +- `nsupdate_bin`: path to nsupdate binary + +Example `.hb.yaml` (minimal): + +```yaml +hbd_host: 0.0.0.0 +hbd_port: 50004 +dyndomains: + - example.com +nsupdate_bin: /usr/bin/nsupdate +pushsrv: pushover +``` + +> Tip: `config.DEFAULTS` in `hbd/config.py` contains the canonical defaults and accepted configuration keys. + +--- + +## 🔧 Architecture & Modules + +- `hbd.proto` — serialization/deserialization of heartbeat messages (supports compressed payloads) +- `hbd.udp` — UDP parsing and `handle_datagram` implementation (main state machine) +- `hbd.dns` — `create_nsupdate_payload`, `nsupdate`, and a background DNS thread (`start_dns_thread`) +- `hbd.notify` — email and push notification helpers +- `hbd.ws` — WebSocket server and thread-safe broadcast helpers +- `hbd.http` — HTTP handler factory for the status UI/API +- `hbd.utils` — small utility helpers (`shortname`, `dur`, `initlog`) +- `hbd.cli` — CLI entrypoint and argument parsing +- `hbd.server` — async orchestration to run UDP/HTTP/WSS components + +This modular layout makes the code easier to test and maintain. + +--- + +## 🧪 Testing & Dev + +Tests are implemented using `unittest` and additional tests rely on `pytest` if you prefer. To run tests locally without installing anything beyond the dev requirements: + +```bash +# with project root on PYTHONPATH +PYTHONPATH=. python -m unittest discover -v +# or with pytest if installed +pytest -q +``` + +Developer tooling included: +- `pyproject.toml` — project metadata and dependencies +- `requirements-dev.txt` — dev/test dependencies +- `tox.ini` — convenience wrappers for running tests, lint, and mypy + +To run linters and type checks locally: + +```bash +# after installing dev deps +tox -e lint +tox -e mypy +``` + +--- + +## 🚀 Running in production + +- Use your system service manager (systemd, launchd, etc.) to run `hbd` in the background. +- Ensure `nsupdate` and necessary credentials are available for dynamic DNS updates. +- Configure TLS for WSS if you enable secure websockets. + +> Note: The project contains a small example for obtaining DNS-verified certs (certbot with RFC2136) — see earlier commit history or ask me to re-add the example to this README if you want it documented here. + +--- + +## 🤝 Contributing + +Contributions welcome! Please: +1. Open an issue to discuss larger changes. +2. Create a topic branch and a clear PR. +3. Add tests for new features and run linters. +4. Keep changes focused and documented. + +--- + +## 📜 License + +This repository is licensed under the MIT license. See `LICENSE` for details. + +--- + +If you'd like, I can also: +- add a **GitHub Actions** workflow that runs tests and lint on push/PR 🔁 +- add a `CONTRIBUTING.md` template for PRs and code style 💬 + +Which one should I do next? ✨ + diff --git a/heartbeat.egg-info/SOURCES.txt b/heartbeat.egg-info/SOURCES.txt new file mode 100644 index 0000000..3824948 --- /dev/null +++ b/heartbeat.egg-info/SOURCES.txt @@ -0,0 +1,23 @@ +README.md +pyproject.toml +hbd/__init__.py +hbd/cli.py +hbd/config.py +hbd/dns.py +hbd/http.py +hbd/notify.py +hbd/proto.py +hbd/server.py +hbd/udp.py +hbd/utils.py +hbd/ws.py +heartbeat.egg-info/PKG-INFO +heartbeat.egg-info/SOURCES.txt +heartbeat.egg-info/dependency_links.txt +heartbeat.egg-info/entry_points.txt +heartbeat.egg-info/requires.txt +heartbeat.egg-info/top_level.txt +tests/test_dns.py +tests/test_handle_datagram.py +tests/test_proto.py +tests/test_udp.py \ No newline at end of file diff --git a/heartbeat.egg-info/dependency_links.txt b/heartbeat.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/heartbeat.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/heartbeat.egg-info/entry_points.txt b/heartbeat.egg-info/entry_points.txt new file mode 100644 index 0000000..4391faa --- /dev/null +++ b/heartbeat.egg-info/entry_points.txt @@ -0,0 +1,2 @@ +[console_scripts] +hbd = hbd.cli:main diff --git a/heartbeat.egg-info/requires.txt b/heartbeat.egg-info/requires.txt new file mode 100644 index 0000000..67c791e --- /dev/null +++ b/heartbeat.egg-info/requires.txt @@ -0,0 +1,10 @@ +websockets>=13.2 +mattermostdriver>=7.3.0 +PyYAML>=6.0 +fastapi>=0.95.0 + +[dev] +pytest>=7.0 +pytest-cov>=4.0 +flake8>=5.0 +mypy>=1.10 diff --git a/heartbeat.egg-info/top_level.txt b/heartbeat.egg-info/top_level.txt new file mode 100644 index 0000000..2f7f30a --- /dev/null +++ b/heartbeat.egg-info/top_level.txt @@ -0,0 +1 @@ +hbd diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..0f671f0 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,1298 @@ +# This file is automatically @generated by Poetry 2.3.1 and should not be changed by hand. + +[[package]] +name = "annotated-doc" +version = "0.0.4" +description = "Document parameters, class attributes, return types, and variables inline, with Annotated." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320"}, + {file = "annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4"}, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, + {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, +] + +[[package]] +name = "anyio" +version = "4.12.1" +description = "High-level concurrency and networking framework on top of asyncio or Trio" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c"}, + {file = "anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703"}, +] + +[package.dependencies] +exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} +idna = ">=2.8" +typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} + +[package.extras] +trio = ["trio (>=0.31.0) ; python_version < \"3.10\"", "trio (>=0.32.0) ; python_version >= \"3.10\""] + +[[package]] +name = "certifi" +version = "2026.1.4" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c"}, + {file = "certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120"}, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aaba3b0819274cc41757a1da876f810a3e4d7b6eb25699253a4effef9e8e4af"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:778d2e08eda00f4256d7f672ca9fef386071c9202f5e4607920b86d7803387f2"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f155a433c2ec037d4e8df17d18922c3a0d9b3232a396690f17175d2946f0218d"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8bf8d0f749c5757af2142fe7903a9df1d2e8aa3841559b2bad34b08d0e2bcf3"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:194f08cbb32dc406d6e1aea671a68be0823673db2832b38405deba2fb0d88f63"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:6aee717dcfead04c6eb1ce3bd29ac1e22663cdea57f943c87d1eab9a025438d7"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:cd4b7ca9984e5e7985c12bc60a6f173f3c958eae74f3ef6624bb6b26e2abbae4"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:b7cf1017d601aa35e6bb650b6ad28652c9cd78ee6caff19f3c28d03e1c80acbf"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:e912091979546adf63357d7e2ccff9b44f026c075aeaf25a52d0e95ad2281074"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:5cb4d72eea50c8868f5288b7f7f33ed276118325c1dfd3957089f6b519e1382a"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-win32.whl", hash = "sha256:837c2ce8c5a65a2035be9b3569c684358dfbf109fd3b6969630a87535495ceaa"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:44c2a8734b333e0578090c4cd6b16f275e07aa6614ca8715e6c038e865e70576"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-win32.whl", hash = "sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-win_arm64.whl", hash = "sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50"}, + {file = "charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f"}, + {file = "charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a"}, +] + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = true +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["main"] +markers = "extra == \"dev\" and sys_platform == \"win32\"" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "coverage" +version = "7.13.3" +description = "Code coverage measurement for Python" +optional = true +python-versions = ">=3.10" +groups = ["main"] +markers = "extra == \"dev\"" +files = [ + {file = "coverage-7.13.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0b4f345f7265cdbdb5ec2521ffff15fa49de6d6c39abf89fc7ad68aa9e3a55f0"}, + {file = "coverage-7.13.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:96c3be8bae9d0333e403cc1a8eb078a7f928b5650bae94a18fb4820cc993fb9b"}, + {file = "coverage-7.13.3-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d6f4a21328ea49d38565b55599e1c02834e76583a6953e5586d65cb1efebd8f8"}, + {file = "coverage-7.13.3-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fc970575799a9d17d5c3fafd83a0f6ccf5d5117cdc9ad6fbd791e9ead82418b0"}, + {file = "coverage-7.13.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:87ff33b652b3556b05e204ae20793d1f872161b0fa5ec8a9ac76f8430e152ed6"}, + {file = "coverage-7.13.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7df8759ee57b9f3f7b66799b7660c282f4375bef620ade1686d6a7b03699e75f"}, + {file = "coverage-7.13.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f45c9bcb16bee25a798ccba8a2f6a1251b19de6a0d617bb365d7d2f386c4e20e"}, + {file = "coverage-7.13.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:318b2e4753cbf611061e01b6cc81477e1cdfeb69c36c4a14e6595e674caadb56"}, + {file = "coverage-7.13.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:24db3959de8ee394eeeca89ccb8ba25305c2da9a668dd44173394cbd5aa0777f"}, + {file = "coverage-7.13.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:be14d0622125edef21b3a4d8cd2d138c4872bf6e38adc90fd92385e3312f406a"}, + {file = "coverage-7.13.3-cp310-cp310-win32.whl", hash = "sha256:53be4aab8ddef18beb6188f3a3fdbf4d1af2277d098d4e618be3a8e6c88e74be"}, + {file = "coverage-7.13.3-cp310-cp310-win_amd64.whl", hash = "sha256:bfeee64ad8b4aae3233abb77eb6b52b51b05fa89da9645518671b9939a78732b"}, + {file = "coverage-7.13.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5907605ee20e126eeee2abe14aae137043c2c8af2fa9b38d2ab3b7a6b8137f73"}, + {file = "coverage-7.13.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a88705500988c8acad8b8fd86c2a933d3aa96bec1ddc4bc5cb256360db7bbd00"}, + {file = "coverage-7.13.3-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7bbb5aa9016c4c29e3432e087aa29ebee3f8fda089cfbfb4e6d64bd292dcd1c2"}, + {file = "coverage-7.13.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0c2be202a83dde768937a61cdc5d06bf9fb204048ca199d93479488e6247656c"}, + {file = "coverage-7.13.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f45e32ef383ce56e0ca099b2e02fcdf7950be4b1b56afaab27b4ad790befe5b"}, + {file = "coverage-7.13.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ed2e787249b922a93cd95c671cc9f4c9797a106e81b455c83a9ddb9d34590c0"}, + {file = "coverage-7.13.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:05dd25b21afffe545e808265897c35f32d3e4437663923e0d256d9ab5031fb14"}, + {file = "coverage-7.13.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:46d29926349b5c4f1ea4fca95e8c892835515f3600995a383fa9a923b5739ea4"}, + {file = "coverage-7.13.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:fae6a21537519c2af00245e834e5bf2884699cc7c1055738fd0f9dc37a3644ad"}, + {file = "coverage-7.13.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c672d4e2f0575a4ca2bf2aa0c5ced5188220ab806c1bb6d7179f70a11a017222"}, + {file = "coverage-7.13.3-cp311-cp311-win32.whl", hash = "sha256:fcda51c918c7a13ad93b5f89a58d56e3a072c9e0ba5c231b0ed81404bf2648fb"}, + {file = "coverage-7.13.3-cp311-cp311-win_amd64.whl", hash = "sha256:d1a049b5c51b3b679928dd35e47c4a2235e0b6128b479a7596d0ef5b42fa6301"}, + {file = "coverage-7.13.3-cp311-cp311-win_arm64.whl", hash = "sha256:79f2670c7e772f4917895c3d89aad59e01f3dbe68a4ed2d0373b431fad1dcfba"}, + {file = "coverage-7.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ed48b4170caa2c4420e0cd27dc977caaffc7eecc317355751df8373dddcef595"}, + {file = "coverage-7.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8f2adf4bcffbbec41f366f2e6dffb9d24e8172d16e91da5799c9b7ed6b5716e6"}, + {file = "coverage-7.13.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:01119735c690786b6966a1e9f098da4cd7ca9174c4cfe076d04e653105488395"}, + {file = "coverage-7.13.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8bb09e83c603f152d855f666d70a71765ca8e67332e5829e62cb9466c176af23"}, + {file = "coverage-7.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b607a40cba795cfac6d130220d25962931ce101f2f478a29822b19755377fb34"}, + {file = "coverage-7.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:44f14a62f5da2e9aedf9080e01d2cda61df39197d48e323538ec037336d68da8"}, + {file = "coverage-7.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:debf29e0b157769843dff0981cc76f79e0ed04e36bb773c6cac5f6029054bd8a"}, + {file = "coverage-7.13.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:824bb95cd71604031ae9a48edb91fd6effde669522f960375668ed21b36e3ec4"}, + {file = "coverage-7.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8f1010029a5b52dc427c8e2a8dbddb2303ddd180b806687d1acd1bb1d06649e7"}, + {file = "coverage-7.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cd5dee4fd7659d8306ffa79eeaaafd91fa30a302dac3af723b9b469e549247e0"}, + {file = "coverage-7.13.3-cp312-cp312-win32.whl", hash = "sha256:f7f153d0184d45f3873b3ad3ad22694fd73aadcb8cdbc4337ab4b41ea6b4dff1"}, + {file = "coverage-7.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:03a6e5e1e50819d6d7436f5bc40c92ded7e484e400716886ac921e35c133149d"}, + {file = "coverage-7.13.3-cp312-cp312-win_arm64.whl", hash = "sha256:51c4c42c0e7d09a822b08b6cf79b3c4db8333fffde7450da946719ba0d45730f"}, + {file = "coverage-7.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:853c3d3c79ff0db65797aad79dee6be020efd218ac4510f15a205f1e8d13ce25"}, + {file = "coverage-7.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f75695e157c83d374f88dcc646a60cb94173304a9258b2e74ba5a66b7614a51a"}, + {file = "coverage-7.13.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2d098709621d0819039f3f1e471ee554f55a0b2ac0d816883c765b14129b5627"}, + {file = "coverage-7.13.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:16d23d6579cf80a474ad160ca14d8b319abaa6db62759d6eef53b2fc979b58c8"}, + {file = "coverage-7.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:00d34b29a59d2076e6f318b30a00a69bf63687e30cd882984ed444e753990cc1"}, + {file = "coverage-7.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ab6d72bffac9deb6e6cb0f61042e748de3f9f8e98afb0375a8e64b0b6e11746b"}, + {file = "coverage-7.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e129328ad1258e49cae0123a3b5fcb93d6c2fa90d540f0b4c7cdcdc019aaa3dc"}, + {file = "coverage-7.13.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2213a8d88ed35459bda71597599d4eec7c2ebad201c88f0bfc2c26fd9b0dd2ea"}, + {file = "coverage-7.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:00dd3f02de6d5f5c9c3d95e3e036c3c2e2a669f8bf2d3ceb92505c4ce7838f67"}, + {file = "coverage-7.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f9bada7bc660d20b23d7d312ebe29e927b655cf414dadcdb6335a2075695bd86"}, + {file = "coverage-7.13.3-cp313-cp313-win32.whl", hash = "sha256:75b3c0300f3fa15809bd62d9ca8b170eb21fcf0100eb4b4154d6dc8b3a5bbd43"}, + {file = "coverage-7.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:a2f7589c6132c44c53f6e705e1a6677e2b7821378c22f7703b2cf5388d0d4587"}, + {file = "coverage-7.13.3-cp313-cp313-win_arm64.whl", hash = "sha256:123ceaf2b9d8c614f01110f908a341e05b1b305d6b2ada98763b9a5a59756051"}, + {file = "coverage-7.13.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:cc7fd0f726795420f3678ac82ff882c7fc33770bd0074463b5aef7293285ace9"}, + {file = "coverage-7.13.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d358dc408edc28730aed5477a69338e444e62fba0b7e9e4a131c505fadad691e"}, + {file = "coverage-7.13.3-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5d67b9ed6f7b5527b209b24b3df9f2e5bf0198c1bbf99c6971b0e2dcb7e2a107"}, + {file = "coverage-7.13.3-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:59224bfb2e9b37c1335ae35d00daa3a5b4e0b1a20f530be208fff1ecfa436f43"}, + {file = "coverage-7.13.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae9306b5299e31e31e0d3b908c66bcb6e7e3ddca143dea0266e9ce6c667346d3"}, + {file = "coverage-7.13.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:343aaeb5f8bb7bcd38620fd7bc56e6ee8207847d8c6103a1e7b72322d381ba4a"}, + {file = "coverage-7.13.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b2182129f4c101272ff5f2f18038d7b698db1bf8e7aa9e615cb48440899ad32e"}, + {file = "coverage-7.13.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:94d2ac94bd0cc57c5626f52f8c2fffed1444b5ae8c9fc68320306cc2b255e155"}, + {file = "coverage-7.13.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:65436cde5ecabe26fb2f0bf598962f0a054d3f23ad529361326ac002c61a2a1e"}, + {file = "coverage-7.13.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:db83b77f97129813dbd463a67e5335adc6a6a91db652cc085d60c2d512746f96"}, + {file = "coverage-7.13.3-cp313-cp313t-win32.whl", hash = "sha256:dfb428e41377e6b9ba1b0a32df6db5409cb089a0ed1d0a672dc4953ec110d84f"}, + {file = "coverage-7.13.3-cp313-cp313t-win_amd64.whl", hash = "sha256:5badd7e596e6b0c89aa8ec6d37f4473e4357f982ce57f9a2942b0221cd9cf60c"}, + {file = "coverage-7.13.3-cp313-cp313t-win_arm64.whl", hash = "sha256:989aa158c0eb19d83c76c26f4ba00dbb272485c56e452010a3450bdbc9daafd9"}, + {file = "coverage-7.13.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c6f6169bbdbdb85aab8ac0392d776948907267fcc91deeacf6f9d55f7a83ae3b"}, + {file = "coverage-7.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2f5e731627a3d5ef11a2a35aa0c6f7c435867c7ccbc391268eb4f2ca5dbdcc10"}, + {file = "coverage-7.13.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9db3a3285d91c0b70fab9f39f0a4aa37d375873677efe4e71e58d8321e8c5d39"}, + {file = "coverage-7.13.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:06e49c5897cb12e3f7ecdc111d44e97c4f6d0557b81a7a0204ed70a8b038f86f"}, + {file = "coverage-7.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb25061a66802df9fc13a9ba1967d25faa4dae0418db469264fd9860a921dde4"}, + {file = "coverage-7.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:99fee45adbb1caeb914da16f70e557fb7ff6ddc9e4b14de665bd41af631367ef"}, + {file = "coverage-7.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:318002f1fd819bdc1651c619268aa5bc853c35fa5cc6d1e8c96bd9cd6c828b75"}, + {file = "coverage-7.13.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:71295f2d1d170b9977dc386d46a7a1b7cbb30e5405492529b4c930113a33f895"}, + {file = "coverage-7.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5b1ad2e0dc672625c44bc4fe34514602a9fd8b10d52ddc414dc585f74453516c"}, + {file = "coverage-7.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b2beb64c145593a50d90db5c7178f55daeae129123b0d265bdb3cbec83e5194a"}, + {file = "coverage-7.13.3-cp314-cp314-win32.whl", hash = "sha256:3d1aed4f4e837a832df2f3b4f68a690eede0de4560a2dbc214ea0bc55aabcdb4"}, + {file = "coverage-7.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9f9efbbaf79f935d5fbe3ad814825cbce4f6cdb3054384cb49f0c0f496125fa0"}, + {file = "coverage-7.13.3-cp314-cp314-win_arm64.whl", hash = "sha256:31b6e889c53d4e6687ca63706148049494aace140cffece1c4dc6acadb70a7b3"}, + {file = "coverage-7.13.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c5e9787cec750793a19a28df7edd85ac4e49d3fb91721afcdc3b86f6c08d9aa8"}, + {file = "coverage-7.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5b86db331c682fd0e4be7098e6acee5e8a293f824d41487c667a93705d415ca"}, + {file = "coverage-7.13.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:edc7754932682d52cf6e7a71806e529ecd5ce660e630e8bd1d37109a2e5f63ba"}, + {file = "coverage-7.13.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3a16d6398666510a6886f67f43d9537bfd0e13aca299688a19daa84f543122f"}, + {file = "coverage-7.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:303d38b19626c1981e1bb067a9928236d88eb0e4479b18a74812f05a82071508"}, + {file = "coverage-7.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:284e06eadfe15ddfee2f4ee56631f164ef897a7d7d5a15bca5f0bb88889fc5ba"}, + {file = "coverage-7.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d401f0864a1d3198422816878e4e84ca89ec1c1bf166ecc0ae01380a39b888cd"}, + {file = "coverage-7.13.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3f379b02c18a64de78c4ccdddf1c81c2c5ae1956c72dacb9133d7dd7809794ab"}, + {file = "coverage-7.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:7a482f2da9086971efb12daca1d6547007ede3674ea06e16d7663414445c683e"}, + {file = "coverage-7.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:562136b0d401992118d9b49fbee5454e16f95f85b120a4226a04d816e33fe024"}, + {file = "coverage-7.13.3-cp314-cp314t-win32.whl", hash = "sha256:ca46e5c3be3b195098dd88711890b8011a9fa4feca942292bb84714ce5eab5d3"}, + {file = "coverage-7.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:06d316dbb3d9fd44cca05b2dbcfbef22948493d63a1f28e828d43e6cc505fed8"}, + {file = "coverage-7.13.3-cp314-cp314t-win_arm64.whl", hash = "sha256:299d66e9218193f9dc6e4880629ed7c4cd23486005166247c283fb98531656c3"}, + {file = "coverage-7.13.3-py3-none-any.whl", hash = "sha256:90a8af9dba6429b2573199622d72e0ebf024d6276f16abce394ad4d181bb0910"}, + {file = "coverage-7.13.3.tar.gz", hash = "sha256:f7f6182d3dfb8802c1747eacbfe611b669455b69b7c037484bb1efbbb56711ac"}, +] + +[package.dependencies] +tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} + +[package.extras] +toml = ["tomli ; python_full_version <= \"3.11.0a6\""] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +groups = ["main"] +markers = "python_version == \"3.10\"" +files = [ + {file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"}, + {file = "exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219"}, +] + +[package.dependencies] +typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "fastapi" +version = "0.128.0" +description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "fastapi-0.128.0-py3-none-any.whl", hash = "sha256:aebd93f9716ee3b4f4fcfe13ffb7cf308d99c9f3ab5622d8877441072561582d"}, + {file = "fastapi-0.128.0.tar.gz", hash = "sha256:1cc179e1cef10a6be60ffe429f79b829dce99d8de32d7acb7e6c8dfdf7f2645a"}, +] + +[package.dependencies] +annotated-doc = ">=0.0.2" +pydantic = ">=2.7.0" +starlette = ">=0.40.0,<0.51.0" +typing-extensions = ">=4.8.0" + +[package.extras] +all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "httpx (>=0.23.0,<1.0.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=3.1.5)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] +standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "httpx (>=0.23.0,<1.0.0)", "jinja2 (>=3.1.5)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] +standard-no-fastapi-cloud-cli = ["email-validator (>=2.0.0)", "fastapi-cli[standard-no-fastapi-cloud-cli] (>=0.0.8)", "httpx (>=0.23.0,<1.0.0)", "jinja2 (>=3.1.5)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] + +[[package]] +name = "flake8" +version = "7.3.0" +description = "the modular source code checker: pep8 pyflakes and co" +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"dev\"" +files = [ + {file = "flake8-7.3.0-py2.py3-none-any.whl", hash = "sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e"}, + {file = "flake8-7.3.0.tar.gz", hash = "sha256:fe044858146b9fc69b551a4b490d69cf960fcb78ad1edcb84e7fbb1b4a8e3872"}, +] + +[package.dependencies] +mccabe = ">=0.7.0,<0.8.0" +pycodestyle = ">=2.14.0,<2.15.0" +pyflakes = ">=3.4.0,<3.5.0" + +[[package]] +name = "idna" +version = "3.11" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"}, + {file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"}, +] + +[package.extras] +all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] + +[[package]] +name = "iniconfig" +version = "2.3.0" +description = "brain-dead simple config-ini parsing" +optional = true +python-versions = ">=3.10" +groups = ["main"] +markers = "extra == \"dev\"" +files = [ + {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, + {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +description = "A very fast and expressive template engine." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, + {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, +] + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + +[[package]] +name = "librt" +version = "0.7.8" +description = "Mypyc runtime library" +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"dev\" and platform_python_implementation != \"PyPy\"" +files = [ + {file = "librt-0.7.8-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b45306a1fc5f53c9330fbee134d8b3227fe5da2ab09813b892790400aa49352d"}, + {file = "librt-0.7.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:864c4b7083eeee250ed55135d2127b260d7eb4b5e953a9e5df09c852e327961b"}, + {file = "librt-0.7.8-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6938cc2de153bc927ed8d71c7d2f2ae01b4e96359126c602721340eb7ce1a92d"}, + {file = "librt-0.7.8-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:66daa6ac5de4288a5bbfbe55b4caa7bf0cd26b3269c7a476ffe8ce45f837f87d"}, + {file = "librt-0.7.8-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4864045f49dc9c974dadb942ac56a74cd0479a2aafa51ce272c490a82322ea3c"}, + {file = "librt-0.7.8-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a36515b1328dc5b3ffce79fe204985ca8572525452eacabee2166f44bb387b2c"}, + {file = "librt-0.7.8-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b7e7f140c5169798f90b80d6e607ed2ba5059784968a004107c88ad61fb3641d"}, + {file = "librt-0.7.8-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ff71447cb778a4f772ddc4ce360e6ba9c95527ed84a52096bd1bbf9fee2ec7c0"}, + {file = "librt-0.7.8-cp310-cp310-win32.whl", hash = "sha256:047164e5f68b7a8ebdf9fae91a3c2161d3192418aadd61ddd3a86a56cbe3dc85"}, + {file = "librt-0.7.8-cp310-cp310-win_amd64.whl", hash = "sha256:d6f254d096d84156a46a84861183c183d30734e52383602443292644d895047c"}, + {file = "librt-0.7.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ff3e9c11aa260c31493d4b3197d1e28dd07768594a4f92bec4506849d736248f"}, + {file = "librt-0.7.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ddb52499d0b3ed4aa88746aaf6f36a08314677d5c346234c3987ddc506404eac"}, + {file = "librt-0.7.8-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e9c0afebbe6ce177ae8edba0c7c4d626f2a0fc12c33bb993d163817c41a7a05c"}, + {file = "librt-0.7.8-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:631599598e2c76ded400c0a8722dec09217c89ff64dc54b060f598ed68e7d2a8"}, + {file = "librt-0.7.8-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c1ba843ae20db09b9d5c80475376168feb2640ce91cd9906414f23cc267a1ff"}, + {file = "librt-0.7.8-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b5b007bb22ea4b255d3ee39dfd06d12534de2fcc3438567d9f48cdaf67ae1ae3"}, + {file = "librt-0.7.8-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:dbd79caaf77a3f590cbe32dc2447f718772d6eea59656a7dcb9311161b10fa75"}, + {file = "librt-0.7.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:87808a8d1e0bd62a01cafc41f0fd6818b5a5d0ca0d8a55326a81643cdda8f873"}, + {file = "librt-0.7.8-cp311-cp311-win32.whl", hash = "sha256:31724b93baa91512bd0a376e7cf0b59d8b631ee17923b1218a65456fa9bda2e7"}, + {file = "librt-0.7.8-cp311-cp311-win_amd64.whl", hash = "sha256:978e8b5f13e52cf23a9e80f3286d7546baa70bc4ef35b51d97a709d0b28e537c"}, + {file = "librt-0.7.8-cp311-cp311-win_arm64.whl", hash = "sha256:20e3946863d872f7cabf7f77c6c9d370b8b3d74333d3a32471c50d3a86c0a232"}, + {file = "librt-0.7.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9b6943885b2d49c48d0cff23b16be830ba46b0152d98f62de49e735c6e655a63"}, + {file = "librt-0.7.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:46ef1f4b9b6cc364b11eea0ecc0897314447a66029ee1e55859acb3dd8757c93"}, + {file = "librt-0.7.8-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:907ad09cfab21e3c86e8f1f87858f7049d1097f77196959c033612f532b4e592"}, + {file = "librt-0.7.8-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2991b6c3775383752b3ca0204842743256f3ad3deeb1d0adc227d56b78a9a850"}, + {file = "librt-0.7.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03679b9856932b8c8f674e87aa3c55ea11c9274301f76ae8dc4d281bda55cf62"}, + {file = "librt-0.7.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3968762fec1b2ad34ce57458b6de25dbb4142713e9ca6279a0d352fa4e9f452b"}, + {file = "librt-0.7.8-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:bb7a7807523a31f03061288cc4ffc065d684c39db7644c676b47d89553c0d714"}, + {file = "librt-0.7.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad64a14b1e56e702e19b24aae108f18ad1bf7777f3af5fcd39f87d0c5a814449"}, + {file = "librt-0.7.8-cp312-cp312-win32.whl", hash = "sha256:0241a6ed65e6666236ea78203a73d800dbed896cf12ae25d026d75dc1fcd1dac"}, + {file = "librt-0.7.8-cp312-cp312-win_amd64.whl", hash = "sha256:6db5faf064b5bab9675c32a873436b31e01d66ca6984c6f7f92621656033a708"}, + {file = "librt-0.7.8-cp312-cp312-win_arm64.whl", hash = "sha256:57175aa93f804d2c08d2edb7213e09276bd49097611aefc37e3fa38d1fb99ad0"}, + {file = "librt-0.7.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4c3995abbbb60b3c129490fa985dfe6cac11d88fc3c36eeb4fb1449efbbb04fc"}, + {file = "librt-0.7.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:44e0c2cbc9bebd074cf2cdbe472ca185e824be4e74b1c63a8e934cea674bebf2"}, + {file = "librt-0.7.8-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d2f1e492cae964b3463a03dc77a7fe8742f7855d7258c7643f0ee32b6651dd3"}, + {file = "librt-0.7.8-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:451e7ffcef8f785831fdb791bd69211f47e95dc4c6ddff68e589058806f044c6"}, + {file = "librt-0.7.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3469e1af9f1380e093ae06bedcbdd11e407ac0b303a56bbe9afb1d6824d4982d"}, + {file = "librt-0.7.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f11b300027ce19a34f6d24ebb0a25fd0e24a9d53353225a5c1e6cadbf2916b2e"}, + {file = "librt-0.7.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4adc73614f0d3c97874f02f2c7fd2a27854e7e24ad532ea6b965459c5b757eca"}, + {file = "librt-0.7.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:60c299e555f87e4c01b2eca085dfccda1dde87f5a604bb45c2906b8305819a93"}, + {file = "librt-0.7.8-cp313-cp313-win32.whl", hash = "sha256:b09c52ed43a461994716082ee7d87618096851319bf695d57ec123f2ab708951"}, + {file = "librt-0.7.8-cp313-cp313-win_amd64.whl", hash = "sha256:f8f4a901a3fa28969d6e4519deceab56c55a09d691ea7b12ca830e2fa3461e34"}, + {file = "librt-0.7.8-cp313-cp313-win_arm64.whl", hash = "sha256:43d4e71b50763fcdcf64725ac680d8cfa1706c928b844794a7aa0fa9ac8e5f09"}, + {file = "librt-0.7.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:be927c3c94c74b05128089a955fba86501c3b544d1d300282cc1b4bd370cb418"}, + {file = "librt-0.7.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7b0803e9008c62a7ef79058233db7ff6f37a9933b8f2573c05b07ddafa226611"}, + {file = "librt-0.7.8-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:79feb4d00b2a4e0e05c9c56df707934f41fcb5fe53fd9efb7549068d0495b758"}, + {file = "librt-0.7.8-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b9122094e3f24aa759c38f46bd8863433820654927370250f460ae75488b66ea"}, + {file = "librt-0.7.8-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e03bea66af33c95ce3addf87a9bf1fcad8d33e757bc479957ddbc0e4f7207ac"}, + {file = "librt-0.7.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f1ade7f31675db00b514b98f9ab9a7698c7282dad4be7492589109471852d398"}, + {file = "librt-0.7.8-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a14229ac62adcf1b90a15992f1ab9c69ae8b99ffb23cb64a90878a6e8a2f5b81"}, + {file = "librt-0.7.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5bcaaf624fd24e6a0cb14beac37677f90793a96864c67c064a91458611446e83"}, + {file = "librt-0.7.8-cp314-cp314-win32.whl", hash = "sha256:7aa7d5457b6c542ecaed79cec4ad98534373c9757383973e638ccced0f11f46d"}, + {file = "librt-0.7.8-cp314-cp314-win_amd64.whl", hash = "sha256:3d1322800771bee4a91f3b4bd4e49abc7d35e65166821086e5afd1e6c0d9be44"}, + {file = "librt-0.7.8-cp314-cp314-win_arm64.whl", hash = "sha256:5363427bc6a8c3b1719f8f3845ea53553d301382928a86e8fab7984426949bce"}, + {file = "librt-0.7.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ca916919793a77e4a98d4a1701e345d337ce53be4a16620f063191f7322ac80f"}, + {file = "librt-0.7.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:54feb7b4f2f6706bb82325e836a01be805770443e2400f706e824e91f6441dde"}, + {file = "librt-0.7.8-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:39a4c76fee41007070f872b648cc2f711f9abf9a13d0c7162478043377b52c8e"}, + {file = "librt-0.7.8-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac9c8a458245c7de80bc1b9765b177055efff5803f08e548dd4bb9ab9a8d789b"}, + {file = "librt-0.7.8-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b67aa7eff150f075fda09d11f6bfb26edffd300f6ab1666759547581e8f666"}, + {file = "librt-0.7.8-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:535929b6eff670c593c34ff435d5440c3096f20fa72d63444608a5aef64dd581"}, + {file = "librt-0.7.8-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:63937bd0f4d1cb56653dc7ae900d6c52c41f0015e25aaf9902481ee79943b33a"}, + {file = "librt-0.7.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cf243da9e42d914036fd362ac3fa77d80a41cadcd11ad789b1b5eec4daaf67ca"}, + {file = "librt-0.7.8-cp314-cp314t-win32.whl", hash = "sha256:171ca3a0a06c643bd0a2f62a8944e1902c94aa8e5da4db1ea9a8daf872685365"}, + {file = "librt-0.7.8-cp314-cp314t-win_amd64.whl", hash = "sha256:445b7304145e24c60288a2f172b5ce2ca35c0f81605f5299f3fa567e189d2e32"}, + {file = "librt-0.7.8-cp314-cp314t-win_arm64.whl", hash = "sha256:8766ece9de08527deabcd7cb1b4f1a967a385d26e33e536d6d8913db6ef74f06"}, + {file = "librt-0.7.8-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c7e8f88f79308d86d8f39c491773cbb533d6cb7fa6476f35d711076ee04fceb6"}, + {file = "librt-0.7.8-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:389bd25a0db916e1d6bcb014f11aa9676cedaa485e9ec3752dfe19f196fd377b"}, + {file = "librt-0.7.8-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:73fd300f501a052f2ba52ede721232212f3b06503fa12665408ecfc9d8fd149c"}, + {file = "librt-0.7.8-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d772edc6a5f7835635c7562f6688e031f0b97e31d538412a852c49c9a6c92d5"}, + {file = "librt-0.7.8-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde8a130bd0f239e45503ab39fab239ace094d63ee1d6b67c25a63d741c0f71"}, + {file = "librt-0.7.8-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fdec6e2368ae4f796fc72fad7fd4bd1753715187e6d870932b0904609e7c878e"}, + {file = "librt-0.7.8-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:00105e7d541a8f2ee5be52caacea98a005e0478cfe78c8080fbb7b5d2b340c63"}, + {file = "librt-0.7.8-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c6f8947d3dfd7f91066c5b4385812c18be26c9d5a99ca56667547f2c39149d94"}, + {file = "librt-0.7.8-cp39-cp39-win32.whl", hash = "sha256:41d7bb1e07916aeb12ae4a44e3025db3691c4149ab788d0315781b4d29b86afb"}, + {file = "librt-0.7.8-cp39-cp39-win_amd64.whl", hash = "sha256:e90a8e237753c83b8e484d478d9a996dc5e39fd5bd4c6ce32563bc8123f132be"}, + {file = "librt-0.7.8.tar.gz", hash = "sha256:1a4ede613941d9c3470b0368be851df6bb78ab218635512d0370b27a277a0862"}, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +description = "Safely add untrusted strings to HTML/XML markup." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559"}, + {file = "markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1"}, + {file = "markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa"}, + {file = "markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8"}, + {file = "markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1"}, + {file = "markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad"}, + {file = "markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a"}, + {file = "markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19"}, + {file = "markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01"}, + {file = "markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c"}, + {file = "markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e"}, + {file = "markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b"}, + {file = "markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d"}, + {file = "markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c"}, + {file = "markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f"}, + {file = "markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795"}, + {file = "markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12"}, + {file = "markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed"}, + {file = "markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5"}, + {file = "markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485"}, + {file = "markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73"}, + {file = "markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287"}, + {file = "markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe"}, + {file = "markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe"}, + {file = "markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9"}, + {file = "markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581"}, + {file = "markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4"}, + {file = "markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab"}, + {file = "markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa"}, + {file = "markupsafe-3.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26"}, + {file = "markupsafe-3.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d"}, + {file = "markupsafe-3.0.3-cp39-cp39-win32.whl", hash = "sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7"}, + {file = "markupsafe-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e"}, + {file = "markupsafe-3.0.3-cp39-cp39-win_arm64.whl", hash = "sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8"}, + {file = "markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698"}, +] + +[[package]] +name = "mattermostdriver" +version = "7.3.2" +description = "A Python Mattermost Driver" +optional = false +python-versions = ">=3.5" +groups = ["main"] +files = [ + {file = "mattermostdriver-7.3.2-py3-none-any.whl", hash = "sha256:8c6f15da34873b6c88da8fa8da0342f94bef77fcd16294befd92fea7e008cd97"}, + {file = "mattermostdriver-7.3.2.tar.gz", hash = "sha256:2e4d7b4a17d3013e279c6f993746ea18cd60b45d8fa3be24f47bc2de22b9b3b4"}, +] + +[package.dependencies] +requests = ">=2.25" +websockets = ">=8" + +[[package]] +name = "mccabe" +version = "0.7.0" +description = "McCabe checker, plugin for flake8" +optional = true +python-versions = ">=3.6" +groups = ["main"] +markers = "extra == \"dev\"" +files = [ + {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, + {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, +] + +[[package]] +name = "mypy" +version = "1.19.1" +description = "Optional static typing for Python" +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"dev\"" +files = [ + {file = "mypy-1.19.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f05aa3d375b385734388e844bc01733bd33c644ab48e9684faa54e5389775ec"}, + {file = "mypy-1.19.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:022ea7279374af1a5d78dfcab853fe6a536eebfda4b59deab53cd21f6cd9f00b"}, + {file = "mypy-1.19.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee4c11e460685c3e0c64a4c5de82ae143622410950d6be863303a1c4ba0e36d6"}, + {file = "mypy-1.19.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de759aafbae8763283b2ee5869c7255391fbc4de3ff171f8f030b5ec48381b74"}, + {file = "mypy-1.19.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ab43590f9cd5108f41aacf9fca31841142c786827a74ab7cc8a2eacb634e09a1"}, + {file = "mypy-1.19.1-cp310-cp310-win_amd64.whl", hash = "sha256:2899753e2f61e571b3971747e302d5f420c3fd09650e1951e99f823bc3089dac"}, + {file = "mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288"}, + {file = "mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab"}, + {file = "mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6"}, + {file = "mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331"}, + {file = "mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925"}, + {file = "mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042"}, + {file = "mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1"}, + {file = "mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e"}, + {file = "mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2"}, + {file = "mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8"}, + {file = "mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a"}, + {file = "mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13"}, + {file = "mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250"}, + {file = "mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b"}, + {file = "mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e"}, + {file = "mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef"}, + {file = "mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75"}, + {file = "mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd"}, + {file = "mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1"}, + {file = "mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718"}, + {file = "mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b"}, + {file = "mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045"}, + {file = "mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957"}, + {file = "mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f"}, + {file = "mypy-1.19.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7bcfc336a03a1aaa26dfce9fff3e287a3ba99872a157561cbfcebe67c13308e3"}, + {file = "mypy-1.19.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b7951a701c07ea584c4fe327834b92a30825514c868b1f69c30445093fdd9d5a"}, + {file = "mypy-1.19.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b13cfdd6c87fc3efb69ea4ec18ef79c74c3f98b4e5498ca9b85ab3b2c2329a67"}, + {file = "mypy-1.19.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f28f99c824ecebcdaa2e55d82953e38ff60ee5ec938476796636b86afa3956e"}, + {file = "mypy-1.19.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c608937067d2fc5a4dd1a5ce92fd9e1398691b8c5d012d66e1ddd430e9244376"}, + {file = "mypy-1.19.1-cp39-cp39-win_amd64.whl", hash = "sha256:409088884802d511ee52ca067707b90c883426bd95514e8cfda8281dc2effe24"}, + {file = "mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247"}, + {file = "mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba"}, +] + +[package.dependencies] +librt = {version = ">=0.6.2", markers = "platform_python_implementation != \"PyPy\""} +mypy_extensions = ">=1.0.0" +pathspec = ">=0.9.0" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +typing_extensions = ">=4.6.0" + +[package.extras] +dmypy = ["psutil (>=4.0)"] +faster-cache = ["orjson"] +install-types = ["pip"] +mypyc = ["setuptools (>=50)"] +reports = ["lxml"] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = true +python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"dev\"" +files = [ + {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, + {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, +] + +[[package]] +name = "packaging" +version = "26.0" +description = "Core utilities for Python packages" +optional = true +python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"dev\"" +files = [ + {file = "packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529"}, + {file = "packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4"}, +] + +[[package]] +name = "pathspec" +version = "1.0.4" +description = "Utility library for gitignore style pattern matching of file paths." +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"dev\"" +files = [ + {file = "pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723"}, + {file = "pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645"}, +] + +[package.extras] +hyperscan = ["hyperscan (>=0.7)"] +optional = ["typing-extensions (>=4)"] +re2 = ["google-re2 (>=1.1)"] +tests = ["pytest (>=9)", "typing-extensions (>=4.15)"] + +[[package]] +name = "pluggy" +version = "1.6.0" +description = "plugin and hook calling mechanisms for python" +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"dev\"" +files = [ + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["coverage", "pytest", "pytest-benchmark"] + +[[package]] +name = "pycodestyle" +version = "2.14.0" +description = "Python style guide checker" +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"dev\"" +files = [ + {file = "pycodestyle-2.14.0-py2.py3-none-any.whl", hash = "sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d"}, + {file = "pycodestyle-2.14.0.tar.gz", hash = "sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783"}, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +description = "Data validation using Python type hints" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d"}, + {file = "pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49"}, +] + +[package.dependencies] +annotated-types = ">=0.6.0" +pydantic-core = "2.41.5" +typing-extensions = ">=4.14.1" +typing-inspection = ">=0.4.2" + +[package.extras] +email = ["email-validator (>=2.0.0)"] +timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +description = "Core functionality for Pydantic validation and serialization" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146"}, + {file = "pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c"}, + {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2"}, + {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556"}, + {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49"}, + {file = "pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba"}, + {file = "pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9"}, + {file = "pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6"}, + {file = "pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b"}, + {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284"}, + {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594"}, + {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e"}, + {file = "pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b"}, + {file = "pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe"}, + {file = "pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f"}, + {file = "pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7"}, + {file = "pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5"}, + {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c"}, + {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294"}, + {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1"}, + {file = "pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d"}, + {file = "pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815"}, + {file = "pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3"}, + {file = "pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9"}, + {file = "pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d"}, + {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740"}, + {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e"}, + {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858"}, + {file = "pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36"}, + {file = "pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11"}, + {file = "pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd"}, + {file = "pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a"}, + {file = "pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553"}, + {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90"}, + {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07"}, + {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb"}, + {file = "pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23"}, + {file = "pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf"}, + {file = "pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008"}, + {file = "pydantic_core-2.41.5-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:8bfeaf8735be79f225f3fefab7f941c712aaca36f1128c9d7e2352ee1aa87bdf"}, + {file = "pydantic_core-2.41.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:346285d28e4c8017da95144c7f3acd42740d637ff41946af5ce6e5e420502dd5"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a75dafbf87d6276ddc5b2bf6fae5254e3d0876b626eb24969a574fff9149ee5d"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7b93a4d08587e2b7e7882de461e82b6ed76d9026ce91ca7915e740ecc7855f60"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8465ab91a4bd96d36dde3263f06caa6a8a6019e4113f24dc753d79a8b3a3f82"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:299e0a22e7ae2b85c1a57f104538b2656e8ab1873511fd718a1c1c6f149b77b5"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:707625ef0983fcfb461acfaf14de2067c5942c6bb0f3b4c99158bed6fedd3cf3"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f41eb9797986d6ebac5e8edff36d5cef9de40def462311b3eb3eeded1431e425"}, + {file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0384e2e1021894b1ff5a786dbf94771e2986ebe2869533874d7e43bc79c6f504"}, + {file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:f0cd744688278965817fd0839c4a4116add48d23890d468bc436f78beb28abf5"}, + {file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:753e230374206729bf0a807954bcc6c150d3743928a73faffee51ac6557a03c3"}, + {file = "pydantic_core-2.41.5-cp39-cp39-win32.whl", hash = "sha256:873e0d5b4fb9b89ef7c2d2a963ea7d02879d9da0da8d9d4933dee8ee86a8b460"}, + {file = "pydantic_core-2.41.5-cp39-cp39-win_amd64.whl", hash = "sha256:e4f4a984405e91527a0d62649ee21138f8e3d0ef103be488c1dc11a80d7f184b"}, + {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034"}, + {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c"}, + {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2"}, + {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad"}, + {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd"}, + {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc"}, + {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56"}, + {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51"}, + {file = "pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e"}, +] + +[package.dependencies] +typing-extensions = ">=4.14.1" + +[[package]] +name = "pyflakes" +version = "3.4.0" +description = "passive checker of Python programs" +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"dev\"" +files = [ + {file = "pyflakes-3.4.0-py2.py3-none-any.whl", hash = "sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f"}, + {file = "pyflakes-3.4.0.tar.gz", hash = "sha256:b24f96fafb7d2ab0ec5075b7350b3d2d2218eab42003821c06344973d3ea2f58"}, +] + +[[package]] +name = "pygments" +version = "2.19.2" +description = "Pygments is a syntax highlighting package written in Python." +optional = true +python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"dev\"" +files = [ + {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, + {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + +[[package]] +name = "pytest" +version = "9.0.2" +description = "pytest: simple powerful testing with Python" +optional = true +python-versions = ">=3.10" +groups = ["main"] +markers = "extra == \"dev\"" +files = [ + {file = "pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b"}, + {file = "pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11"}, +] + +[package.dependencies] +colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""} +iniconfig = ">=1.0.1" +packaging = ">=22" +pluggy = ">=1.5,<2" +pygments = ">=2.7.2" +tomli = {version = ">=1", markers = "python_version < \"3.11\""} + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-cov" +version = "7.0.0" +description = "Pytest plugin for measuring coverage." +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"dev\"" +files = [ + {file = "pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861"}, + {file = "pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1"}, +] + +[package.dependencies] +coverage = {version = ">=7.10.6", extras = ["toml"]} +pluggy = ">=1.2" +pytest = ">=7" + +[package.extras] +testing = ["process-tests", "pytest-xdist", "virtualenv"] + +[[package]] +name = "pyyaml" +version = "6.0.3" +description = "YAML parser and emitter for Python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6"}, + {file = "PyYAML-6.0.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369"}, + {file = "PyYAML-6.0.3-cp38-cp38-win32.whl", hash = "sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295"}, + {file = "PyYAML-6.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b"}, + {file = "pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b"}, + {file = "pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b"}, + {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0"}, + {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69"}, + {file = "pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e"}, + {file = "pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c"}, + {file = "pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e"}, + {file = "pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d"}, + {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a"}, + {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4"}, + {file = "pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b"}, + {file = "pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf"}, + {file = "pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196"}, + {file = "pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc"}, + {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e"}, + {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea"}, + {file = "pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5"}, + {file = "pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b"}, + {file = "pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd"}, + {file = "pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8"}, + {file = "pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6"}, + {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6"}, + {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be"}, + {file = "pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26"}, + {file = "pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c"}, + {file = "pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb"}, + {file = "pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac"}, + {file = "pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5"}, + {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764"}, + {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35"}, + {file = "pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac"}, + {file = "pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3"}, + {file = "pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3"}, + {file = "pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c"}, + {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065"}, + {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65"}, + {file = "pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9"}, + {file = "pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b"}, + {file = "pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da"}, + {file = "pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a"}, + {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926"}, + {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7"}, + {file = "pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0"}, + {file = "pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007"}, + {file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"}, +] + +[[package]] +name = "requests" +version = "2.32.5" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"}, + {file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"}, +] + +[package.dependencies] +certifi = ">=2017.4.17" +charset_normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] + +[[package]] +name = "starlette" +version = "0.50.0" +description = "The little ASGI library that shines." +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca"}, + {file = "starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca"}, +] + +[package.dependencies] +anyio = ">=3.6.2,<5" +typing-extensions = {version = ">=4.10.0", markers = "python_version < \"3.13\""} + +[package.extras] +full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"] + +[[package]] +name = "tomli" +version = "2.4.0" +description = "A lil' TOML parser" +optional = true +python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"dev\" and python_full_version <= \"3.11.0a6\"" +files = [ + {file = "tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867"}, + {file = "tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9"}, + {file = "tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95"}, + {file = "tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76"}, + {file = "tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d"}, + {file = "tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576"}, + {file = "tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a"}, + {file = "tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa"}, + {file = "tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614"}, + {file = "tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1"}, + {file = "tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8"}, + {file = "tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a"}, + {file = "tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1"}, + {file = "tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b"}, + {file = "tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51"}, + {file = "tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729"}, + {file = "tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da"}, + {file = "tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3"}, + {file = "tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0"}, + {file = "tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e"}, + {file = "tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4"}, + {file = "tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e"}, + {file = "tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c"}, + {file = "tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f"}, + {file = "tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86"}, + {file = "tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87"}, + {file = "tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132"}, + {file = "tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6"}, + {file = "tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc"}, + {file = "tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66"}, + {file = "tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d"}, + {file = "tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702"}, + {file = "tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8"}, + {file = "tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776"}, + {file = "tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475"}, + {file = "tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2"}, + {file = "tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9"}, + {file = "tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0"}, + {file = "tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df"}, + {file = "tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d"}, + {file = "tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f"}, + {file = "tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b"}, + {file = "tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087"}, + {file = "tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd"}, + {file = "tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4"}, + {file = "tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a"}, + {file = "tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c"}, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +description = "Backported and Experimental Type Hints for Python 3.9+" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, + {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +description = "Runtime typing introspection tools" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"}, + {file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"}, +] + +[package.dependencies] +typing-extensions = ">=4.12.0" + +[[package]] +name = "urllib3" +version = "2.6.3" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"}, + {file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"}, +] + +[package.extras] +brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""] +h2 = ["h2 (>=4,<5)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] + +[[package]] +name = "websockets" +version = "16.0" +description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "websockets-16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a"}, + {file = "websockets-16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0"}, + {file = "websockets-16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:583b7c42688636f930688d712885cf1531326ee05effd982028212ccc13e5957"}, + {file = "websockets-16.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7d837379b647c0c4c2355c2499723f82f1635fd2c26510e1f587d89bc2199e72"}, + {file = "websockets-16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df57afc692e517a85e65b72e165356ed1df12386ecb879ad5693be08fac65dde"}, + {file = "websockets-16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2b9f1e0d69bc60a4a87349d50c09a037a2607918746f07de04df9e43252c77a3"}, + {file = "websockets-16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:335c23addf3d5e6a8633f9f8eda77efad001671e80b95c491dd0924587ece0b3"}, + {file = "websockets-16.0-cp310-cp310-win32.whl", hash = "sha256:37b31c1623c6605e4c00d466c9d633f9b812ea430c11c8a278774a1fde1acfa9"}, + {file = "websockets-16.0-cp310-cp310-win_amd64.whl", hash = "sha256:8e1dab317b6e77424356e11e99a432b7cb2f3ec8c5ab4dabbcee6add48f72b35"}, + {file = "websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8"}, + {file = "websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad"}, + {file = "websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d"}, + {file = "websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe"}, + {file = "websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b"}, + {file = "websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5"}, + {file = "websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64"}, + {file = "websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6"}, + {file = "websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac"}, + {file = "websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00"}, + {file = "websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79"}, + {file = "websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39"}, + {file = "websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c"}, + {file = "websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f"}, + {file = "websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1"}, + {file = "websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2"}, + {file = "websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89"}, + {file = "websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea"}, + {file = "websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9"}, + {file = "websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230"}, + {file = "websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c"}, + {file = "websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5"}, + {file = "websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82"}, + {file = "websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8"}, + {file = "websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f"}, + {file = "websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a"}, + {file = "websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156"}, + {file = "websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0"}, + {file = "websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904"}, + {file = "websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4"}, + {file = "websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e"}, + {file = "websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4"}, + {file = "websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1"}, + {file = "websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3"}, + {file = "websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8"}, + {file = "websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d"}, + {file = "websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244"}, + {file = "websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e"}, + {file = "websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641"}, + {file = "websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8"}, + {file = "websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e"}, + {file = "websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944"}, + {file = "websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206"}, + {file = "websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6"}, + {file = "websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd"}, + {file = "websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d"}, + {file = "websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03"}, + {file = "websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da"}, + {file = "websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c"}, + {file = "websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767"}, + {file = "websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec"}, + {file = "websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5"}, +] + +[extras] +dev = ["flake8", "mypy", "pytest", "pytest-cov"] + +[metadata] +lock-version = "2.1" +python-versions = ">=3.10" +content-hash = "15b749b39d9e26563c1ca77896e371e0505979ad053627c31a47f5de8e02bc32" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..0fc6107 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,39 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "heartbeat" +version = "0.1.0" +description = "Heartbeat daemon (hbd) — receive heartbeats and act on them" +readme = "README.md" +requires-python = ">=3.10" +license = { text = "MIT" } +keywords = ["heartbeat", "monitoring", "dns", "websocket"] + +authors = [ + { name = "heartbeat contributors" } +] + +dependencies = [ + "websockets>=13.2", + "mattermostdriver>=7.3.0", + "PyYAML>=6.0", + "Jinja2>=3.1.0",s + "fastapi>=0.95.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0", + "pytest-cov>=4.0", + "flake8>=5.0", + "mypy>=1.10", +] + +[project.scripts] +hbd = "hbd.cli:main" + +[tool.setuptools.packages.find] +where = ["."] +include = ["hbd*"] diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..1fc2af8 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,9 @@ +# Development requirements +pytest>=7.0 +pytest-cov>=4.0 +flake8>=5.0 +mypy>=1.10 +black>=23.0 +isort>=5.0 +pre-commit>=3.0 +tox>=4.0 diff --git a/templates/foot.html b/templates/foot.html new file mode 100644 index 0000000..11a5c45 --- /dev/null +++ b/templates/foot.html @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/templates/head.html b/templates/head.html new file mode 100644 index 0000000..ed15688 --- /dev/null +++ b/templates/head.html @@ -0,0 +1,7 @@ + + + + + {{ title }} + + \ No newline at end of file diff --git a/templates/live.html b/templates/live.html new file mode 100644 index 0000000..fd5507c --- /dev/null +++ b/templates/live.html @@ -0,0 +1,229 @@ + + + {% include 'head.html' %} + + + + + {% include 'menu.html' %} + +
+
+ + + + + + + + + + + + + + + + + +
NameVerIPv4 AddrStateLatenceyLast StateIPv6 AddrStateLatenceyLast State
+
+
+

Log of Events

+
+ +
+
+
+ {% include 'foot.html' %} + + + diff --git a/templates/menu.html b/templates/menu.html new file mode 100644 index 0000000..04d375a --- /dev/null +++ b/templates/menu.html @@ -0,0 +1,20 @@ + + +
{{ header }}
+ diff --git a/tests/test_dns.py b/tests/test_dns.py new file mode 100644 index 0000000..d0cf7d2 --- /dev/null +++ b/tests/test_dns.py @@ -0,0 +1,128 @@ +import time +import queue +import unittest +from unittest.mock import patch, MagicMock + +import hbd.dns as dns + + +class TestDNS(unittest.TestCase): + + def test_create_nsupdate_payload_ipv4(self): + p = dns.create_nsupdate_payload("host", "1.2.3.4", "example") + self.assertIn("update add host.dy.example 5 A 1.2.3.4", p) + self.assertNotIn("AAAA", p) + + def test_create_nsupdate_payload_ipv6(self): + p = dns.create_nsupdate_payload("host", "2001:db8::1", "example") + self.assertIn("update add host.dy.example 5 AAAA 2001:db8::1", p) + + @patch("hbd.dns.Popen") + def test_nsupdate_success(self, mock_popen): + proc = MagicMock() + proc.communicate.return_value = (b"status: NOERROR", None) + mock_popen.return_value = proc + + err = dns.nsupdate( + "host", + "1.2.3.4", + "example", + nsupdate_bin="/usr/bin/nsupdate", + rndc_key="/etc/rndc.key", + ) + + self.assertIsNone(err) + mock_popen.assert_called_once_with( + ["/usr/bin/nsupdate", "-k", "/etc/rndc.key", "-v"], + shell=False, + bufsize=0, + stdin=dns.PIPE, + stdout=dns.PIPE, + stderr=dns.STDOUT, + ) + + @patch("hbd.dns.Popen") + def test_nsupdate_failure(self, mock_popen): + proc = MagicMock() + proc.communicate.return_value = (b"some error", None) + mock_popen.return_value = proc + + err = dns.nsupdate("host", "1.2.3.4", "example", nsupdate_bin="/usr/bin/nsupdate", rndc_key="/etc/rndc.key") + self.assertIsNotNone(err) + self.assertIn("some error", err) + + def test_dnsupdatethread_processes_queue(self): + # patch nsupdate to succeed + with patch("hbd.dns.nsupdate", return_value=None): + logs = [] + + def log(h, m): + logs.append((h, m)) + + emails = [] + + def email(s, m): + emails.append((s, m)) + + class FakeHost: + dnsQ = queue.Queue() + + class FakeHbd: + Host = FakeHost + + # start the thread (daemon) that processes the queue + t = dns.start_dns_thread(FakeHbd, {"dyndomains": ["example"]}, log=log, email=email) + self.assertTrue(t.is_alive()) + + # enqueue one item and wait for it to be processed (polling with timeout) + FakeHbd.Host.dnsQ.put(("testhost", "1.2.3.4")) + + for _ in range(30): + if logs: + break + time.sleep(0.1) + + self.assertTrue(logs, "dnsupdatethread did not call log") + self.assertTrue(any("changed address" in m or "DNS updated" in m for (_h, m) in logs)) + + def test_dnsupdatethread_calls_email_on_failure(self): + # patch nsupdate to fail with an error message + with patch("hbd.dns.nsupdate", return_value="error: failed"): + logs = [] + + def log(h, m): + logs.append((h, m)) + + emails = [] + + def email(s, m): + emails.append((s, m)) + + class FakeHost: + dnsQ = queue.Queue() + + class FakeHbd: + Host = FakeHost + + t = dns.start_dns_thread(FakeHbd, {"dyndomains": ["example"]}, log=log, email=email) + # enqueue and wait for the email to be sent + FakeHbd.Host.dnsQ.put(("testhost", "1.2.3.4")) + + for _ in range(30): + if emails: + break + time.sleep(0.1) + + self.assertTrue(emails, "dnsupdatethread did not call email on failure") + self.assertTrue(any("nsupdate failed" in s or "nsupdate failed" in m or "error" in m for (s, m) in emails)) + + @patch("hbd.dns.Popen") + def test_nsupdate_raises_oserror(self, mock_popen): + mock_popen.side_effect = OSError("noexec") + err = dns.nsupdate("h", "1.2.3.4", "example", nsupdate_bin="/usr/bin/nsupdate", rndc_key="/etc/rndc.key") + self.assertIsNotNone(err) + self.assertIn("execution failed", err) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_handle_datagram.py b/tests/test_handle_datagram.py new file mode 100644 index 0000000..7c686b6 --- /dev/null +++ b/tests/test_handle_datagram.py @@ -0,0 +1,47 @@ +from hbd.udp import handle_datagram, parse_message +from hbd.proto import dicttos + +class FakeTransport: + def __init__(self): + self.sent = [] + def sendto(self, data, addr): + self.sent.append((data, addr)) + + +def dummy_noop(*a, **k): + pass + + +def test_handle_cmd_sends_command(): + ftr = FakeTransport() + # prepare ctx linking to hbdclass + import hbdclass + + ctx = { + 'config': {'watchhosts':[], 'dyndnshosts':[]}, + 'hbdclass': hbdclass, + 'log': dummy_noop, + 'email': dummy_noop, + 'pushmsg': dummy_noop, + 'msg_to_websockets': dummy_noop, + 'msgs': [], + 'DEBUG': 0, + 'verbose': False, + } + + # create host by sending initial heartbeat + msg = parse_message(dicttos('HTB', {'name':'cmdhost','interval':10})) + handle_datagram(msg, ('127.0.0.1',50000), ftr, ctx) + assert ftr.sent[0][0] == b'ACK' + + # queue a CMD for the host and send another heartbeat; expect command sent + h = hbdclass.Host.hosts['cmdhost'] + h.cmds.append(('CMD', {'cmd': 'doit'})) + ftr.sent.clear() + msg2 = parse_message(dicttos('HTB', {'name':'cmdhost','interval':10})) + handle_datagram(msg2, ('127.0.0.1',50000), ftr, ctx) + # should have sent ACK and the command; last send should be non-empty + assert len(ftr.sent) >= 1 + # the command for cver 0 will be sent as raw cmd string + # so at least one send contains b'doit' or similar + assert any(b'doit' in s[0] for s in ftr.sent) diff --git a/tests/test_proto.py b/tests/test_proto.py new file mode 100644 index 0000000..16a4044 --- /dev/null +++ b/tests/test_proto.py @@ -0,0 +1,25 @@ +import pytest +from hbd.proto import dicttos, stodict, oldmtodict + + +def test_dicttos_and_stodict_uncompressed(): + msg = dicttos("HTB", {"name": "host.example", "interval": 10}) + d = stodict(msg) + assert d["ID"].startswith("HTB") + assert d["name"] == "host.example" + assert d["interval"] == 10 + + +def test_dicttos_and_stodict_compressed(): + msg = dicttos("ACK", {"time": 12345}, compress=True) + d = stodict(msg) + # for compressed the original code included the colon in ID slice; ensure no crash + assert "ID" in d + + +def test_oldmtodict(): + msg = b"name=foo;interval=5" + d = oldmtodict(msg) + assert d["ID"].startswith("HTB") + assert d["name"] == "foo" + assert d["interval"] == 5 diff --git a/tests/test_udp.py b/tests/test_udp.py new file mode 100644 index 0000000..3495980 --- /dev/null +++ b/tests/test_udp.py @@ -0,0 +1,14 @@ +from hbd.udp import parse_message +from hbd.proto import dicttos + + +def test_parse_message_uncompressed(): + raw = dicttos('HTB', {'name': 'host', 'interval': 1}) + m = parse_message(raw) + assert m['ID'].startswith('HTB') + + +def test_parse_message_compressed(): + raw = dicttos('ACK', {'time': 1}, compress=True) + m = parse_message(raw) + assert 'ID' in m diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..ad7c1ef --- /dev/null +++ b/tox.ini @@ -0,0 +1,26 @@ +[tox] +envlist = py, lint, mypy +skipsdist = True + +[testenv] +deps = -rrequirements-dev.txt +commands = + pytest -q + +[testenv:lint] +description = run linters +deps = + flake8>=5.0 +commands = + flake8 hbd tests + +[testenv:mypy] +description = run mypy type checks +deps = + mypy>=1.10 +commands = + mypy hbd + +[flake8] +max-line-length = 88 +extend-ignore = E203