updates people updates

This commit is contained in:
2026-02-15 17:31:33 +01:00
parent cde554c981
commit 8a0e48b3cb
5 changed files with 441 additions and 302 deletions

View File

@@ -17,3 +17,5 @@ run_speedtest.py
uv.lock
.dockerignore
.python-version
speedtest.db
Dockerfile

292
app.py
View File

@@ -1,166 +1,148 @@
#!/usr/bin/env python3
"""
Flask app to visualize a large SQLite database of speed tests.
- Plots time series for down_90th and up_90th
- Serves a lazily loaded table (server-side pagination)
- Designed for ~1GB DB: efficient SQLite pragmas + timestamp cursor pagination
Run:
export DB_PATH="/path/to/your/speedtests.sqlite3"
python3 app.py
Then open http://127.0.0.1:5000
Optional: create an index (speeds up range scans by timestamp):
sqlite3 "$DB_PATH" "CREATE INDEX IF NOT EXISTS idx_speed_tests_ts ON speed_tests(timestamp);"
"""
from __future__ import annotations
import os
import math
import sqlite3
import time
from datetime import datetime
import pytz
from flask import Flask, render_template, request, jsonify
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy import text
from flask import Flask, jsonify, request, render_template
from config import SERIES_MAX_POINTS_DEFAULT, DB_PATH, SERIES_MAX_POINTS_HARD, PAGE_SIZE_MAX, PAGE_SIZE_DEFAULT
app = Flask(__name__)
app = Flask(__name__, template_folder="templates")
# connect to your existing database file
# ensure the path is correct relative to where you run this script
db_path = os.path.join(os.getcwd(), 'speedtest.db')
app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{db_path}'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
# ---------------------- SQLite Helpers ----------------------
db = SQLAlchemy(app)
def get_conn() -> sqlite3.Connection:
uri = f"file:{os.path.abspath(DB_PATH)}?cache=shared"
conn = sqlite3.connect(uri, uri=True, check_same_thread=False)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL;")
conn.execute("PRAGMA synchronous=NORMAL;")
conn.execute("PRAGMA temp_store=MEMORY;")
conn.execute("PRAGMA cache_size=-20000;") # ~20MB cache
return conn
# Reflect the existing table explicitly to match your schema
class SpeedTest(db.Model):
__tablename__ = 'speed_tests'
id = db.Column(db.Integer, primary_key=True)
timestamp = db.Column(db.Float, nullable=False)
failed = db.Column(db.Boolean, nullable=False)
isp = db.Column(db.String)
ip = db.Column(db.String)
location_code = db.Column(db.String)
location_city = db.Column(db.String)
location_region = db.Column(db.String)
latency = db.Column(db.Float)
jitter = db.Column(db.Float)
down_100kB = db.Column(db.Float)
down_1MB = db.Column(db.Float)
down_10MB = db.Column(db.Float)
down_25MB = db.Column(db.Float)
down_90th = db.Column(db.Float) # We will graph this
up_100kB = db.Column(db.Float)
up_1MB = db.Column(db.Float)
up_10MB = db.Column(db.Float)
up_90th = db.Column(db.Float) # We will graph this
CONN = get_conn()
# ---------------------- Utilities ----------------------
def ts_to_iso(ts: float | int | str) -> str:
try:
t = float(ts)
except Exception:
return str(ts)
return datetime.fromtimestamp(t, tz=pytz.timezone("Europe/Berlin")).isoformat()
# ---------------------- API Endpoints ----------------------
@app.get("/api/series")
def api_series():
q_from = request.args.get("from", type=float)
q_to = request.args.get("to", type=float)
max_points = request.args.get("max_points", type=int) or SERIES_MAX_POINTS_DEFAULT
max_points = max(1, min(max_points, SERIES_MAX_POINTS_HARD))
params = []
where = []
if q_from is not None:
where.append("timestamp >= ?")
params.append(q_from)
if q_to is not None:
where.append("timestamp <= ?")
params.append(q_to)
where_sql = ("WHERE " + " AND ".join(where)) if where else ""
cnt_sql = f"SELECT COUNT(*) AS n FROM speed_tests {where_sql};"
n_rows = CONN.execute(cnt_sql, params).fetchone()[0]
stride = 1 if n_rows <= max_points else math.ceil(n_rows / max_points)
sql = (
f"SELECT timestamp, down_90th, up_90th FROM speed_tests {where_sql} "
"ORDER BY timestamp ASC;"
)
rows = []
kept = 0
for i, r in enumerate(CONN.execute(sql, params)):
if (i % stride) == 0:
rows.append({
"t": float(r["timestamp"]),
"t_iso": ts_to_iso(r["timestamp"]),
"down_90th": None if r["down_90th"] is None else float(r["down_90th"]),
"up_90th": None if r["up_90th"] is None else float(r["up_90th"]),
})
kept += 1
if kept >= max_points:
break
return jsonify({
"count_total": n_rows,
"stride": stride,
"returned": len(rows),
"points": rows,
})
@app.get("/api/table")
def api_table():
limit = request.args.get("limit", type=int) or PAGE_SIZE_DEFAULT
limit = max(1, min(limit, PAGE_SIZE_MAX))
order = request.args.get("order", default="desc")
order = "ASC" if str(order).lower().startswith("asc") else "DESC"
cursor = request.args.get("cursor", type=float)
params = []
where = []
if cursor is not None:
if order == "ASC":
where.append("timestamp > ?")
else:
where.append("timestamp < ?")
params.append(cursor)
where_sql = ("WHERE " + " AND ".join(where)) if where else ""
sql = (
"SELECT id, timestamp, failed, isp, ip, location_code, location_city, location_region, "
"latency, jitter, down_100kB, down_1MB, down_10MB, down_25MB, down_90th, "
"up_100kB, up_1MB, up_10MB, up_90th "
f"FROM speed_tests {where_sql} ORDER BY timestamp {order} LIMIT ?;"
)
params2 = params + [limit]
rows = [dict(r) for r in CONN.execute(sql, params2).fetchall()]
next_cursor = None
if rows:
last_ts = rows[-1]["timestamp"]
try:
next_cursor = float(last_ts)
except Exception:
next_cursor = last_ts
for r in rows:
r["timestamp_iso"] = ts_to_iso(r["timestamp"])
return jsonify({
"limit": limit,
"order": order.lower(),
"count": len(rows),
"next_cursor": next_cursor,
"rows": rows,
})
@app.get("/")
@app.route('/')
def index():
return render_template(
"index.html",
db_name=os.path.basename(DB_PATH),
SERIES_MAX_POINTS_DEFAULT=SERIES_MAX_POINTS_DEFAULT,
SERIES_MAX_POINTS_HARD=SERIES_MAX_POINTS_HARD,
PAGE_SIZE_DEFAULT=PAGE_SIZE_DEFAULT,
PAGE_SIZE_MAX=PAGE_SIZE_MAX,
)
return render_template('index.html')
@app.route('/api/hourly_stats')
def get_hourly_stats():
# We use raw SQL for efficiency and to easily extract the hour from the timestamp
# strftime('%H') works on the unix timestamp if we convert it first
# SQLite: strftime('%H', datetime(timestamp, 'unixepoch'))
if __name__ == "__main__":
port = int(os.environ.get("PORT", 5000))
app.run(host="0.0.0.0", port=port)
sql = text("""
SELECT
strftime('%H', datetime(timestamp, 'unixepoch', 'localtime')) as hour,
AVG(down_90th) as avg_down,
AVG(up_90th) as avg_up,
COUNT(*) as count
FROM speed_tests
WHERE failed = 0
GROUP BY hour
ORDER BY hour ASC
""")
result = db.session.execute(sql)
data = []
for row in result:
data.append({
'hour': row[0], # 00, 01, ... 23
'down': row[1],
'up': row[2],
'count': row[3]
})
return jsonify(data)
@app.route('/api/data')
def get_data():
# Get start/end dates from query params (defaults to last 24h if missing)
start_str = request.args.get('start')
end_str = request.args.get('end')
# Default to last 7 days if no filter provided
now = time.time()
if start_str and end_str:
# Convert string 'YYYY-MM-DD' to unix timestamp
start_ts = datetime.strptime(start_str, '%Y-%m-%d').timestamp()
end_ts = datetime.strptime(end_str, '%Y-%m-%d').timestamp() + 86400 # Include the full end day
else:
start_ts = now - (7 * 24 * 60 * 60)
end_ts = now
# Query the database
query = SpeedTest.query.filter(
SpeedTest.timestamp >= start_ts,
SpeedTest.timestamp <= end_ts
).order_by(SpeedTest.timestamp.asc())
results = query.all()
# Process data for JSON response
data = []
total_tests = 0
failed_tests = 0
total_down = 0
total_up = 0
count_valid_speed = 0
for r in results:
total_tests += 1
if r.failed:
failed_tests += 1
# Format timestamp for JS (milliseconds)
ts_ms = r.timestamp * 1000
# Only include successful speeds in averages
if not r.failed and r.down_90th is not None and r.up_90th is not None:
total_down += r.down_90th
total_up += r.up_90th
count_valid_speed += 1
data.append({
'timestamp': ts_ms,
'readable_date': datetime.fromtimestamp(r.timestamp).strftime('%Y-%m-%d %H:%M'),
'down': r.down_90th,
'up': r.up_90th,
'failed': r.failed,
'latency': r.latency,
'isp': r.isp
})
# Calculate Averages and Uptime
uptime_pct = ((total_tests - failed_tests) / total_tests * 100) if total_tests > 0 else 0
avg_down = (total_down / count_valid_speed) if count_valid_speed > 0 else 0
avg_up = (total_up / count_valid_speed) if count_valid_speed > 0 else 0
return jsonify({
'rows': data,
'stats': {
'uptime': round(uptime_pct, 2),
'avg_down': round(avg_down, 2),
'avg_up': round(avg_up, 2),
'total_tests': total_tests
}
})
if __name__ == '__main__':
app.run(debug=True)

View File

@@ -7,6 +7,7 @@ requires-python = ">=3.13"
dependencies = [
"cloudflarepycli",
"flask>=3.1.0",
"flask-sqlalchemy>=3.1.1",
"getmac>=0.9.5",
"gunicorn>=23.0.0",
"pandas>=2.2.3",

View File

@@ -1,171 +1,232 @@
<!doctype html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Speed Tests</title>
<style>
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, 'Helvetica Neue', Arial, 'Noto Sans', 'Liberation Sans', sans-serif; margin: 0; background: #0b0b10; color: #e8e8f0; }
header { padding: 1rem 1.25rem; position: sticky; top: 0; background: #0b0b10; border-bottom: 1px solid #23232b; z-index: 2; }
.wrap { max-width: 1200px; margin: 0 auto; padding: 1rem; }
.card { background: #12121a; border: 1px solid #23232b; border-radius: 16px; padding: 1rem; box-shadow: 0 4px 16px rgba(0,0,0,.25); }
h1 { font-size: 1.25rem; margin: 0; }
#chart { height: 420px; }
table { width: 100%; border-collapse: collapse; font-size: 0.95rem; }
th, td { padding: 8px 10px; border-bottom: 1px solid #23232b; white-space: nowrap; }
thead th { position: sticky; top: 0; background: #12121a; z-index: 1; }
tr:hover td { background: #171723; }
.muted { color: #a0a0b8; font-size: 0.9rem; }
.controls { display: flex; gap: .5rem; align-items: center; flex-wrap: wrap; margin-bottom: .5rem; }
input, select, button { background: #0b0b10; color: #e8e8f0; border: 1px solid #23232b; border-radius: 10px; padding: .4rem .6rem; }
button { cursor: pointer; }
#sentinel { height: 24px; }
.badge { display:inline-block; padding:.15rem .5rem; border-radius: 999px; background:#1e293b; color:#d1e7ff; font-size: .75rem; margin-left: .5rem; }
</style>
<script src="https://cdn.plot.ly/plotly-2.35.2.min.js" defer></script>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Speed Test Dashboard</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns"></script>
<style>
body { font-family: sans-serif; padding: 20px; background: #f4f4f9; }
.controls { background: white; padding: 15px; border-radius: 8px; margin-bottom: 20px; display: flex; gap: 10px; align-items: center;}
.cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; margin-bottom: 20px; }
.card { background: white; padding: 20px; border-radius: 8px; text-align: center; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.card h3 { margin: 0 0 10px 0; color: #666; font-size: 0.9em; text-transform: uppercase; }
.card .value { font-size: 1.8em; font-weight: bold; color: #333; }
.chart-container { background: white; padding: 20px; border-radius: 8px; margin-bottom: 20px; height: 400px; }
table { width: 100%; border-collapse: collapse; background: white; border-radius: 8px; overflow: hidden; }
th, td { padding: 12px; text-align: left; border-bottom: 1px solid #eee; }
th { background: #007bff; color: white; }
.failed-row { background-color: #ffe6e6; color: #b30000; }
</style>
</head>
<body>
<header>
<div class="wrap">
<h1>Speed Tests <span class="badge">DB: {{ db_name }}</span></h1>
<div class="muted">down_90th vs up_90th · lazy table below</div>
<div class="controls">
<label>From: <input type="date" id="startDate"></label>
<label>To: <input type="date" id="endDate"></label>
<button onclick="fetchData()">Update Dashboard</button>
</div>
</header>
<main class="wrap" style="display:grid; gap: 1rem;">
<section class="card">
<div class="controls">
<label>Max points <input id="maxPoints" type="number" value="{{ SERIES_MAX_POINTS_DEFAULT }}" min="0" max="{{ SERIES_MAX_POINTS_HARD }}" step="200"></label>
<button id="reloadSeries">Reload series</button>
<span class="muted" id="seriesMeta"></span>
</div>
<div id="chart"></div>
</section>
<div class="cards">
<div class="card">
<h3>Uptime</h3>
<div class="value" id="uptimeVal">--%</div>
</div>
<div class="card">
<h3>Avg Download</h3>
<div class="value" id="avgDownVal">-- Mbps</div>
</div>
<div class="card">
<h3>Avg Upload</h3>
<div class="value" id="avgUpVal">-- Mbps</div>
</div>
</div>
<section class="card">
<div class="controls">
<strong>Table</strong>
<label>Page size <input id="pageSize" type="number" value="{{ PAGE_SIZE_DEFAULT }}" min="10" max="{{ PAGE_SIZE_MAX }}" step="50"></label>
<select id="orderSel">
<option value="desc" selected>Newest first</option>
<option value="asc">Oldest first</option>
</select>
<button id="resetTable">Reset</button>
<span class="muted" id="tableMeta"></span>
</div>
<div style="overflow:auto; max-height: 420px; border:1px solid #23232b; border-radius: 12px;">
<table id="tbl">
<thead><tr>
<th>timestamp</th>
<th>down_90th</th>
<th>up_90th</th>
<th>latency</th>
<th>jitter</th>
<th>failed</th>
<th>isp</th>
<th>ip</th>
<th>location</th>
</tr></thead>
<tbody></tbody>
</table>
</div>
<div id="sentinel"></div>
</section>
</main>
<div class="chart-container">
<canvas id="speedChart"></canvas>
</div>
<script>
async function loadSeries() {
const maxPoints = Math.max(1, Math.min({{ SERIES_MAX_POINTS_HARD }}, Number(document.getElementById('maxPoints').value||{{ SERIES_MAX_POINTS_DEFAULT }})));
const url = new URL('/api/series', window.location.origin);
url.searchParams.set('max_points', maxPoints);
const res = await fetch(url);
const js = await res.json();
<div class="chart-container" style="margin-top: 20px;">
<h3>Average Speed by Hour of Day (0-24h)</h3>
<canvas id="hourlyChart"></canvas>
</div>
const t = js.points.map(p => new Date(p.t * 1000));
const down = js.points.map(p => p.down_90th);
const up = js.points.map(p => p.up_90th);
<h3>Detailed Logs</h3>
<table>
<thead>
<tr>
<th>Time</th>
<th>ISP</th>
<th>Download (90th)</th>
<th>Upload (90th)</th>
<th>Latency</th>
<th>Status</th>
</tr>
</thead>
<tbody id="tableBody">
</tbody>
</table>
const layout = {
margin: {l: 50, r: 20, t: 10, b: 40},
paper_bgcolor: '#12121a',
plot_bgcolor: '#12121a',
xaxis: {title: 'Time', gridcolor: '#23232b'},
yaxis: {title: 'Mbit/s (approx)', gridcolor: '#23232b'},
showlegend: true,
legend: {orientation: 'h'}
};
<script>
let chartInstance = null;
const traces = [
{x: t, y: down, name: 'down_90th', mode: 'lines', type: 'scatter'},
{x: t, y: up, name: 'up_90th', mode: 'lines', type: 'scatter'}
];
async function fetchData() {
const start = document.getElementById('startDate').value;
const end = document.getElementById('endDate').value;
Plotly.react('chart', traces, layout, {displayModeBar: true, responsive: true});
document.getElementById('seriesMeta').textContent = `returned ${js.returned} / total ${js.count_total} (stride ${js.stride})`;
}
let url = '/api/data';
if(start && end) {
url += `?start=${start}&end=${end}`;
}
document.getElementById('reloadSeries').addEventListener('click', loadSeries);
window.addEventListener('load', loadSeries);
const response = await fetch(url);
const data = await response.json();
let nextCursor = null;
let loading = false;
updateStats(data.stats);
updateChart(data.rows);
updateTable(data.rows);
}
function formatRow(r) {
const loc = `${r.location_code||''} · ${r.location_city||''} · ${r.location_region||''}`;
return `<tr>
<td title="${r.timestamp}">${r.timestamp_iso}</td>
<td>${r.down_90th ?? ''}</td>
<td>${r.up_90th ?? ''}</td>
<td>${r.latency ?? ''}</td>
<td>${r.jitter ?? ''}</td>
<td>${r.failed}</td>
<td>${r.isp ?? ''}</td>
<td style="max-width:220px; overflow:hidden; text-overflow:ellipsis;">${r.ip ?? ''}</td>
<td>${loc}</td>
</tr>`;
}
function updateStats(stats) {
document.getElementById('uptimeVal').innerText = stats.uptime + '%';
document.getElementById('avgDownVal').innerText = stats.avg_down + ' Mbps';
document.getElementById('avgUpVal').innerText = stats.avg_up + ' Mbps';
}
async function loadMore() {
if (loading) return;
loading = true;
function updateTable(rows) {
const tbody = document.getElementById('tableBody');
tbody.innerHTML = '';
const limit = Math.max(10, Math.min({{ PAGE_SIZE_MAX }}, Number(document.getElementById('pageSize').value||{{ PAGE_SIZE_DEFAULT }})));
const order = document.getElementById('orderSel').value;
// Show last 50 rows for performance (reverse order)
const recentRows = rows.slice().reverse().slice(0, 100);
const url = new URL('/api/table', window.location.origin);
url.searchParams.set('limit', limit);
url.searchParams.set('order', order);
if (nextCursor !== null) url.searchParams.set('cursor', nextCursor);
recentRows.forEach(row => {
const tr = document.createElement('tr');
if(row.failed) tr.classList.add('failed-row');
const res = await fetch(url);
const js = await res.json();
tr.innerHTML = `
<td>${row.readable_date}</td>
<td>${row.isp || 'N/A'}</td>
<td>${row.down ? row.down.toFixed(2) : '-'}</td>
<td>${row.up ? row.up.toFixed(2) : '-'}</td>
<td>${row.latency ? row.latency.toFixed(2) + 'ms' : '-'}</td>
<td>${row.failed ? 'FAILED' : 'OK'}</td>
`;
tbody.appendChild(tr);
});
}
const tbody = document.querySelector('#tbl tbody');
tbody.insertAdjacentHTML('beforeend', js.rows.map(formatRow).join(''));
function updateChart(rows) {
const ctx = document.getElementById('speedChart').getContext('2d');
nextCursor = js.next_cursor;
document.getElementById('tableMeta').textContent = `loaded ${js.count} rows · next cursor: ${nextCursor ?? '—'}`;
// Prepare data for Chart.js
const downData = rows.filter(r => !r.failed).map(r => ({x: r.timestamp, y: r.down}));
const upData = rows.filter(r => !r.failed).map(r => ({x: r.timestamp, y: r.up}));
loading = false;
}
if (chartInstance) {
chartInstance.destroy();
}
function resetTable() {
document.querySelector('#tbl tbody').innerHTML = '';
nextCursor = null;
loadMore();
}
chartInstance = new Chart(ctx, {
type: 'line',
data: {
datasets: [
{
label: 'Download (90th)',
data: downData,
borderColor: '#007bff',
tension: 0.1
},
{
label: 'Upload (90th)',
data: upData,
borderColor: '#28a745',
tension: 0.1
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
type: 'time',
time: { unit: 'day' },
title: { display: true, text: 'Date' }
},
y: {
title: { display: true, text: 'Speed (Mbps)' },
beginAtZero: true
}
}
}
});
}
document.getElementById('resetTable').addEventListener('click', resetTable);
window.addEventListener('load', resetTable);
// Set default dates (past 7 days) and load
window.onload = function() {
const today = new Date();
const lastWeek = new Date();
lastWeek.setDate(today.getDate() - 7);
const sentinel = document.getElementById('sentinel');
const io = new IntersectionObserver((entries) => {
entries.forEach(e => {
if (e.isIntersecting) loadMore();
});
}, {root: null, rootMargin: '200px', threshold: 0});
io.observe(sentinel);
</script>
document.getElementById('endDate').valueAsDate = today;
document.getElementById('startDate').valueAsDate = lastWeek;
fetchData();
fetchHourlyData();
};
async function fetchHourlyData() {
const response = await fetch('/api/hourly_stats');
const data = await response.json();
const ctx = document.getElementById('hourlyChart').getContext('2d');
new Chart(ctx, {
type: 'bar', // Bar chart is better for comparing buckets
data: {
labels: data.map(d => d.hour + ":00"),
datasets: [
{
label: 'Avg Download (Mbps)',
data: data.map(d => d.down),
backgroundColor: 'rgba(0, 123, 255, 0.7)',
},
{
label: 'Avg Upload (Mbps)',
data: data.map(d => d.up),
backgroundColor: 'rgba(40, 167, 69, 0.7)',
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: { beginAtZero: true, title: { display: true, text: 'Mbps' } }
},
plugins: {
annotation: {
annotations: {
box1: {
// Highlight the 1pm - 11pm danger zone
type: 'box',
xMin: 13,
xMax: 23,
backgroundColor: 'rgba(255, 99, 132, 0.1)',
borderWidth: 0,
label: { content: 'Problem Area', enabled: true }
}
}
}
}
}
});
}
// Call this function in window.onload along with your existing fetchData()
// window.onload = function() { ... fetchHourlyData(); ... }
</script>
</body>
</html>

93
uv.lock generated
View File

@@ -107,6 +107,19 @@ 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-sqlalchemy"
version = "3.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "flask" },
{ name = "sqlalchemy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/91/53/b0a9fcc1b1297f51e68b69ed3b7c3c40d8c45be1391d77ae198712914392/flask_sqlalchemy-3.1.1.tar.gz", hash = "sha256:e4b68bb881802dda1a7d878b2fc84c06d1ee57fb40b874d3dc97dabfa36b8312", size = 81899, upload-time = "2023-09-11T21:42:36.147Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1d/6a/89963a5c6ecf166e8be29e0d1bf6806051ee8fe6c82e232842e3aeac9204/flask_sqlalchemy-3.1.1-py3-none-any.whl", hash = "sha256:4ba4be7f419dc72f4efd8802d69974803c37259dd42f3913b0dcf75c9447e0a0", size = 25125, upload-time = "2023-09-11T21:42:34.514Z" },
]
[[package]]
name = "getmac"
version = "0.9.5"
@@ -116,6 +129,40 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/18/85/4cdbc925381422397bd2b3280680e130091173f2c8dfafb9216eaaa91b00/getmac-0.9.5-py2.py3-none-any.whl", hash = "sha256:22b8a3e15bc0c6bfa94651a3f7f6cd91b59432e1d8199411d4fe12804423e0aa", size = 35781, upload-time = "2024-07-16T01:47:03.75Z" },
]
[[package]]
name = "greenlet"
version = "3.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8a/99/1cd3411c56a410994669062bd73dd58270c00cc074cac15f385a1fd91f8a/greenlet-3.3.1.tar.gz", hash = "sha256:41848f3230b58c08bb43dee542e74a2a2e34d3c59dc3076cec9151aeeedcae98", size = 184690, upload-time = "2026-01-23T15:31:02.076Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/ab/d26750f2b7242c2b90ea2ad71de70cfcd73a948a49513188a0fc0d6fc15a/greenlet-3.3.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:7ab327905cabb0622adca5971e488064e35115430cec2c35a50fd36e72a315b3", size = 275205, upload-time = "2026-01-23T15:30:24.556Z" },
{ url = "https://files.pythonhosted.org/packages/10/d3/be7d19e8fad7c5a78eeefb2d896a08cd4643e1e90c605c4be3b46264998f/greenlet-3.3.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65be2f026ca6a176f88fb935ee23c18333ccea97048076aef4db1ef5bc0713ac", size = 599284, upload-time = "2026-01-23T16:00:58.584Z" },
{ url = "https://files.pythonhosted.org/packages/ae/21/fe703aaa056fdb0f17e5afd4b5c80195bbdab701208918938bd15b00d39b/greenlet-3.3.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7a3ae05b3d225b4155bda56b072ceb09d05e974bc74be6c3fc15463cf69f33fd", size = 610274, upload-time = "2026-01-23T16:05:29.312Z" },
{ url = "https://files.pythonhosted.org/packages/06/00/95df0b6a935103c0452dad2203f5be8377e551b8466a29650c4c5a5af6cc/greenlet-3.3.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:12184c61e5d64268a160226fb4818af4df02cfead8379d7f8b99a56c3a54ff3e", size = 624375, upload-time = "2026-01-23T16:15:55.915Z" },
{ url = "https://files.pythonhosted.org/packages/cb/86/5c6ab23bb3c28c21ed6bebad006515cfe08b04613eb105ca0041fecca852/greenlet-3.3.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6423481193bbbe871313de5fd06a082f2649e7ce6e08015d2a76c1e9186ca5b3", size = 612904, upload-time = "2026-01-23T15:32:52.317Z" },
{ url = "https://files.pythonhosted.org/packages/c2/f3/7949994264e22639e40718c2daf6f6df5169bf48fb038c008a489ec53a50/greenlet-3.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:33a956fe78bbbda82bfc95e128d61129b32d66bcf0a20a1f0c08aa4839ffa951", size = 1567316, upload-time = "2026-01-23T16:04:23.316Z" },
{ url = "https://files.pythonhosted.org/packages/8d/6e/d73c94d13b6465e9f7cd6231c68abde838bb22408596c05d9059830b7872/greenlet-3.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b065d3284be43728dd280f6f9a13990b56470b81be20375a207cdc814a983f2", size = 1636549, upload-time = "2026-01-23T15:33:48.643Z" },
{ url = "https://files.pythonhosted.org/packages/5e/b3/c9c23a6478b3bcc91f979ce4ca50879e4d0b2bd7b9a53d8ecded719b92e2/greenlet-3.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:27289986f4e5b0edec7b5a91063c109f0276abb09a7e9bdab08437525977c946", size = 227042, upload-time = "2026-01-23T15:33:58.216Z" },
{ url = "https://files.pythonhosted.org/packages/90/e7/824beda656097edee36ab15809fd063447b200cc03a7f6a24c34d520bc88/greenlet-3.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:2f080e028001c5273e0b42690eaf359aeef9cb1389da0f171ea51a5dc3c7608d", size = 226294, upload-time = "2026-01-23T15:30:52.73Z" },
{ url = "https://files.pythonhosted.org/packages/ae/fb/011c7c717213182caf78084a9bea51c8590b0afda98001f69d9f853a495b/greenlet-3.3.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:bd59acd8529b372775cd0fcbc5f420ae20681c5b045ce25bd453ed8455ab99b5", size = 275737, upload-time = "2026-01-23T15:32:16.889Z" },
{ url = "https://files.pythonhosted.org/packages/41/2e/a3a417d620363fdbb08a48b1dd582956a46a61bf8fd27ee8164f9dfe87c2/greenlet-3.3.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b31c05dd84ef6871dd47120386aed35323c944d86c3d91a17c4b8d23df62f15b", size = 646422, upload-time = "2026-01-23T16:01:00.354Z" },
{ url = "https://files.pythonhosted.org/packages/b4/09/c6c4a0db47defafd2d6bab8ddfe47ad19963b4e30f5bed84d75328059f8c/greenlet-3.3.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02925a0bfffc41e542c70aa14c7eda3593e4d7e274bfcccca1827e6c0875902e", size = 658219, upload-time = "2026-01-23T16:05:30.956Z" },
{ url = "https://files.pythonhosted.org/packages/e2/89/b95f2ddcc5f3c2bc09c8ee8d77be312df7f9e7175703ab780f2014a0e781/greenlet-3.3.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3e0f3878ca3a3ff63ab4ea478585942b53df66ddde327b59ecb191b19dbbd62d", size = 671455, upload-time = "2026-01-23T16:15:57.232Z" },
{ url = "https://files.pythonhosted.org/packages/80/38/9d42d60dffb04b45f03dbab9430898352dba277758640751dc5cc316c521/greenlet-3.3.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34a729e2e4e4ffe9ae2408d5ecaf12f944853f40ad724929b7585bca808a9d6f", size = 660237, upload-time = "2026-01-23T15:32:53.967Z" },
{ url = "https://files.pythonhosted.org/packages/96/61/373c30b7197f9e756e4c81ae90a8d55dc3598c17673f91f4d31c3c689c3f/greenlet-3.3.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:aec9ab04e82918e623415947921dea15851b152b822661cce3f8e4393c3df683", size = 1615261, upload-time = "2026-01-23T16:04:25.066Z" },
{ url = "https://files.pythonhosted.org/packages/fd/d3/ca534310343f5945316f9451e953dcd89b36fe7a19de652a1dc5a0eeef3f/greenlet-3.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71c767cf281a80d02b6c1bdc41c9468e1f5a494fb11bc8688c360524e273d7b1", size = 1683719, upload-time = "2026-01-23T15:33:50.61Z" },
{ url = "https://files.pythonhosted.org/packages/52/cb/c21a3fd5d2c9c8b622e7bede6d6d00e00551a5ee474ea6d831b5f567a8b4/greenlet-3.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:96aff77af063b607f2489473484e39a0bbae730f2ea90c9e5606c9b73c44174a", size = 228125, upload-time = "2026-01-23T15:32:45.265Z" },
{ url = "https://files.pythonhosted.org/packages/6a/8e/8a2db6d11491837af1de64b8aff23707c6e85241be13c60ed399a72e2ef8/greenlet-3.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:b066e8b50e28b503f604fa538adc764a638b38cf8e81e025011d26e8a627fa79", size = 227519, upload-time = "2026-01-23T15:31:47.284Z" },
{ url = "https://files.pythonhosted.org/packages/28/24/cbbec49bacdcc9ec652a81d3efef7b59f326697e7edf6ed775a5e08e54c2/greenlet-3.3.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:3e63252943c921b90abb035ebe9de832c436401d9c45f262d80e2d06cc659242", size = 282706, upload-time = "2026-01-23T15:33:05.525Z" },
{ url = "https://files.pythonhosted.org/packages/86/2e/4f2b9323c144c4fe8842a4e0d92121465485c3c2c5b9e9b30a52e80f523f/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76e39058e68eb125de10c92524573924e827927df5d3891fbc97bd55764a8774", size = 651209, upload-time = "2026-01-23T16:01:01.517Z" },
{ url = "https://files.pythonhosted.org/packages/d9/87/50ca60e515f5bb55a2fbc5f0c9b5b156de7d2fc51a0a69abc9d23914a237/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9f9d5e7a9310b7a2f416dd13d2e3fd8b42d803968ea580b7c0f322ccb389b97", size = 654300, upload-time = "2026-01-23T16:05:32.199Z" },
{ url = "https://files.pythonhosted.org/packages/7c/25/c51a63f3f463171e09cb586eb64db0861eb06667ab01a7968371a24c4f3b/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b9721549a95db96689458a1e0ae32412ca18776ed004463df3a9299c1b257ab", size = 662574, upload-time = "2026-01-23T16:15:58.364Z" },
{ url = "https://files.pythonhosted.org/packages/1d/94/74310866dfa2b73dd08659a3d18762f83985ad3281901ba0ee9a815194fb/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92497c78adf3ac703b57f1e3813c2d874f27f71a178f9ea5887855da413cd6d2", size = 653842, upload-time = "2026-01-23T15:32:55.671Z" },
{ url = "https://files.pythonhosted.org/packages/97/43/8bf0ffa3d498eeee4c58c212a3905dd6146c01c8dc0b0a046481ca29b18c/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ed6b402bc74d6557a705e197d47f9063733091ed6357b3de33619d8a8d93ac53", size = 1614917, upload-time = "2026-01-23T16:04:26.276Z" },
{ url = "https://files.pythonhosted.org/packages/89/90/a3be7a5f378fc6e84abe4dcfb2ba32b07786861172e502388b4c90000d1b/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:59913f1e5ada20fde795ba906916aea25d442abcc0593fba7e26c92b7ad76249", size = 1676092, upload-time = "2026-01-23T15:33:52.176Z" },
{ url = "https://files.pythonhosted.org/packages/e1/2b/98c7f93e6db9977aaee07eb1e51ca63bd5f779b900d362791d3252e60558/greenlet-3.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:301860987846c24cb8964bdec0e31a96ad4a2a801b41b4ef40963c1b44f33451", size = 233181, upload-time = "2026-01-23T15:33:00.29Z" },
]
[[package]]
name = "gunicorn"
version = "23.0.0"
@@ -370,6 +417,7 @@ source = { virtual = "." }
dependencies = [
{ name = "cloudflarepycli" },
{ name = "flask" },
{ name = "flask-sqlalchemy" },
{ name = "getmac" },
{ name = "gunicorn" },
{ name = "pandas" },
@@ -381,6 +429,7 @@ dependencies = [
requires-dist = [
{ name = "cloudflarepycli", git = "https://github.com/cato447/cloudflarepycli" },
{ name = "flask", specifier = ">=3.1.0" },
{ name = "flask-sqlalchemy", specifier = ">=3.1.1" },
{ name = "getmac", specifier = ">=0.9.5" },
{ name = "gunicorn", specifier = ">=23.0.0" },
{ name = "pandas", specifier = ">=2.2.3" },
@@ -388,6 +437,50 @@ requires-dist = [
{ name = "pytz", specifier = ">=2025.2" },
]
[[package]]
name = "sqlalchemy"
version = "2.0.46"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/aa/9ce0f3e7a9829ead5c8ce549392f33a12c4555a6c0609bb27d882e9c7ddf/sqlalchemy-2.0.46.tar.gz", hash = "sha256:cf36851ee7219c170bb0793dbc3da3e80c582e04a5437bc601bfe8c85c9216d7", size = 9865393, upload-time = "2026-01-21T18:03:45.119Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/4b/fa7838fe20bb752810feed60e45625a9a8b0102c0c09971e2d1d95362992/sqlalchemy-2.0.46-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:93a12da97cca70cea10d4b4fc602589c4511f96c1f8f6c11817620c021d21d00", size = 2150268, upload-time = "2026-01-21T19:05:56.621Z" },
{ url = "https://files.pythonhosted.org/packages/46/c1/b34dccd712e8ea846edf396e00973dda82d598cb93762e55e43e6835eba9/sqlalchemy-2.0.46-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:af865c18752d416798dae13f83f38927c52f085c52e2f32b8ab0fef46fdd02c2", size = 3276511, upload-time = "2026-01-21T18:46:49.022Z" },
{ url = "https://files.pythonhosted.org/packages/96/48/a04d9c94753e5d5d096c628c82a98c4793b9c08ca0e7155c3eb7d7db9f24/sqlalchemy-2.0.46-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8d679b5f318423eacb61f933a9a0f75535bfca7056daeadbf6bd5bcee6183aee", size = 3292881, upload-time = "2026-01-21T18:40:13.089Z" },
{ url = "https://files.pythonhosted.org/packages/be/f4/06eda6e91476f90a7d8058f74311cb65a2fb68d988171aced81707189131/sqlalchemy-2.0.46-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64901e08c33462acc9ec3bad27fc7a5c2b6491665f2aa57564e57a4f5d7c52ad", size = 3224559, upload-time = "2026-01-21T18:46:50.974Z" },
{ url = "https://files.pythonhosted.org/packages/ab/a2/d2af04095412ca6345ac22b33b89fe8d6f32a481e613ffcb2377d931d8d0/sqlalchemy-2.0.46-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8ac45e8f4eaac0f9f8043ea0e224158855c6a4329fd4ee37c45c61e3beb518e", size = 3262728, upload-time = "2026-01-21T18:40:14.883Z" },
{ url = "https://files.pythonhosted.org/packages/31/48/1980c7caa5978a3b8225b4d230e69a2a6538a3562b8b31cea679b6933c83/sqlalchemy-2.0.46-cp313-cp313-win32.whl", hash = "sha256:8d3b44b3d0ab2f1319d71d9863d76eeb46766f8cf9e921ac293511804d39813f", size = 2111295, upload-time = "2026-01-21T18:42:52.366Z" },
{ url = "https://files.pythonhosted.org/packages/2d/54/f8d65bbde3d877617c4720f3c9f60e99bb7266df0d5d78b6e25e7c149f35/sqlalchemy-2.0.46-cp313-cp313-win_amd64.whl", hash = "sha256:77f8071d8fbcbb2dd11b7fd40dedd04e8ebe2eb80497916efedba844298065ef", size = 2137076, upload-time = "2026-01-21T18:42:53.924Z" },
{ url = "https://files.pythonhosted.org/packages/56/ba/9be4f97c7eb2b9d5544f2624adfc2853e796ed51d2bb8aec90bc94b7137e/sqlalchemy-2.0.46-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1e8cc6cc01da346dc92d9509a63033b9b1bda4fed7a7a7807ed385c7dccdc10", size = 3556533, upload-time = "2026-01-21T18:33:06.636Z" },
{ url = "https://files.pythonhosted.org/packages/20/a6/b1fc6634564dbb4415b7ed6419cdfeaadefd2c39cdab1e3aa07a5f2474c2/sqlalchemy-2.0.46-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:96c7cca1a4babaaf3bfff3e4e606e38578856917e52f0384635a95b226c87764", size = 3523208, upload-time = "2026-01-21T18:45:08.436Z" },
{ url = "https://files.pythonhosted.org/packages/a1/d8/41e0bdfc0f930ff236f86fccd12962d8fa03713f17ed57332d38af6a3782/sqlalchemy-2.0.46-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b2a9f9aee38039cf4755891a1e50e1effcc42ea6ba053743f452c372c3152b1b", size = 3464292, upload-time = "2026-01-21T18:33:08.208Z" },
{ url = "https://files.pythonhosted.org/packages/f0/8b/9dcbec62d95bea85f5ecad9b8d65b78cc30fb0ffceeb3597961f3712549b/sqlalchemy-2.0.46-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:db23b1bf8cfe1f7fda19018e7207b20cdb5168f83c437ff7e95d19e39289c447", size = 3473497, upload-time = "2026-01-21T18:45:10.552Z" },
{ url = "https://files.pythonhosted.org/packages/e9/f8/5ecdfc73383ec496de038ed1614de9e740a82db9ad67e6e4514ebc0708a3/sqlalchemy-2.0.46-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:56bdd261bfd0895452006d5316cbf35739c53b9bb71a170a331fa0ea560b2ada", size = 2152079, upload-time = "2026-01-21T19:05:58.477Z" },
{ url = "https://files.pythonhosted.org/packages/e5/bf/eba3036be7663ce4d9c050bc3d63794dc29fbe01691f2bf5ccb64e048d20/sqlalchemy-2.0.46-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33e462154edb9493f6c3ad2125931e273bbd0be8ae53f3ecd1c161ea9a1dd366", size = 3272216, upload-time = "2026-01-21T18:46:52.634Z" },
{ url = "https://files.pythonhosted.org/packages/05/45/1256fb597bb83b58a01ddb600c59fe6fdf0e5afe333f0456ed75c0f8d7bd/sqlalchemy-2.0.46-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bcdce05f056622a632f1d44bb47dbdb677f58cad393612280406ce37530eb6d", size = 3277208, upload-time = "2026-01-21T18:40:16.38Z" },
{ url = "https://files.pythonhosted.org/packages/d9/a0/2053b39e4e63b5d7ceb3372cface0859a067c1ddbd575ea7e9985716f771/sqlalchemy-2.0.46-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e84b09a9b0f19accedcbeff5c2caf36e0dd537341a33aad8d680336152dc34e", size = 3221994, upload-time = "2026-01-21T18:46:54.622Z" },
{ url = "https://files.pythonhosted.org/packages/1e/87/97713497d9502553c68f105a1cb62786ba1ee91dea3852ae4067ed956a50/sqlalchemy-2.0.46-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4f52f7291a92381e9b4de9050b0a65ce5d6a763333406861e33906b8aa4906bf", size = 3243990, upload-time = "2026-01-21T18:40:18.253Z" },
{ url = "https://files.pythonhosted.org/packages/a8/87/5d1b23548f420ff823c236f8bea36b1a997250fd2f892e44a3838ca424f4/sqlalchemy-2.0.46-cp314-cp314-win32.whl", hash = "sha256:70ed2830b169a9960193f4d4322d22be5c0925357d82cbf485b3369893350908", size = 2114215, upload-time = "2026-01-21T18:42:55.232Z" },
{ url = "https://files.pythonhosted.org/packages/3a/20/555f39cbcf0c10cf452988b6a93c2a12495035f68b3dbd1a408531049d31/sqlalchemy-2.0.46-cp314-cp314-win_amd64.whl", hash = "sha256:3c32e993bc57be6d177f7d5d31edb93f30726d798ad86ff9066d75d9bf2e0b6b", size = 2139867, upload-time = "2026-01-21T18:42:56.474Z" },
{ url = "https://files.pythonhosted.org/packages/3e/f0/f96c8057c982d9d8a7a68f45d69c674bc6f78cad401099692fe16521640a/sqlalchemy-2.0.46-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4dafb537740eef640c4d6a7c254611dca2df87eaf6d14d6a5fca9d1f4c3fc0fa", size = 3561202, upload-time = "2026-01-21T18:33:10.337Z" },
{ url = "https://files.pythonhosted.org/packages/d7/53/3b37dda0a5b137f21ef608d8dfc77b08477bab0fe2ac9d3e0a66eaeab6fc/sqlalchemy-2.0.46-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42a1643dc5427b69aca967dae540a90b0fbf57eaf248f13a90ea5930e0966863", size = 3526296, upload-time = "2026-01-21T18:45:12.657Z" },
{ url = "https://files.pythonhosted.org/packages/33/75/f28622ba6dde79cd545055ea7bd4062dc934e0621f7b3be2891f8563f8de/sqlalchemy-2.0.46-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ff33c6e6ad006bbc0f34f5faf941cfc62c45841c64c0a058ac38c799f15b5ede", size = 3470008, upload-time = "2026-01-21T18:33:11.725Z" },
{ url = "https://files.pythonhosted.org/packages/a9/42/4afecbbc38d5e99b18acef446453c76eec6fbd03db0a457a12a056836e22/sqlalchemy-2.0.46-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:82ec52100ec1e6ec671563bbd02d7c7c8d0b9e71a0723c72f22ecf52d1755330", size = 3476137, upload-time = "2026-01-21T18:45:15.001Z" },
{ url = "https://files.pythonhosted.org/packages/fc/a1/9c4efa03300926601c19c18582531b45aededfb961ab3c3585f1e24f120b/sqlalchemy-2.0.46-py3-none-any.whl", hash = "sha256:f9c11766e7e7c0a2767dda5acb006a118640c9fc0a4104214b96269bfb78399e", size = 1937882, upload-time = "2026-01-21T18:22:10.456Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "tzdata"
version = "2025.3"