feat: Add new updated version
All checks were successful
Build Docker / BuildImage (push) Successful in 1m0s

This commit is contained in:
2025-11-21 15:58:21 +11:00
parent f936973b8d
commit ff3f40beaf
9 changed files with 1049 additions and 426 deletions

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.13

69
main.py
View File

@@ -1,14 +1,10 @@
import time import sys
import signal import signal
import threading import threading
import concurrent.futures
from flask import Flask
from server import app, node_check_executor
import server import server
from gunicorn.app.base import BaseApplication from gunicorn.app.base import BaseApplication
import os import os
import dotenv import dotenv
import schedule
class GunicornApp(BaseApplication): class GunicornApp(BaseApplication):
@@ -26,28 +22,25 @@ class GunicornApp(BaseApplication):
return self.application return self.application
def check():
print('Checking nodes...', flush=True)
server.check_nodes()
def run_scheduler(stop_event):
schedule.every(5).minutes.do(check)
while not stop_event.is_set():
schedule.run_pending()
time.sleep(1)
def run_gunicorn(): def run_gunicorn():
workers = os.getenv('WORKERS', 1) workers = os.getenv('WORKERS', 1)
threads = os.getenv('THREADS', 2) threads = os.getenv('THREADS', 2)
try:
workers = int(workers) workers = int(workers)
except (ValueError, TypeError):
workers = 1
try:
threads = int(threads) threads = int(threads)
except (ValueError, TypeError):
threads = 2
options = { options = {
'bind': '0.0.0.0:5000', 'bind': '0.0.0.0:5000',
'workers': workers, 'workers': workers,
'threads': threads, 'threads': threads,
'timeout': 120,
} }
gunicorn_app = GunicornApp(server.app, options) gunicorn_app = GunicornApp(server.app, options)
@@ -57,39 +50,41 @@ def run_gunicorn():
def signal_handler(sig, frame): def signal_handler(sig, frame):
print("Shutting down gracefully...", flush=True) print("Shutting down gracefully...", flush=True)
stop_event.set()
# Shutdown the node check executor # Shutdown the scheduler
if server.scheduler.running:
print("Stopping scheduler...", flush=True)
server.scheduler.shutdown()
# Shutdown the node check executors
print("Shutting down thread pools...", flush=True) print("Shutting down thread pools...", flush=True)
node_check_executor.shutdown(wait=False) server.node_check_executor.shutdown(wait=False)
server.sub_check_executor.shutdown(wait=False)
sys.exit(0)
if __name__ == '__main__': if __name__ == '__main__':
dotenv.load_dotenv() dotenv.load_dotenv()
stop_event = threading.Event() # Register signal handlers
signal.signal(signal.SIGINT, signal_handler) signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler) signal.signal(signal.SIGTERM, signal_handler)
with concurrent.futures.ThreadPoolExecutor() as executor: # Start the scheduler from server.py
# Start the scheduler # This ensures we use the robust APScheduler defined there instead of the custom loop
scheduler_future = executor.submit(run_scheduler, stop_event) print("Starting background scheduler...", flush=True)
with server.app.app_context():
server.start_scheduler()
# Run an immediate check in a background thread so we don't block startup
startup_thread = threading.Thread(target=server.scheduled_node_check)
startup_thread.daemon = True
startup_thread.start()
try: try:
# Run the Gunicorn server # Run the Gunicorn server
run_gunicorn() run_gunicorn()
except KeyboardInterrupt: except KeyboardInterrupt:
print("Shutting down server...", flush=True) print("Shutting down server...", flush=True)
finally: signal_handler(signal.SIGINT, None)
stop_event.set()
scheduler_future.cancel()
# Make sure to shut down node check executor
node_check_executor.shutdown(wait=False)
try:
scheduler_future.result(timeout=5)
except concurrent.futures.CancelledError:
print("Scheduler stopped.")
except Exception as e:
print(f"Scheduler did not stop cleanly: {e}")

24
pyproject.toml Normal file
View File

@@ -0,0 +1,24 @@
[project]
name = "hnsdoh-status"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"apscheduler>=3.9.1",
"brotli>=1.2.0",
"dnslib>=0.9.26",
"dnspython>=2.8.0",
"flask>=3.1.2",
"flask-caching>=2.3.1",
"gunicorn>=23.0.0",
"python-dateutil>=2.9.0.post0",
"python-dotenv>=1.2.1",
"requests>=2.32.5",
"schedule>=1.2.2",
]
[dependency-groups]
dev = [
"ruff>=0.14.5",
]

225
server.py
View File

@@ -1,10 +1,9 @@
from collections import defaultdict from collections import defaultdict
from functools import cache, wraps from functools import wraps
import json import json
from flask import ( from flask import (
Flask, Flask,
make_response, make_response,
redirect,
request, request,
jsonify, jsonify,
render_template, render_template,
@@ -12,7 +11,6 @@ from flask import (
send_file, send_file,
) )
import os import os
import json
import requests import requests
import dns.resolver import dns.resolver
import dns.message import dns.message
@@ -21,25 +19,23 @@ import dns.name
import dns.rdatatype import dns.rdatatype
import ssl import ssl
import dnslib import dnslib
import dnslib.dns
import socket import socket
from datetime import datetime from datetime import datetime
from dateutil import relativedelta from dateutil import relativedelta
import dotenv import dotenv
import time import time
import logging import logging
import signal
import sys import sys
import signal
from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.interval import IntervalTrigger from apscheduler.triggers.interval import IntervalTrigger
from apscheduler.events import EVENT_JOB_ERROR, EVENT_JOB_EXECUTED from apscheduler.events import EVENT_JOB_ERROR, EVENT_JOB_EXECUTED
from flask_caching import Cache from flask_caching import Cache
import functools import functools
import io
import brotli import brotli
from io import BytesIO
import concurrent.futures import concurrent.futures
from threading import Lock from threading import Lock
import gc
# Set up logging BEFORE attempting imports that might fail # Set up logging BEFORE attempting imports that might fail
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
@@ -48,9 +44,13 @@ logger = logging.getLogger(__name__)
# Set up ThreadPoolExecutor for parallel node checking # Set up ThreadPoolExecutor for parallel node checking
# Use a reasonable number of workers based on CPU cores # Use a reasonable number of workers based on CPU cores
node_check_executor = concurrent.futures.ThreadPoolExecutor( node_check_executor = concurrent.futures.ThreadPoolExecutor(
max_workers=min(32, os.cpu_count() * 4) # Max 32 workers or 4x CPU cores max_workers=min(32, (os.cpu_count() or 1) * 4) # Max 32 workers or 4x CPU cores
) )
# Shared executor for sub-checks (DNS, DoH, DoT, Certs) to prevent thread churn
# Increased to 200 to accommodate all sub-tasks when max_workers (32) are active in the main pool
sub_check_executor = concurrent.futures.ThreadPoolExecutor(max_workers=200)
# Create a lock for thread safety when updating cache # Create a lock for thread safety when updating cache
cache_lock = Lock() cache_lock = Lock()
@@ -71,17 +71,17 @@ cache = Cache(app)
scheduler = BackgroundScheduler(daemon=True, job_defaults={'coalesce': True, 'max_instances': 1}) scheduler = BackgroundScheduler(daemon=True, job_defaults={'coalesce': True, 'max_instances': 1})
node_names = { node_names = {
"18.169.98.42": "Easy HNS",
"172.233.46.92": "Nathan.Woodburn/",
"194.50.5.27": "Nathan.Woodburn/", "194.50.5.27": "Nathan.Woodburn/",
"18.169.98.42": "Easy HNS",
"172.233.46.92": "Marioo",
"139.177.195.185": "HNSCanada", "139.177.195.185": "HNSCanada",
"172.105.120.203": "Nathan.Woodburn/", "172.105.120.203": "NameGuardian/",
"173.233.72.88": "Zorro" "173.233.72.88": "Zorro"
} }
node_locations = { node_locations = {
"194.50.5.27": "Australia",
"18.169.98.42": "England", "18.169.98.42": "England",
"172.233.46.92": "Netherlands", "172.233.46.92": "Netherlands",
"194.50.5.27": "Australia",
"139.177.195.185": "Canada", "139.177.195.185": "Canada",
"172.105.120.203": "Singapore", "172.105.120.203": "Singapore",
"173.233.72.88": "United States" "173.233.72.88": "United States"
@@ -110,7 +110,7 @@ else:
sent_notifications = json.load(file) sent_notifications = json.load(file)
if (os.getenv("NODES")): if (os.getenv("NODES")):
manual_nodes = os.getenv("NODES").split(",") manual_nodes = os.getenv("NODES","").split(",")
print(f"Log directory: {log_dir}", flush=True) print(f"Log directory: {log_dir}", flush=True)
@@ -179,10 +179,14 @@ def faviconPNG():
@app.route("/.well-known/<path:path>") @app.route("/.well-known/<path:path>")
def wellknown(path): def wellknown(path):
req = requests.get(f"https://nathan.woodburn.au/.well-known/{path}") try:
req = requests.get(f"https://nathan.woodburn.au/.well-known/{path}", timeout=10)
return make_response( return make_response(
req.content, 200, {"Content-Type": req.headers["Content-Type"]} req.content, 200, {"Content-Type": req.headers["Content-Type"]}
) )
except Exception as e:
logger.error(f"Error fetching well-known path {path}: {e}")
return make_response("Error fetching resource", 500)
# endregion # endregion
@@ -268,6 +272,19 @@ def build_dns_query(domain: str, qtype: str = "A"):
return q.pack() return q.pack()
def send_down_notification(node):
"""
Send a notification that a node is down.
This is a stub implementation to prevent crashes if the function is missing.
"""
try:
# Implement your notification logic here (e.g. email, discord, slack)
# For now, we just log it to avoid crashing
logger.warning(f"Node DOWN: {node.get('name')} ({node.get('ip')})")
except Exception as e:
logger.error(f"Error sending notification: {e}")
@retry(max_attempts=3, delay_seconds=2) @retry(max_attempts=3, delay_seconds=2)
def check_doh(ip: str) -> dict: def check_doh(ip: str) -> dict:
status = False status = False
@@ -351,12 +368,12 @@ def check_doh(ip: str) -> dict:
if ssock: if ssock:
try: try:
ssock.close() ssock.close()
except: except Exception:
pass pass
if sock and sock != ssock: if sock and sock != ssock:
try: try:
sock.close() sock.close()
except: except Exception:
pass pass
return {"status": status, "server": server_name} return {"status": status, "server": server_name}
@@ -435,12 +452,12 @@ def verify_cert(ip: str, port: int) -> dict:
if ssock: if ssock:
try: try:
ssock.close() ssock.close()
except: except Exception:
pass pass
if sock and sock != ssock: if sock and sock != ssock:
try: try:
sock.close() sock.close()
except: except Exception:
pass pass
return {"valid": valid, "expires": expires, "expiry_date": expiry_date_str} return {"valid": valid, "expires": expires, "expiry_date": expiry_date_str}
@@ -482,12 +499,19 @@ def format_last_check(last_log: datetime) -> str:
return "less than a minute ago" return "less than a minute ago"
def check_nodes() -> list: def check_nodes(force=False) -> list:
global nodes, _node_status_cache, _node_status_cache_time global nodes, _node_status_cache, _node_status_cache_time
if last_log > datetime.now() - relativedelta.relativedelta(minutes=1):
# Skip check if done recently, unless forced
if not force and last_log > datetime.now() - relativedelta.relativedelta(minutes=1):
# Load the last log # Load the last log
try:
with open(f"{log_dir}/node_status.json", "r") as file: with open(f"{log_dir}/node_status.json", "r") as file:
data = json.load(file) data = json.load(file)
except (json.JSONDecodeError, FileNotFoundError) as e:
logger.error(f"Error reading node_status.json: {e}")
data = []
newest = { newest = {
"date": datetime.now() - relativedelta.relativedelta(years=1), "date": datetime.now() - relativedelta.relativedelta(years=1),
"nodes": [], "nodes": [],
@@ -542,6 +566,7 @@ def check_nodes() -> list:
# Send notifications if any nodes are down # Send notifications if any nodes are down
for node in node_status: for node in node_status:
try:
if ( if (
not node["plain_dns"] not node["plain_dns"]
or not node["doh"] or not node["doh"]
@@ -567,6 +592,8 @@ def check_nodes() -> list:
send_down_notification(node) send_down_notification(node)
except Exception as e: except Exception as e:
logger.error(f"Error processing certificate expiry for {node['ip']}: {e}") logger.error(f"Error processing certificate expiry for {node['ip']}: {e}")
except Exception as e:
logger.error(f"Error in notification loop for {node.get('ip')}: {e}")
return node_status return node_status
@@ -582,13 +609,12 @@ def check_single_node(ip):
cert_result = {"valid": False, "expires": "ERROR", "expiry_date": "ERROR"} cert_result = {"valid": False, "expires": "ERROR", "expiry_date": "ERROR"}
cert_853_result = {"valid": False, "expires": "ERROR", "expiry_date": "ERROR"} cert_853_result = {"valid": False, "expires": "ERROR", "expiry_date": "ERROR"}
# Use timeout to limit time spent on each check # Use shared executor to avoid creating/destroying thread pools constantly
with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor: future_plain_dns = sub_check_executor.submit(check_plain_dns, ip)
future_plain_dns = executor.submit(check_plain_dns, ip) future_doh = sub_check_executor.submit(check_doh, ip)
future_doh = executor.submit(check_doh, ip) future_dot = sub_check_executor.submit(check_dot, ip)
future_dot = executor.submit(check_dot, ip) future_cert = sub_check_executor.submit(verify_cert, ip, 443)
future_cert = executor.submit(verify_cert, ip, 443) future_cert_853 = sub_check_executor.submit(verify_cert, ip, 853)
future_cert_853 = executor.submit(verify_cert, ip, 853)
# Collect results with timeout # Collect results with timeout
try: try:
@@ -658,8 +684,12 @@ def log_status(node_status: list):
# Check if the file exists # Check if the file exists
filename = f"{log_dir}/node_status.json" filename = f"{log_dir}/node_status.json"
if os.path.isfile(filename): if os.path.isfile(filename):
try:
with open(filename, "r") as file: with open(filename, "r") as file:
data = json.load(file) data = json.load(file)
except json.JSONDecodeError:
logger.error(f"Corrupted JSON in {filename}, starting fresh")
data = []
else: else:
data = [] data = []
@@ -758,13 +788,13 @@ def summarize_history(history: list) -> dict:
# Update counts and last downtime # Update counts and last downtime
for key in ["plain_dns", "doh", "dot"]: for key in ["plain_dns", "doh", "dot"]:
status = node.get(key, "up") status = node.get(key, "up")
if status == False: if not status:
total_counts[ip][key]["down"] += 1 total_counts[ip][key]["down"] += 1
total_counts[ip][key]["total"] += 1 total_counts[ip][key]["total"] += 1
# Update last downtime for each key # Update last downtime for each key
for key in ["plain_dns", "doh", "dot"]: for key in ["plain_dns", "doh", "dot"]:
if node.get(key) == False: if not node.get(key):
# Check if the last downtime is more recent # Check if the last downtime is more recent
if nodes_status[ip][key]["last_down"] == "never": if nodes_status[ip][key]["last_down"] == "never":
nodes_status[ip][key]["last_down"] = date.strftime("%Y-%m-%d %H:%M:%S") nodes_status[ip][key]["last_down"] = date.strftime("%Y-%m-%d %H:%M:%S")
@@ -907,7 +937,7 @@ def api_history():
if "days" in request.args: if "days" in request.args:
try: try:
history_days = int(request.args["days"]) history_days = int(request.args["days"])
except: except Exception:
pass pass
history = get_history(history_days) history = get_history(history_days)
history_summary = summarize_history(history) history_summary = summarize_history(history)
@@ -929,12 +959,12 @@ def api_all():
if "history" in request.args: if "history" in request.args:
try: try:
history_days = int(request.args["history"]) history_days = int(request.args["history"])
except: except Exception:
pass pass
if "days" in request.args: if "days" in request.args:
try: try:
history_days = int(request.args["days"]) history_days = int(request.args["days"])
except: except Exception:
pass pass
history = get_history(history_days) history = get_history(history_days)
return jsonify(history) return jsonify(history)
@@ -942,7 +972,7 @@ def api_all():
@app.route("/api/refresh") @app.route("/api/refresh")
def api_refresh(): def api_refresh():
node_status = check_nodes() node_status = check_nodes(force=True)
return jsonify(node_status) return jsonify(node_status)
@app.route("/api/latest") @app.route("/api/latest")
@@ -1161,7 +1191,7 @@ def index():
if "history" in request.args: if "history" in request.args:
try: try:
history_days = int(request.args["history"]) history_days = int(request.args["history"])
except: except Exception:
pass pass
history = get_history(history_days) history = get_history(history_days)
history_summary = summarize_history(history) history_summary = summarize_history(history)
@@ -1183,18 +1213,8 @@ def index():
datetime.strptime(history_summary["overall"][key]["last_down"], "%Y-%m-%d %H:%M:%S") datetime.strptime(history_summary["overall"][key]["last_down"], "%Y-%m-%d %H:%M:%S")
) )
history_summary["nodes"] = convert_nodes_to_dict(history_summary["nodes"])
last_check = format_last_check(last_log) last_check = format_last_check(last_log)
# Replace true/false with up/down
for node in node_status:
for key in ["plain_dns", "doh", "dot"]:
if node[key]:
node[key] = "Up"
else:
node[key] = "Down"
return render_template( return render_template(
"index.html", "index.html",
nodes=node_status, nodes=node_status,
@@ -1259,11 +1279,16 @@ def scheduled_node_check():
nodes = [] # Reset node list to force refresh nodes = [] # Reset node list to force refresh
# Run the check (which now uses ThreadPoolExecutor) # Run the check (which now uses ThreadPoolExecutor)
node_status = check_nodes() # Force the check to bypass the 1-minute debounce
node_status = check_nodes(force=True)
# Clear relevant caches # Clear relevant caches
cache.delete_memoized(api_nodes) cache.delete_memoized(api_nodes)
cache.delete_memoized(api_errors) cache.delete_memoized(api_errors)
cache.delete_memoized(index) cache.delete_memoized(index)
# Force garbage collection to prevent memory leaks over long periods
gc.collect()
logger.info("Completed scheduled node check and updated caches") logger.info("Completed scheduled node check and updated caches")
except Exception as e: except Exception as e:
logger.error(f"Error in scheduled node check: {e}") logger.error(f"Error in scheduled node check: {e}")
@@ -1307,14 +1332,29 @@ def signal_handler(sig, frame):
if scheduler.running: if scheduler.running:
scheduler.shutdown() scheduler.shutdown()
logger.info("Scheduler shut down") logger.info("Scheduler shut down")
# Shutdown thread pools
logger.info("Shutting down thread pools...")
node_check_executor.shutdown(wait=False)
sub_check_executor.shutdown(wait=False)
sys.exit(0) sys.exit(0)
# Register the signal handlers
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
# Initialize the scheduler when the app starts without relying on @before_first_request # Initialize the scheduler when the app starts without relying on @before_first_request
# which is deprecated in newer Flask versions # which is deprecated in newer Flask versions
# Only start scheduler if we are the main process or not in debug mode reloader
if os.environ.get("WERKZEUG_RUN_MAIN") == "true" or __name__ == "__main__":
with app.app_context(): with app.app_context():
start_scheduler() start_scheduler()
# Run an immediate check # Run an immediate check in a separate thread to not block startup
scheduled_node_check() import threading
startup_thread = threading.Thread(target=scheduled_node_check)
startup_thread.daemon = True
startup_thread.start()
# Custom Brotli compression for responses # Custom Brotli compression for responses
@app.after_request @app.after_request
@@ -1362,7 +1402,7 @@ def add_compression(response):
def check_nodes_from_log(): def check_nodes_from_log():
"""Read the most recent node status from the log file.""" """Read the most recent node status from the log file."""
global _node_status_cache, _node_status_cache_time global _node_status_cache, _node_status_cache_time, last_log
# Return cached result if it's less than 2 minutes old (increased from 60s) # Return cached result if it's less than 2 minutes old (increased from 60s)
with cache_lock: with cache_lock:
@@ -1387,13 +1427,17 @@ def check_nodes_from_log():
newest = entry newest = entry
newest["date"] = entry_date newest["date"] = entry_date
# Update global last_log from the file data so the UI shows the correct time
if isinstance(newest["date"], datetime) and newest["date"] > last_log:
last_log = newest["date"]
# Update the cache # Update the cache
with cache_lock: with cache_lock:
_node_status_cache = newest["nodes"] _node_status_cache = newest["nodes"]
_node_status_cache_time = datetime.now() _node_status_cache_time = datetime.now()
return newest["nodes"] return newest["nodes"]
except Exception as e: except (json.JSONDecodeError, FileNotFoundError, Exception) as e:
logger.error(f"Error reading node status from log: {e}") logger.error(f"Error reading node status from log: {e}")
# If we can't read from the log, run a fresh check # If we can't read from the log, run a fresh check
return check_nodes() return check_nodes()
@@ -1434,86 +1478,7 @@ def quick_status():
logger.error(f"Error getting quick status: {e}") logger.error(f"Error getting quick status: {e}")
return jsonify({"status": "error", "message": str(e)}) return jsonify({"status": "error", "message": str(e)})
# Optimize check_single_node with shorter timeouts
def check_single_node(ip):
"""Check a single node and return its status."""
logger.info(f"Checking node {ip}")
try:
# Add timeout handling for individual checks
plain_dns_result = False
doh_result = {"status": False, "server": []}
dot_result = False
cert_result = {"valid": False, "expires": "ERROR", "expiry_date": "ERROR"}
cert_853_result = {"valid": False, "expires": "ERROR", "expiry_date": "ERROR"}
# Use timeout to limit time spent on each check
with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
future_plain_dns = executor.submit(check_plain_dns, ip)
future_doh = executor.submit(check_doh, ip)
future_dot = executor.submit(check_dot, ip)
future_cert = executor.submit(verify_cert, ip, 443)
future_cert_853 = executor.submit(verify_cert, ip, 853)
# Collect results with timeout
try:
plain_dns_result = future_plain_dns.result(timeout=5)
except (concurrent.futures.TimeoutError, Exception) as e:
logger.warning(f"Plain DNS check timed out for {ip}: {str(e)}")
try:
doh_result = future_doh.result(timeout=5)
except (concurrent.futures.TimeoutError, Exception) as e:
logger.warning(f"DoH check timed out for {ip}: {str(e)}")
try:
dot_result = future_dot.result(timeout=5)
except (concurrent.futures.TimeoutError, Exception) as e:
logger.warning(f"DoT check timed out for {ip}: {str(e)}")
try:
cert_result = future_cert.result(timeout=5)
except (concurrent.futures.TimeoutError, Exception) as e:
logger.warning(f"Cert check timed out for {ip}: {str(e)}")
try:
cert_853_result = future_cert_853.result(timeout=5)
except (concurrent.futures.TimeoutError, Exception) as e:
logger.warning(f"Cert 853 check timed out for {ip}: {str(e)}")
node_status = {
"ip": ip,
"name": node_names[ip] if ip in node_names else ip,
"location": (
node_locations[ip] if ip in node_locations else "Unknown"
),
"plain_dns": plain_dns_result,
"doh": doh_result["status"],
"doh_server": doh_result["server"],
"dot": dot_result,
"cert": cert_result,
"cert_853": cert_853_result,
}
logger.info(f"Node {ip} check complete")
return node_status
except Exception as e:
logger.error(f"Error checking node {ip}: {e}")
# Add a failed entry for this node to ensure it's still included
return {
"ip": ip,
"name": node_names[ip] if ip in node_names else ip,
"location": (
node_locations[ip] if ip in node_locations else "Unknown"
),
"plain_dns": False,
"doh": False,
"doh_server": [],
"dot": False,
"cert": {"valid": False, "expires": "ERROR", "expiry_date": "ERROR"},
"cert_853": {"valid": False, "expires": "ERROR", "expiry_date": "ERROR"},
}
# Run the app with threading enabled # Run the app with threading enabled
if __name__ == "__main__": if __name__ == "__main__":
# The scheduler is already started in the app context above # Disable debug mode for production reliability to prevent double execution of scheduler
# Run the Flask app with threading for better concurrency app.run(debug=False, port=5000, host="0.0.0.0", threaded=True)
app.run(debug=True, port=5000, host="0.0.0.0", threaded=True)

View File

@@ -4,17 +4,63 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Status | HNS DoH</title> <title>Page Not Found - HNSDoH Status</title>
<link rel="icon" href="/assets/img/HNS.png" type="image/png"> <link rel="stylesheet" href="/assets/style.css">
<link rel="stylesheet" href="/assets/css/404.css"> <link rel="icon" type="image/png" href="/favicon.png">
<style>
.error-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
text-align: center;
}
.error-code {
font-size: 6rem;
font-weight: 800;
color: var(--bg-card-hover);
line-height: 1;
margin-bottom: 1rem;
}
.error-title {
font-size: 2rem;
font-weight: 700;
margin-bottom: 1rem;
}
.error-msg {
color: var(--text-muted);
margin-bottom: 2rem;
max-width: 400px;
}
.btn {
display: inline-block;
background-color: var(--accent);
color: white;
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
font-weight: 600;
transition: opacity 0.2s;
}
.btn:hover {
opacity: 0.9;
text-decoration: none;
}
</style>
</head> </head>
<body> <body>
<div class="spacer"></div> <div class="container">
<div class="centre"> <div class="error-container">
<h1>404 | Page not found</h1> <div class="error-code">404</div>
<p>Sorry, the page you are looking for does not exist.</p> <div class="error-title">Page Not Found</div>
<p><a href="/">Go back to the homepage</a></p> <div class="error-msg">The page you are looking for might have been removed, had its name changed, or is temporarily unavailable.</div>
<a href="/" class="btn">Return Home</a>
</div>
<div class="footer">
<p>Powered by <a href="https://nathan.woodburn.au">Nathan.Woodburn/</a></p>
</div>
</div> </div>
</body> </body>

View File

@@ -4,42 +4,101 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>API | HNS DoH</title> <title>API Documentation - HNSDoH Status</title>
<link rel="icon" href="/assets/img/HNS.png" type="image/png"> <link rel="stylesheet" href="/assets/style.css">
<link rel="stylesheet" href="/assets/css/api.css"> <link rel="icon" type="image/png" href="/favicon.png">
<style>
.endpoint-card {
background-color: var(--bg-card);
border: 1px solid var(--border);
border-radius: 0.75rem;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.endpoint-method {
display: inline-block;
background-color: rgba(59, 130, 246, 0.15);
color: var(--accent);
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-weight: 700;
font-size: 0.75rem;
margin-right: 0.75rem;
}
.endpoint-route {
font-family: monospace;
font-size: 1.1rem;
color: var(--text-main);
}
.endpoint-desc {
margin-top: 0.75rem;
color: var(--text-muted);
}
.param-table {
width: 100%;
margin-top: 1rem;
border: 1px solid var(--border);
border-radius: 0.5rem;
overflow: hidden;
}
.param-table th { background-color: rgba(0,0,0,0.2); }
.param-table td, .param-table th { padding: 0.75rem; border-bottom: 1px solid var(--border); }
.param-table tr:last-child td { border-bottom: none; }
.back-link { display: inline-block; margin-bottom: 1rem; }
</style>
</head> </head>
<body> <body>
<div class="spacer"></div> <div class="container">
<div class="centre"> <header>
{% if endpoints %} <div>
<h1 style="font-size: xx-large;">API Info</h1> <h1>API Documentation</h1>
<p>Available endpoints:</p> <div class="meta">Programmatic access to status data</div>
<ul style="width: fit-content;margin: auto;"> </div>
{% for endpoint in endpoints %} <div>
<li class="top"> <a href="/" class="back-link">← Back to Dashboard</a>
<strong>{{ endpoint.route }}</strong> - {{ endpoint.description }} </div>
{% if endpoint.parameters %} </header>
<ul>
<li><em>Parameters:</em></li>
{% for param in endpoint.parameters %}
<li>
<strong>{{ param.name }}:</strong>
({{ param.type }}) - {{ param.description }}
</li>
{% endfor %}
</ul>
{% endif %}
</li>
{% endfor %}
</ul>
{% else %} <div class="endpoints-list">
<h1>404 | Page not found</h1> {% for endpoint in endpoints %}
<p>Sorry, the page you are looking for does not exist.</p> <div class="endpoint-card">
<p><a href="/">Go back to the homepage</a></p> <div>
<span class="endpoint-method">GET</span>
<a href="{{ endpoint.route }}" class="endpoint-route">{{ endpoint.route }}</a>
</div>
<div class="endpoint-desc">{{ endpoint.description }}</div>
{% if endpoint.parameters %}
<div class="param-table">
<table>
<thead>
<tr>
<th>Parameter</th>
<th>Type</th>
<th>Description</th>
</tr>
</thead>
<tbody>
{% for param in endpoint.parameters %}
<tr>
<td><code>{{ param.name }}</code></td>
<td><code>{{ param.type }}</code></td>
<td>{{ param.description }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %} {% endif %}
</div> </div>
{% endfor %}
</div>
<div class="footer">
<p>Powered by <a href="https://nathan.woodburn.au">Nathan.Woodburn/</a></p>
</div>
</div>
</body> </body>
</html> </html>

133
templates/assets/style.css Normal file
View File

@@ -0,0 +1,133 @@
:root {
--bg-body: #0f172a;
--bg-card: #1e293b;
--bg-card-hover: #334155;
--text-main: #f1f5f9;
--text-muted: #94a3b8;
--border: #334155;
--accent: #3b82f6;
--success: #22c55e;
--error: #ef4444;
--warning: #f59e0b;
--shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, -apple-system, sans-serif;
background-color: var(--bg-body);
color: var(--text-main);
line-height: 1.5;
padding: 2rem;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
header {
margin-bottom: 2rem;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--border);
padding-bottom: 1rem;
}
h1 { font-size: 1.5rem; font-weight: 700; }
h2 { font-size: 1.25rem; margin-bottom: 1rem; color: var(--text-main); }
.meta { color: var(--text-muted); font-size: 0.875rem; }
/* Alerts */
.alerts-section { margin-bottom: 2rem; }
.alert-box {
padding: 1rem;
border-radius: 0.5rem;
margin-bottom: 0.5rem;
font-weight: 500;
}
.alert-box.error { background-color: rgba(239, 68, 68, 0.1); border: 1px solid var(--error); color: #fca5a5; }
.alert-box.warning { background-color: rgba(245, 158, 11, 0.1); border: 1px solid var(--warning); color: #fcd34d; }
/* Grid */
.node-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.5rem;
margin-bottom: 3rem;
}
.card {
background-color: var(--bg-card);
border: 1px solid var(--border);
border-radius: 0.75rem;
padding: 1.5rem;
box-shadow: var(--shadow);
transition: transform 0.2s, border-color 0.2s;
}
.card:hover {
transform: translateY(-2px);
border-color: var(--text-muted);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--border);
}
.node-name { font-weight: 600; font-size: 1.125rem; }
.node-location { font-size: 0.875rem; color: var(--text-muted); }
.node-ip { font-family: monospace; font-size: 0.75rem; color: var(--text-muted); background: rgba(0,0,0,0.2); padding: 2px 6px; border-radius: 4px; }
.status-list { display: flex; flex-direction: column; gap: 0.75rem; }
.status-item { display: flex; justify-content: space-between; align-items: center; font-size: 0.875rem; }
.badge {
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.badge.up { background-color: rgba(34, 197, 94, 0.15); color: var(--success); }
.badge.down { background-color: rgba(239, 68, 68, 0.15); color: var(--error); }
.cert-info { font-size: 0.75rem; color: var(--text-muted); margin-top: 0.25rem; text-align: right; }
/* History Table */
.table-container {
background-color: var(--bg-card);
border-radius: 0.75rem;
border: 1px solid var(--border);
overflow-x: auto;
}
table { width: 100%; border-collapse: collapse; font-size: 0.875rem; }
th, td { padding: 1rem; text-align: left; border-bottom: 1px solid var(--border); }
th { background-color: rgba(0,0,0,0.2); font-weight: 600; color: var(--text-muted); }
tr:last-child td { border-bottom: none; }
tr:hover td { background-color: var(--bg-card-hover); }
.uptime-bar {
height: 6px;
background-color: var(--bg-body);
border-radius: 3px;
overflow: hidden;
width: 100px;
margin-top: 4px;
}
.uptime-fill { height: 100%; background-color: var(--success); }
.uptime-fill.warn { background-color: var(--warning); }
.uptime-fill.bad { background-color: var(--error); }
.footer { margin-top: 4rem; text-align: center; color: var(--text-muted); font-size: 0.875rem; }
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }

View File

@@ -1,174 +1,128 @@
<!DOCTYPE html> <!DOCTYPE html>
<html data-bs-theme="dark" lang="en-au"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Status | HNS DoH</title> <title>HNSDoH Status</title>
<meta name="twitter:image" content="https://status.hnsdoh.com/assets/img/HNS.png"> <link rel="stylesheet" href="/assets/style.css">
<meta name="twitter:card" content="summary"> <link rel="icon" type="image/png" href="/favicon.png">
<meta name="twitter:description" content="Access Handshake Domains with DNS over HTTPS">
<meta property="og:title" content="Status | HNS DoH">
<meta name="description" content="Access Handshake Domains with DNS over HTTPS">
<meta property="og:type" content="website">
<meta property="og:description" content="Access Handshake Domains with DNS over HTTPS">
<meta name="twitter:title" content="Status | HNS DoH">
<meta property="og:image" content="https://status.hnsdoh.com/assets/img/HNS.png">
<script type="application/ld+json">
{
"@context": "http://schema.org",
"@type": "WebSite",
"name": "Status | HNS DoH",
"url": "https://status.hnsdoh.com"
}
</script>
<link rel="icon" type="image/png" sizes="670x700" href="assets/img/HNS.png">
<link rel="icon" type="image/png" sizes="670x700" href="assets/img/HNSW.png" media="(prefers-color-scheme: dark)">
<link rel="icon" type="image/png" sizes="670x700" href="assets/img/HNS.png">
<link rel="icon" type="image/png" sizes="670x700" href="assets/img/HNSW.png" media="(prefers-color-scheme: dark)">
<link rel="icon" type="image/png" sizes="670x700" href="assets/img/HNS.png">
<link rel="icon" type="image/png" sizes="670x700" href="assets/img/HNS.png">
<link rel="icon" type="image/png" sizes="670x700" href="assets/img/HNS.png">
<link rel="stylesheet" href="assets/bootstrap/css/bootstrap.min.css">
<link rel="stylesheet" href="assets/css/bs-theme-overrides.css">
<link rel="stylesheet" href="assets/css/Navbar-Right-Links-Dark-icons.css">
<link rel="stylesheet" href="assets/css/Team-images.css">
<link rel="stylesheet" href="assets/css/index.css">
<link rel="manifest" href="manifest.json">
<script async src="https://umami.woodburn.au/script.js"
data-website-id="7e0ed7e4-3858-4124-a574-b57ac05aaad1"></script>
</head> </head>
<body> <body>
<div class="container">
<header> <header>
<nav class="navbar navbar-expand-md fixed-top bg-dark py-3" data-bs-theme="dark"> <div>
<div class="container-fluid"><a class="navbar-brand d-flex align-items-center" href="https://welcome.hnsdoh.com"><span <h1>HNSDoH Status</h1>
class="bs-icon-sm bs-icon-rounded bs-icon-primary d-flex justify-content-center align-items-center me-2 bs-icon"><img <div class="meta">Handshake DNS over HTTPS/TLS</div>
src="assets/img/HNSW.png" width="20px"></span><span>HNS DoH</span></a></div> </div>
</nav> <div class="meta">
Last checked: {{ last_check }}
</div>
</header> </header>
<div style="margin: 100px;"></div>
<section id="intro"> {% if alerts or warnings %}
<div class="text-center"> <div class="alerts-section">
<h1 class="text-center" style="font-size: 60px;">HNS DoH Status</h1>
<div class="errors">
<!-- Check if errors is empty -->
{% if alerts %}
<div class="alert alert-danger" role="alert">
<h4 class="alert-heading">Alert</h4>
{% for alert in alerts %} {% for alert in alerts %}
<p>{{ alert }}</p> <div class="alert-box error">{{ alert }}</div>
{% endfor %} {% endfor %}
</div>
{% endif %}
</div>
<div class="warnings">
<!-- Check if warnings is empty -->
{% if warnings %}
<div class="alert alert-warning" role="alert">
<h4 class="alert-heading">Warning</h4>
{% for warning in warnings %} {% for warning in warnings %}
<p>{{ warning }}</p> <div class="alert-box warning">{{ warning }}</div>
{% endfor %} {% endfor %}
</div> </div>
{% endif %} {% endif %}
</div> <h2>Node Status</h2>
</div> <div class="node-grid">
</section>
<section id="status"></section>
<div class="text-center" style="width: fit-content;margin: auto;max-width: 100%;">
<span style="font-size: smaller;margin-bottom: 10px;">Last check: {{last_check}}</span>
<div class="spacer"></div>
<div class="node" style="display: block;">
<div>
<h2>Overall Stats</h2>
</div>
<div class="node-info">
<p>Plain DNS: {{history.overall.plain_dns.percentage}}% uptime (last down
{{history.overall.plain_dns.last_down}})</p>
<p>DNS over HTTPS: {{history.overall.doh.percentage}}% uptime (last down
{{history.overall.doh.last_down}})</p>
<p>DNS over TLS: {{history.overall.dot.percentage}}% uptime (last down
{{history.overall.dot.last_down}})</p>
</div>
</div>
<div class="spacer"></div>
{% for node in nodes %} {% for node in nodes %}
<div class="node {{node.class}}"> <div class="card">
<div class="card-header">
<div> <div>
<h2>{{node.location}}</h2> <div class="node-name">{{ node.name }}</div>
</div> <div class="node-location">{{ node.location }}</div>
<div class="node-info">
<h5>Current Status</h5>
<p>Plain DNS: {{node.plain_dns}}</p>
<p>DNS over HTTPS: {{node.doh}}</p>
<p>DNS over TLS: {{node.dot}}</p>
<p>Certificate: {% if node.cert.valid %} Valid {% else %} Invalid {% endif %} (expires
{{node.cert.expires}})</p>
</div>
<div class="node-info">
<h5>Stats</h5>
<p>Plain DNS: {{history.nodes[node.ip].plain_dns.percentage}}% uptime (last down
{{history.nodes[node.ip].plain_dns.last_down}})</p>
<p>DNS over HTTPS: {{history.nodes[node.ip].doh.percentage}}% uptime (last down
{{history.nodes[node.ip].doh.last_down}})</p>
<p>DNS over TLS: {{history.nodes[node.ip].dot.percentage}}% uptime (last down
{{history.nodes[node.ip].dot.last_down}})</p>
</div>
<div class="node-info">
<p style="font-weight: bold;">{{node.name}}: {{node.ip}}</p>
</div> </div>
<div class="node-ip">{{ node.ip }}</div>
</div> </div>
<div class="status-list">
<div class="status-item">
<span>Plain DNS</span>
<span class="badge {{ 'up' if node.plain_dns else 'down' }}">
{{ 'UP' if node.plain_dns else 'DOWN' }}
</span>
</div>
<div class="status-item">
<span>DoH (443)</span>
<span class="badge {{ 'up' if node.doh else 'down' }}">
{{ 'UP' if node.doh else 'DOWN' }}
</span>
</div>
<div class="status-item">
<span>DoT (853)</span>
<span class="badge {{ 'up' if node.dot else 'down' }}">
{{ 'UP' if node.dot else 'DOWN' }}
</span>
</div>
<div class="status-item">
<span>Certificate</span>
<div style="text-align: right;">
<span class="badge {{ 'up' if node.cert.valid else 'down' }}">
{{ 'VALID' if node.cert.valid else 'INVALID' }}
</span>
{% if node.cert.valid %}
<div class="cert-info">Exp: {{ node.cert.expires }}</div>
{% endif %}
</div>
</div>
</div>
</div>
{% endfor %} {% endfor %}
</div> </div>
</section>
<section id="setup" <h2>30-Day History</h2>
style="min-height: 400px;padding-top: 10vh;text-align: center;margin-right: 10%;margin-left: 10%;" <div class="table-container">
data-bs-target="#navcol-5" data-bs-smooth-scroll="true"> <table>
<h3 class="display-1">Setup</h3> <thead>
<ul class="list-group"> <tr>
<li class="list-group-item"> <th>Node</th>
<div> <th style="text-align: center;">Plain DNS</th>
<h5 class="display-5">DNS over HTTPS</h5> <th style="text-align: center;">DoH</th>
<p>DNS over HTTPS is supported by most browsers. To add HNSDoH to your revolvers add this URL to <th style="text-align: center;">DoT</th>
your Secure DNS setting<br><code>https://hnsdoh.com/dns-query</code></p> </tr>
</thead>
<tbody>
{% for node in history.nodes %}
<tr>
<td>
<div class="node-name">{{ node.name }}</div>
<div class="node-location">{{ node.location }}</div>
</td>
{% for type in ['plain_dns', 'doh', 'dot'] %}
<td style="text-align: center;">
<div>{{ node[type].percentage }}%</div>
<div class="uptime-bar" style="margin: 4px auto 0;">
<div class="uptime-fill {% if node[type].percentage < 90 %}warn{% endif %} {% if node[type].percentage < 70 %}bad{% endif %}"
style="width: {{ node[type].percentage }}%"></div>
</div> </div>
</li> {% if node[type].last_down != 'never' %}
<li class="list-group-item"> <div class="cert-info" style="text-align: center;">Last outage: {{ node[type].last_down }}</div>
<div> {% endif %}
<h5 class="display-5">DNS over TLS</h5> </td>
<p>DNS over TLS is the best option for mobile phones. Simply set Private DNS to the {% endfor %}
hostname&nbsp;<br><code>hnsdoh.com</code></p> </tr>
{% endfor %}
</tbody>
</table>
</div> </div>
</li>
<li class="list-group-item"> <div class="footer">
<div> <p>Powered by <a href="https://nathan.woodburn.au">Nathan.Woodburn/</a></p>
<h5 class="display-5">Plain DNS</h5> <p><a href="/api">API Access</a></p>
<p>As a last resort you can use any of plain DNS below (best to chose 2 IPs from different
people)<br><br>- 194.50.5.27 (powered by Nathan.Woodburn/)<br>-&nbsp;139.177.195.185 (powered by
HNS Canada)<br>-&nbsp;172.233.46.92 (powered by Nathan.Woodburn/)<br>-&nbsp;172.105.120.203 (powered
by Nathan.Woodburn/)<br>-&nbsp;18.169.98.42 (powered by Easy HNS)<br><br>Alternative Providers (Not
running the HNSDoH software configuration)<br><br>- 194.50.5.26 (powered by
Nathan.Woodburn/)<br>- 194.50.5.28 (powered by Nathan.Woodburn/)<br>-&nbsp;139.144.68.241
(powered by HNS DNS)<br>- 139.144.68.242 (powered by HNS DNS)<br>- 2a01:7e01:e002:c300::
(powered by HNS DNS)<br>- 2a01:7e01:e002:c500:: (powered by HNS DNS)</p>
</div> </div>
</li>
</ul>
</section>
<footer class="text-center bg-dark">
<div class="container text-white py-4 py-lg-5">
<p class="text-muted mb-0">Copyright © 2024 HNSDoH</p>
</div> </div>
</footer>
<script src="assets/bootstrap/js/bootstrap.min.js"></script>
<script src="assets/js/bs-init.js"></script>
</body> </body>
</html> </html>

446
uv.lock generated Normal file
View File

@@ -0,0 +1,446 @@
version = 1
revision = 3
requires-python = ">=3.13"
[[package]]
name = "apscheduler"
version = "3.11.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "tzlocal" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d0/81/192db4f8471de5bc1f0d098783decffb1e6e69c4f8b4bc6711094691950b/apscheduler-3.11.1.tar.gz", hash = "sha256:0db77af6400c84d1747fe98a04b8b58f0080c77d11d338c4f507a9752880f221", size = 108044, upload-time = "2025-10-31T18:55:42.819Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/58/9f/d3c76f76c73fcc959d28e9def45b8b1cc3d7722660c5003b19c1022fd7f4/apscheduler-3.11.1-py3-none-any.whl", hash = "sha256:6162cb5683cb09923654fa9bdd3130c4be4bfda6ad8990971c9597ecd52965d2", size = 64278, upload-time = "2025-10-31T18:55:41.186Z" },
]
[[package]]
name = "blinker"
version = "1.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" },
]
[[package]]
name = "brotli"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f7/16/c92ca344d646e71a43b8bb353f0a6490d7f6e06210f8554c8f874e454285/brotli-1.2.0.tar.gz", hash = "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a", size = 7388632, upload-time = "2025-11-05T18:39:42.86Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6c/d4/4ad5432ac98c73096159d9ce7ffeb82d151c2ac84adcc6168e476bb54674/brotli-1.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9e5825ba2c9998375530504578fd4d5d1059d09621a02065d1b6bfc41a8e05ab", size = 861523, upload-time = "2025-11-05T18:38:34.67Z" },
{ url = "https://files.pythonhosted.org/packages/91/9f/9cc5bd03ee68a85dc4bc89114f7067c056a3c14b3d95f171918c088bf88d/brotli-1.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0cf8c3b8ba93d496b2fae778039e2f5ecc7cff99df84df337ca31d8f2252896c", size = 444289, upload-time = "2025-11-05T18:38:35.6Z" },
{ url = "https://files.pythonhosted.org/packages/2e/b6/fe84227c56a865d16a6614e2c4722864b380cb14b13f3e6bef441e73a85a/brotli-1.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8565e3cdc1808b1a34714b553b262c5de5fbda202285782173ec137fd13709f", size = 1528076, upload-time = "2025-11-05T18:38:36.639Z" },
{ url = "https://files.pythonhosted.org/packages/55/de/de4ae0aaca06c790371cf6e7ee93a024f6b4bb0568727da8c3de112e726c/brotli-1.2.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:26e8d3ecb0ee458a9804f47f21b74845cc823fd1bb19f02272be70774f56e2a6", size = 1626880, upload-time = "2025-11-05T18:38:37.623Z" },
{ url = "https://files.pythonhosted.org/packages/5f/16/a1b22cbea436642e071adcaf8d4b350a2ad02f5e0ad0da879a1be16188a0/brotli-1.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67a91c5187e1eec76a61625c77a6c8c785650f5b576ca732bd33ef58b0dff49c", size = 1419737, upload-time = "2025-11-05T18:38:38.729Z" },
{ url = "https://files.pythonhosted.org/packages/46/63/c968a97cbb3bdbf7f974ef5a6ab467a2879b82afbc5ffb65b8acbb744f95/brotli-1.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4ecdb3b6dc36e6d6e14d3a1bdc6c1057c8cbf80db04031d566eb6080ce283a48", size = 1484440, upload-time = "2025-11-05T18:38:39.916Z" },
{ url = "https://files.pythonhosted.org/packages/06/9d/102c67ea5c9fc171f423e8399e585dabea29b5bc79b05572891e70013cdd/brotli-1.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3e1b35d56856f3ed326b140d3c6d9db91740f22e14b06e840fe4bb1923439a18", size = 1593313, upload-time = "2025-11-05T18:38:41.24Z" },
{ url = "https://files.pythonhosted.org/packages/9e/4a/9526d14fa6b87bc827ba1755a8440e214ff90de03095cacd78a64abe2b7d/brotli-1.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:54a50a9dad16b32136b2241ddea9e4df159b41247b2ce6aac0b3276a66a8f1e5", size = 1487945, upload-time = "2025-11-05T18:38:42.277Z" },
{ url = "https://files.pythonhosted.org/packages/5b/e8/3fe1ffed70cbef83c5236166acaed7bb9c766509b157854c80e2f766b38c/brotli-1.2.0-cp313-cp313-win32.whl", hash = "sha256:1b1d6a4efedd53671c793be6dd760fcf2107da3a52331ad9ea429edf0902f27a", size = 334368, upload-time = "2025-11-05T18:38:43.345Z" },
{ url = "https://files.pythonhosted.org/packages/ff/91/e739587be970a113b37b821eae8097aac5a48e5f0eca438c22e4c7dd8648/brotli-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:b63daa43d82f0cdabf98dee215b375b4058cce72871fd07934f179885aad16e8", size = 369116, upload-time = "2025-11-05T18:38:44.609Z" },
{ url = "https://files.pythonhosted.org/packages/17/e1/298c2ddf786bb7347a1cd71d63a347a79e5712a7c0cba9e3c3458ebd976f/brotli-1.2.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6c12dad5cd04530323e723787ff762bac749a7b256a5bece32b2243dd5c27b21", size = 863080, upload-time = "2025-11-05T18:38:45.503Z" },
{ url = "https://files.pythonhosted.org/packages/84/0c/aac98e286ba66868b2b3b50338ffbd85a35c7122e9531a73a37a29763d38/brotli-1.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3219bd9e69868e57183316ee19c84e03e8f8b5a1d1f2667e1aa8c2f91cb061ac", size = 445453, upload-time = "2025-11-05T18:38:46.433Z" },
{ url = "https://files.pythonhosted.org/packages/ec/f1/0ca1f3f99ae300372635ab3fe2f7a79fa335fee3d874fa7f9e68575e0e62/brotli-1.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:963a08f3bebd8b75ac57661045402da15991468a621f014be54e50f53a58d19e", size = 1528168, upload-time = "2025-11-05T18:38:47.371Z" },
{ url = "https://files.pythonhosted.org/packages/d6/a6/2ebfc8f766d46df8d3e65b880a2e220732395e6d7dc312c1e1244b0f074a/brotli-1.2.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9322b9f8656782414b37e6af884146869d46ab85158201d82bab9abbcb971dc7", size = 1627098, upload-time = "2025-11-05T18:38:48.385Z" },
{ url = "https://files.pythonhosted.org/packages/f3/2f/0976d5b097ff8a22163b10617f76b2557f15f0f39d6a0fe1f02b1a53e92b/brotli-1.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf9cba6f5b78a2071ec6fb1e7bd39acf35071d90a81231d67e92d637776a6a63", size = 1419861, upload-time = "2025-11-05T18:38:49.372Z" },
{ url = "https://files.pythonhosted.org/packages/9c/97/d76df7176a2ce7616ff94c1fb72d307c9a30d2189fe877f3dd99af00ea5a/brotli-1.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7547369c4392b47d30a3467fe8c3330b4f2e0f7730e45e3103d7d636678a808b", size = 1484594, upload-time = "2025-11-05T18:38:50.655Z" },
{ url = "https://files.pythonhosted.org/packages/d3/93/14cf0b1216f43df5609f5b272050b0abd219e0b54ea80b47cef9867b45e7/brotli-1.2.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1530af5c3c275b8524f2e24841cbe2599d74462455e9bae5109e9ff42e9361", size = 1593455, upload-time = "2025-11-05T18:38:51.624Z" },
{ url = "https://files.pythonhosted.org/packages/b3/73/3183c9e41ca755713bdf2cc1d0810df742c09484e2e1ddd693bee53877c1/brotli-1.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d2d085ded05278d1c7f65560aae97b3160aeb2ea2c0b3e26204856beccb60888", size = 1488164, upload-time = "2025-11-05T18:38:53.079Z" },
{ url = "https://files.pythonhosted.org/packages/64/6a/0c78d8f3a582859236482fd9fa86a65a60328a00983006bcf6d83b7b2253/brotli-1.2.0-cp314-cp314-win32.whl", hash = "sha256:832c115a020e463c2f67664560449a7bea26b0c1fdd690352addad6d0a08714d", size = 339280, upload-time = "2025-11-05T18:38:54.02Z" },
{ url = "https://files.pythonhosted.org/packages/f5/10/56978295c14794b2c12007b07f3e41ba26acda9257457d7085b0bb3bb90c/brotli-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:e7c0af964e0b4e3412a0ebf341ea26ec767fa0b4cf81abb5e897c9338b5ad6a3", size = 375639, upload-time = "2025-11-05T18:38:55.67Z" },
]
[[package]]
name = "cachelib"
version = "0.13.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1d/69/0b5c1259e12fbcf5c2abe5934b5c0c1294ec0f845e2b4b2a51a91d79a4fb/cachelib-0.13.0.tar.gz", hash = "sha256:209d8996e3c57595bee274ff97116d1d73c4980b2fd9a34c7846cd07fd2e1a48", size = 34418, upload-time = "2024-04-13T14:18:27.782Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9b/42/960fc9896ddeb301716fdd554bab7941c35fb90a1dc7260b77df3366f87f/cachelib-0.13.0-py3-none-any.whl", hash = "sha256:8c8019e53b6302967d4e8329a504acf75e7bc46130291d30188a6e4e58162516", size = 20914, upload-time = "2024-04-13T14:18:26.361Z" },
]
[[package]]
name = "certifi"
version = "2025.11.12"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" },
]
[[package]]
name = "charset-normalizer"
version = "3.4.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" },
{ url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" },
{ url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" },
{ url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" },
{ url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" },
{ url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" },
{ url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" },
{ url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" },
{ url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" },
{ url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" },
{ url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" },
{ url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" },
{ url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" },
{ url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" },
{ url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" },
{ url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" },
{ url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" },
{ url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" },
{ url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" },
{ url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" },
{ url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" },
{ url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" },
{ url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" },
{ url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" },
{ url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" },
{ url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" },
{ url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" },
{ url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" },
{ url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" },
{ url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" },
{ url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" },
{ url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" },
{ url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
]
[[package]]
name = "click"
version = "8.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "dnslib"
version = "0.9.26"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/71/269f74ef9bc8ca453af2e1768d4f4c8e7ef5f894d058d27fd1b69c754d7f/dnslib-0.9.26.tar.gz", hash = "sha256:be56857534390b2fbd02935270019bacc5e6b411d156cb3921ac55a7fb51f1a8", size = 82901, upload-time = "2025-03-03T09:17:32.606Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/16/93/167364f10194e374119b211e475ed8763d7591ae4f7001b304282dde5825/dnslib-0.9.26-py3-none-any.whl", hash = "sha256:e68719e633d761747c7e91bd241019ef5a2b61a63f56025939e144c841a70e0d", size = 64161, upload-time = "2025-03-03T09:17:31.47Z" },
]
[[package]]
name = "dnspython"
version = "2.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },
]
[[package]]
name = "flask"
version = "3.1.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "blinker" },
{ name = "click" },
{ name = "itsdangerous" },
{ name = "jinja2" },
{ name = "markupsafe" },
{ name = "werkzeug" },
]
sdist = { url = "https://files.pythonhosted.org/packages/dc/6d/cfe3c0fcc5e477df242b98bfe186a4c34357b4847e87ecaef04507332dab/flask-3.1.2.tar.gz", hash = "sha256:bf656c15c80190ed628ad08cdfd3aaa35beb087855e2f494910aa3774cc4fd87", size = 720160, upload-time = "2025-08-19T21:03:21.205Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/f9/7f9263c5695f4bd0023734af91bedb2ff8209e8de6ead162f35d8dc762fd/flask-3.1.2-py3-none-any.whl", hash = "sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c", size = 103308, upload-time = "2025-08-19T21:03:19.499Z" },
]
[[package]]
name = "flask-caching"
version = "2.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cachelib" },
{ name = "flask" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e2/80/74846c8af58ed60972d64f23a6cd0c3ac0175677d7555dff9f51bf82c294/flask_caching-2.3.1.tar.gz", hash = "sha256:65d7fd1b4eebf810f844de7de6258254b3248296ee429bdcb3f741bcbf7b98c9", size = 67560, upload-time = "2025-02-23T01:34:40.207Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/00/bb/82daa5e2fcecafadcc8659ce5779679d0641666f9252a4d5a2ae987b0506/Flask_Caching-2.3.1-py3-none-any.whl", hash = "sha256:d3efcf600e5925ea5a2fcb810f13b341ae984f5b52c00e9d9070392f3ca10761", size = 28916, upload-time = "2025-02-23T01:34:37.749Z" },
]
[[package]]
name = "gunicorn"
version = "23.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "packaging" },
]
sdist = { url = "https://files.pythonhosted.org/packages/34/72/9614c465dc206155d93eff0ca20d42e1e35afc533971379482de953521a4/gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec", size = 375031, upload-time = "2024-08-10T20:25:27.378Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/7d/6dac2a6e1eba33ee43f318edbed4ff29151a49b5d37f080aad1e6469bca4/gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d", size = 85029, upload-time = "2024-08-10T20:25:24.996Z" },
]
[[package]]
name = "hnsdoh-status"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "apscheduler" },
{ name = "brotli" },
{ name = "dnslib" },
{ name = "dnspython" },
{ name = "flask" },
{ name = "flask-caching" },
{ name = "gunicorn" },
{ name = "python-dateutil" },
{ name = "python-dotenv" },
{ name = "requests" },
{ name = "schedule" },
]
[package.dev-dependencies]
dev = [
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "apscheduler", specifier = ">=3.9.1" },
{ name = "brotli", specifier = ">=1.2.0" },
{ name = "dnslib", specifier = ">=0.9.26" },
{ name = "dnspython", specifier = ">=2.8.0" },
{ name = "flask", specifier = ">=3.1.2" },
{ name = "flask-caching", specifier = ">=2.3.1" },
{ name = "gunicorn", specifier = ">=23.0.0" },
{ name = "python-dateutil", specifier = ">=2.9.0.post0" },
{ name = "python-dotenv", specifier = ">=1.2.1" },
{ name = "requests", specifier = ">=2.32.5" },
{ name = "schedule", specifier = ">=1.2.2" },
]
[package.metadata.requires-dev]
dev = [{ name = "ruff", specifier = ">=0.14.5" }]
[[package]]
name = "idna"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
name = "itsdangerous"
version = "2.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
]
[[package]]
name = "jinja2"
version = "3.1.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
[[package]]
name = "markupsafe"
version = "3.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
{ url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
{ url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
{ url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
{ url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
{ url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
{ url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
{ url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
{ url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
{ url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
{ url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
{ url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
{ url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
{ url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
{ url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
{ url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
{ url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
{ url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
{ url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
{ url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
{ url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
{ url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
{ url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
{ url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
{ url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
{ url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
{ url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
{ url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
{ url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
{ url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
{ url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
{ url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
{ url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
{ url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
{ url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
{ url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
{ url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
{ url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
{ url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
{ url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
{ url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
]
[[package]]
name = "packaging"
version = "25.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" },
]
[[package]]
name = "requests"
version = "2.32.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
]
[[package]]
name = "ruff"
version = "0.14.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/fa/fbb67a5780ae0f704876cb8ac92d6d76da41da4dc72b7ed3565ab18f2f52/ruff-0.14.5.tar.gz", hash = "sha256:8d3b48d7d8aad423d3137af7ab6c8b1e38e4de104800f0d596990f6ada1a9fc1", size = 5615944, upload-time = "2025-11-13T19:58:51.155Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/68/31/c07e9c535248d10836a94e4f4e8c5a31a1beed6f169b31405b227872d4f4/ruff-0.14.5-py3-none-linux_armv6l.whl", hash = "sha256:f3b8248123b586de44a8018bcc9fefe31d23dda57a34e6f0e1e53bd51fd63594", size = 13171630, upload-time = "2025-11-13T19:57:54.894Z" },
{ url = "https://files.pythonhosted.org/packages/8e/5c/283c62516dca697cd604c2796d1487396b7a436b2f0ecc3fd412aca470e0/ruff-0.14.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f7a75236570318c7a30edd7f5491945f0169de738d945ca8784500b517163a72", size = 13413925, upload-time = "2025-11-13T19:57:59.181Z" },
{ url = "https://files.pythonhosted.org/packages/b6/f3/aa319f4afc22cb6fcba2b9cdfc0f03bbf747e59ab7a8c5e90173857a1361/ruff-0.14.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6d146132d1ee115f8802356a2dc9a634dbf58184c51bff21f313e8cd1c74899a", size = 12574040, upload-time = "2025-11-13T19:58:02.056Z" },
{ url = "https://files.pythonhosted.org/packages/f9/7f/cb5845fcc7c7e88ed57f58670189fc2ff517fe2134c3821e77e29fd3b0c8/ruff-0.14.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2380596653dcd20b057794d55681571a257a42327da8894b93bbd6111aa801f", size = 13009755, upload-time = "2025-11-13T19:58:05.172Z" },
{ url = "https://files.pythonhosted.org/packages/21/d2/bcbedbb6bcb9253085981730687ddc0cc7b2e18e8dc13cf4453de905d7a0/ruff-0.14.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2d1fa985a42b1f075a098fa1ab9d472b712bdb17ad87a8ec86e45e7fa6273e68", size = 12937641, upload-time = "2025-11-13T19:58:08.345Z" },
{ url = "https://files.pythonhosted.org/packages/a4/58/e25de28a572bdd60ffc6bb71fc7fd25a94ec6a076942e372437649cbb02a/ruff-0.14.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88f0770d42b7fa02bbefddde15d235ca3aa24e2f0137388cc15b2dcbb1f7c7a7", size = 13610854, upload-time = "2025-11-13T19:58:11.419Z" },
{ url = "https://files.pythonhosted.org/packages/7d/24/43bb3fd23ecee9861970978ea1a7a63e12a204d319248a7e8af539984280/ruff-0.14.5-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:3676cb02b9061fee7294661071c4709fa21419ea9176087cb77e64410926eb78", size = 15061088, upload-time = "2025-11-13T19:58:14.551Z" },
{ url = "https://files.pythonhosted.org/packages/23/44/a022f288d61c2f8c8645b24c364b719aee293ffc7d633a2ca4d116b9c716/ruff-0.14.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b595bedf6bc9cab647c4a173a61acf4f1ac5f2b545203ba82f30fcb10b0318fb", size = 14734717, upload-time = "2025-11-13T19:58:17.518Z" },
{ url = "https://files.pythonhosted.org/packages/58/81/5c6ba44de7e44c91f68073e0658109d8373b0590940efe5bd7753a2585a3/ruff-0.14.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f55382725ad0bdb2e8ee2babcbbfb16f124f5a59496a2f6a46f1d9d99d93e6e2", size = 14028812, upload-time = "2025-11-13T19:58:20.533Z" },
{ url = "https://files.pythonhosted.org/packages/ad/ef/41a8b60f8462cb320f68615b00299ebb12660097c952c600c762078420f8/ruff-0.14.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7497d19dce23976bdaca24345ae131a1d38dcfe1b0850ad8e9e6e4fa321a6e19", size = 13825656, upload-time = "2025-11-13T19:58:23.345Z" },
{ url = "https://files.pythonhosted.org/packages/7c/00/207e5de737fdb59b39eb1fac806904fe05681981b46d6a6db9468501062e/ruff-0.14.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:410e781f1122d6be4f446981dd479470af86537fb0b8857f27a6e872f65a38e4", size = 13959922, upload-time = "2025-11-13T19:58:26.537Z" },
{ url = "https://files.pythonhosted.org/packages/bc/7e/fa1f5c2776db4be405040293618846a2dece5c70b050874c2d1f10f24776/ruff-0.14.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c01be527ef4c91a6d55e53b337bfe2c0f82af024cc1a33c44792d6844e2331e1", size = 12932501, upload-time = "2025-11-13T19:58:29.822Z" },
{ url = "https://files.pythonhosted.org/packages/67/d8/d86bf784d693a764b59479a6bbdc9515ae42c340a5dc5ab1dabef847bfaa/ruff-0.14.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f66e9bb762e68d66e48550b59c74314168ebb46199886c5c5aa0b0fbcc81b151", size = 12927319, upload-time = "2025-11-13T19:58:32.923Z" },
{ url = "https://files.pythonhosted.org/packages/ac/de/ee0b304d450ae007ce0cb3e455fe24fbcaaedae4ebaad6c23831c6663651/ruff-0.14.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d93be8f1fa01022337f1f8f3bcaa7ffee2d0b03f00922c45c2207954f351f465", size = 13206209, upload-time = "2025-11-13T19:58:35.952Z" },
{ url = "https://files.pythonhosted.org/packages/33/aa/193ca7e3a92d74f17d9d5771a765965d2cf42c86e6f0fd95b13969115723/ruff-0.14.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:c135d4b681f7401fe0e7312017e41aba9b3160861105726b76cfa14bc25aa367", size = 13953709, upload-time = "2025-11-13T19:58:39.002Z" },
{ url = "https://files.pythonhosted.org/packages/cc/f1/7119e42aa1d3bf036ffc9478885c2e248812b7de9abea4eae89163d2929d/ruff-0.14.5-py3-none-win32.whl", hash = "sha256:c83642e6fccfb6dea8b785eb9f456800dcd6a63f362238af5fc0c83d027dd08b", size = 12925808, upload-time = "2025-11-13T19:58:42.779Z" },
{ url = "https://files.pythonhosted.org/packages/3b/9d/7c0a255d21e0912114784e4a96bf62af0618e2190cae468cd82b13625ad2/ruff-0.14.5-py3-none-win_amd64.whl", hash = "sha256:9d55d7af7166f143c94eae1db3312f9ea8f95a4defef1979ed516dbb38c27621", size = 14331546, upload-time = "2025-11-13T19:58:45.691Z" },
{ url = "https://files.pythonhosted.org/packages/e5/80/69756670caedcf3b9be597a6e12276a6cf6197076eb62aad0c608f8efce0/ruff-0.14.5-py3-none-win_arm64.whl", hash = "sha256:4b700459d4649e2594b31f20a9de33bc7c19976d4746d8d0798ad959621d64a4", size = 13433331, upload-time = "2025-11-13T19:58:48.434Z" },
]
[[package]]
name = "schedule"
version = "1.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0c/91/b525790063015759f34447d4cf9d2ccb52cdee0f1dd6ff8764e863bcb74c/schedule-1.2.2.tar.gz", hash = "sha256:15fe9c75fe5fd9b9627f3f19cc0ef1420508f9f9a46f45cd0769ef75ede5f0b7", size = 26452, upload-time = "2024-06-18T20:03:14.633Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/a7/84c96b61fd13205f2cafbe263cdb2745965974bdf3e0078f121dfeca5f02/schedule-1.2.2-py3-none-any.whl", hash = "sha256:5bef4a2a0183abf44046ae0d164cadcac21b1db011bdd8102e4a0c1e91e06a7d", size = 12220, upload-time = "2024-05-25T18:41:59.121Z" },
]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]]
name = "tzdata"
version = "2025.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" },
]
[[package]]
name = "tzlocal"
version = "5.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" },
]
[[package]]
name = "urllib3"
version = "2.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" },
]
[[package]]
name = "werkzeug"
version = "3.1.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9f/69/83029f1f6300c5fb2471d621ab06f6ec6b3324685a2ce0f9777fd4a8b71e/werkzeug-3.1.3.tar.gz", hash = "sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746", size = 806925, upload-time = "2024-11-08T15:52:18.093Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/52/24/ab44c871b0f07f491e5d2ad12c9bd7358e527510618cb1b803a88e986db1/werkzeug-3.1.3-py3-none-any.whl", hash = "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e", size = 224498, upload-time = "2024-11-08T15:52:16.132Z" },
]