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 0000000..9187da9
Binary files /dev/null and b/dist/heartbeat-0.1.0-py3-none-any.whl differ
diff --git a/dist/heartbeat-0.1.0.tar.gz b/dist/heartbeat-0.1.0.tar.gz
new file mode 100644
index 0000000..6e92a28
Binary files /dev/null and b/dist/heartbeat-0.1.0.tar.gz differ
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' %}
+
+
+
+
+
+
+
+ | Name |
+ Ver |
+ IPv4 Addr |
+ State |
+ Latencey |
+ Last State |
+ IPv6 Addr |
+ State |
+ Latencey |
+ Last State |
+
+
+
+
+
+
+
+ {% 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 @@
+
+
+
+
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