initial commit
This commit is contained in:
commit
45214ff6f3
54 changed files with 9253 additions and 0 deletions
50
.env.example
Normal file
50
.env.example
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
# Beispielkonfiguration. Alle Werte sind optional; gezeigt sind die Defaults
|
||||
# bzw. typische Werte für den Betrieb im Container.
|
||||
|
||||
# --- Transport ---------------------------------------------------------------
|
||||
MCP_CALC_TRANSPORT=http # stdio | http
|
||||
MCP_CALC_HOST=0.0.0.0 # im Container 0.0.0.0, lokal 127.0.0.1
|
||||
MCP_CALC_PORT=8080
|
||||
MCP_CALC_PATH=/mcp
|
||||
|
||||
# Erlaubte Host-Header (kommagetrennt). Gesetzt = DNS-Rebinding-Schutz aktiv.
|
||||
MCP_CALC_ALLOWED_HOSTS=
|
||||
MCP_CALC_ALLOWED_ORIGINS=
|
||||
# Proxys (z. B. OpenShift-Router), deren X-Forwarded-For ausgewertet wird (IPs/CIDRs)
|
||||
MCP_CALC_TRUSTED_PROXIES=
|
||||
MCP_CALC_MAX_REQUEST_BODY_BYTES=1048576
|
||||
MCP_CALC_MAX_CONNECTIONS=200
|
||||
MCP_CALC_METRICS=true # Prometheus-Metriken unter /metrics
|
||||
|
||||
# --- Authentifizierung (nur HTTP, siehe docs/auth.md) ------------------------
|
||||
MCP_CALC_CLIENTS_FILE=/data/clients.json # RWX-PVC; aktiviert die Authentifizierung
|
||||
MCP_CALC_CLIENTS_RELOAD_SECONDS=5
|
||||
MCP_CALC_AUDIT_FILE=/data/audit.log
|
||||
# Statische Tokens name:token (einfache Alternative zur Client-Datei)
|
||||
MCP_CALC_AUTH_TOKENS=
|
||||
# Admin-API: startet nur, wenn Admin-Tokens gesetzt sind (Wert aus einem Secret!)
|
||||
MCP_CALC_ADMIN_TOKENS=
|
||||
MCP_CALC_ADMIN_HOST=127.0.0.1
|
||||
MCP_CALC_ADMIN_PORT=9090
|
||||
|
||||
# --- Ausführung --------------------------------------------------------------
|
||||
MCP_CALC_EXECUTOR=process # process | inline (nur Entwicklung)
|
||||
MCP_CALC_WORKERS=2 # ~ Anzahl CPU-Kerne des Pods
|
||||
MCP_CALC_MAX_QUEUE=20 # wartende Berechnungen; darüber sofort "busy"
|
||||
MCP_CALC_MAX_QUEUE_WAIT_SECONDS=30 # maximale Wartezeit auf einen freien Worker
|
||||
MCP_CALC_CLIENT_MAX_CONCURRENT=10 # gleichzeitige Berechnungen pro Client (0 = unbegrenzt)
|
||||
MCP_CALC_TIMEOUT_SECONDS=10
|
||||
MCP_CALC_MAX_TASKS_PER_WORKER=200
|
||||
MCP_CALC_MEMORY_LIMIT_MB=512 # zusätzlicher Speicher pro Berechnung/Worker, nur Linux, 0 = aus
|
||||
|
||||
# --- Eingabe-/Ausgabelimits --------------------------------------------------
|
||||
MCP_CALC_MAX_INPUT_LENGTH=5000
|
||||
MCP_CALC_MAX_OUTPUT_LENGTH=20000
|
||||
MCP_CALC_MAX_PRECISION=1000
|
||||
MCP_CALC_MAX_MATRIX_SIZE=30
|
||||
MCP_CALC_MAX_DATA_POINTS=1000000
|
||||
MCP_CALC_MAX_LIST_ITEMS=10000
|
||||
|
||||
# --- Logging -----------------------------------------------------------------
|
||||
MCP_CALC_LOG_LEVEL=INFO # DEBUG | INFO | WARNING | ERROR
|
||||
MCP_CALC_LOG_FORMAT=json # text | json
|
||||
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.egg-info/
|
||||
build/
|
||||
dist/
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.env
|
||||
.ruff_cache/
|
||||
1
.python-version
Normal file
1
.python-version
Normal file
|
|
@ -0,0 +1 @@
|
|||
3.12
|
||||
130
BACKLOG.md
Normal file
130
BACKLOG.md
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
# Backlog
|
||||
|
||||
Geplante Features und offene Themen für mcp-calculator.
|
||||
**Priorität:** hoch / mittel / niedrig. **Aufwand:** S (≤ ½ Tag), M (1–2 Tage), L (> 2 Tage).
|
||||
|
||||
---
|
||||
|
||||
## Entscheidungen
|
||||
|
||||
| Thema | Entscheidung | Konsequenz |
|
||||
|---|---|---|
|
||||
| **Authentifizierung** | Jetzt API-Tokens, später Keycloak (OIDC/JWT). Lokal (stdio) keine Auth. | Umgesetzt in 1.2.0: Client-Datei auf RWX-PVC, CLI + Admin-API, Audit-Log, Token- und Netz-Clients. Keycloak siehe Features. HMAC-Signaturen aktuell nicht. |
|
||||
| **Token-Verwaltung** | Datei auf dem PVC als einziger Speicher, Admin-API und CLI schreiben dieselbe Datei. | Keine Datenbank, funktioniert mit mehreren Replicas (RWX, Hot-Reload). |
|
||||
| **Rate-Limiting** | Ziel ist **Überlastschutz**, nicht Fairness zwischen Nutzern. | Umgesetzt in 1.2.0 (sechs Schichten, pro Replica). Kein zentraler Zähler nötig. |
|
||||
| **Replicas** | Aktuell eine, bei Bedarf mehrere. | Server ist zustandslos, Skalierung ohne Codeänderung möglich. Readiness und Backpressure wirken pro Pod. |
|
||||
|
||||
## Offen, erledigt das Team
|
||||
|
||||
| Thema | Details |
|
||||
|---|---|
|
||||
| Linux-Verifikation im Pod | `python scripts/measure_memory.py server`; speicherhungrige Anfrage (z. B. `evaluate` mit `factorial(10^7)`) muss mit Memory-/Timeout-Meldung enden, ohne Pod-Neustart. Siehe [docs/memory.md](docs/memory.md) |
|
||||
| Clients end-to-end | opencode, Cline und eigene Tools gegen den Pod; Konfigurationsfelder der README gegen die eingesetzten Client-Versionen prüfen |
|
||||
| Git-Repository | inkl. `uv.lock` und `requirements.lock` committen |
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
### Hohe Priorität
|
||||
|
||||
#### Keycloak (OIDC/JWT) · Aufwand M
|
||||
Zusätzlich zu API-Tokens: Keycloak-JWTs prüfen (Signatur gegen JWKS, `iss`, `aud`, Ablauf) über den
|
||||
`TokenVerifier` des MCP SDK; Identität aus `azp`/Client-ID oder einem Claim, gleiche Limits und Metriken
|
||||
wie heute. Umschaltbar per `MCP_CALC_AUTH_MODE=token|oidc|both`.
|
||||
- **Vorher klären:** Unterstützen opencode und Cline den OAuth-Ablauf von MCP gegen Keycloak? Sonst `both` (IDE-Clients mit API-Token, Dienste mit Client-Credentials).
|
||||
- **Voraussetzung:** Keycloak-Realm/Client, erreichbar aus dem Pod (JWKS).
|
||||
|
||||
#### Eval-Suite für die Tool-Nutzung · Aufwand M
|
||||
Realistische Aufgaben (mit bekannter Lösung) gegen das eingesetzte Modell laufen lassen, jeweils mit
|
||||
und ohne MCP-Server. Gemessen werden Trefferquote, Tool-Wahl, Anzahl der Aufrufe und Fehlversuche.
|
||||
- **Nutzen:** belegt den Mehrwert und zeigt, welche Tool-Beschreibungen nachgeschärft werden müssen. Basis für die Priorisierung der übrigen Features.
|
||||
- **Braucht:** Modell-Zugang und idealerweise echte, anonymisierte Nutzerfragen.
|
||||
- **Ansatz:** eigener MCP-Client (siehe `examples/client_example.py`) in einer LLM-Schleife, Aufgaben als JSON/YAML, Report als Tabelle.
|
||||
|
||||
#### Finanzmathematik · Aufwand M
|
||||
Neues Tool `finance`: Barwert/Endwert, Annuität und Tilgungsplan, NPV, IRR (interner Zinsfuß), effektiver
|
||||
Jahreszins, Abschreibungen.
|
||||
- **Nutzen:** häufige Business-Fragen, bei denen LLMs regelmäßig falsch runden oder Formeln verwechseln.
|
||||
- **Ansatz:** exakte Arithmetik über SymPy/`Decimal`, IRR numerisch (`scipy.optimize.brentq`); Tilgungsplan als Tabelle mit Längenlimit.
|
||||
|
||||
#### Datum und Zeit · Aufwand S–M
|
||||
Neues Tool `datetime_calc`: Differenz zwischen Daten, Datum ± Dauer, Arbeitstage (mit Feiertagskalender
|
||||
je Bundesland), Wochentag, Kalenderwoche (ISO), Zeitzonen-Umrechnung, Fristen.
|
||||
- **Nutzen:** LLMs sind hier notorisch unzuverlässig (Schaltjahre, Monatslängen, Wochentage).
|
||||
- **Ansatz:** Standardbibliothek `datetime`/`zoneinfo`; Feiertage über das Paket `holidays` (neue Abhängigkeit).
|
||||
|
||||
### Mittlere Priorität
|
||||
|
||||
#### Optimierung · Aufwand M
|
||||
Neues Tool `optimize`: Minimum/Maximum einer Funktion (mit Schranken und Nebenbedingungen), lineare
|
||||
Optimierung, Nullstellensuche in Intervallen.
|
||||
- **Ansatz:** `scipy.optimize` (`minimize`, `linprog`, `brentq`); symbolisch über `solve` des Gradienten, wo möglich.
|
||||
|
||||
#### Numerische Differentialgleichungen · Aufwand M
|
||||
`solve_ode` um einen numerischen Modus erweitern: Anfangswertprobleme und Systeme ohne geschlossene Lösung,
|
||||
Ergebnis als Wertetabelle an gewählten Stellen.
|
||||
- **Ansatz:** `scipy.integrate.solve_ivp`; Rechte Seite über den sicheren Parser, dann `lambdify`.
|
||||
|
||||
#### Batch-Aufrufe · Aufwand S
|
||||
Mehrere Ausdrücke oder Aufgaben in einem Tool-Aufruf (z. B. `evaluate` mit Liste).
|
||||
- **Nutzen:** weniger Roundtrips bei Tabellen und Serienrechnungen, geringere Latenz im Agenten.
|
||||
- **Achtung:** Timeout und Limits pro Batch sinnvoll definieren.
|
||||
|
||||
#### Typisierte Ausgabeschemas · Aufwand M
|
||||
Rückgabewerte als Pydantic-Modelle statt `dict[str, Any]`, damit Clients ein präzises `outputSchema` erhalten.
|
||||
- **Nutzen:** Clients und Agenten können Ergebnisse maschinell sicher weiterverarbeiten.
|
||||
|
||||
#### Signierte Requests (HMAC) · Aufwand S–M
|
||||
Für Dienste: Signatur über Zeitstempel + Body mit einem gemeinsamen Geheimnis (`X-Signature`, `X-Timestamp`),
|
||||
Schutz vor Replay durch ein Zeitfenster. Als weitere Methode in der Auth-Kette (`auth: "hmac"`).
|
||||
Aktuell nicht benötigt; Token + Netz-Einschränkung decken den Fall ab.
|
||||
|
||||
#### Worker-Metriken · Aufwand S
|
||||
RSS und Anzahl erledigter Aufgaben je Worker als Metrik (heute enthalten die `process_*`-Metriken nur den Server-Prozess).
|
||||
- **Ansatz:** Worker melden Kennzahlen mit dem Ergebnis zurück, oder der Server liest `/proc/<pid>/status` der Worker.
|
||||
|
||||
### Niedrige Priorität
|
||||
|
||||
#### Plots · Aufwand M
|
||||
Funktionsgraphen (z. B. zur Kurvendiskussion) als PNG/SVG im MCP-Bildformat.
|
||||
- **Achtung:** neue Abhängigkeit (matplotlib, ~+40 MB je Worker); nicht jeder Client zeigt Bilder an.
|
||||
|
||||
#### LaTeX-Eingabe · Aufwand S
|
||||
Ausdrücke in LaTeX-Notation akzeptieren (`\frac{1}{2}`, `\int_0^1`).
|
||||
- **Ansatz:** `sympy.parsing.latex.parse_latex`; braucht `antlr4-python3-runtime` in passender Version. Sicherheitsprüfung wie beim bestehenden Parser.
|
||||
|
||||
#### MCP-Prompts · Aufwand S
|
||||
Vorlagen wie „Kurvendiskussion“, „Statistik-Report“, „Einheiten-Check“ als MCP-Prompts, die Clients im Menü anbieten.
|
||||
|
||||
#### Komplexe Zahlen in Polarform · Aufwand S
|
||||
Ergebnisse zusätzlich als Betrag/Winkel und in Exponentialform ausgeben.
|
||||
|
||||
---
|
||||
|
||||
## Qualität und Betrieb
|
||||
|
||||
| Thema | Priorität | Aufwand | Details |
|
||||
|---|---|---|---|
|
||||
| CI-Pipeline | hoch | S | `python -m unittest`, `ruff check`, `pip-audit -r requirements.lock` bei jedem Push |
|
||||
| Regelmäßige Abhängigkeits-Updates | mittel | S | z. B. monatlich `uv lock --upgrade`, Tests, beide Lock-Dateien committen; Renovate/Dependabot möglich |
|
||||
| Lasttest | mittel | S–M | Durchsatz und Latenz je Worker-Zahl messen, Grundlage für Worker- und Replica-Anzahl |
|
||||
| Weitere Python-Versionen | niedrig | S | Tests zusätzlich mit 3.13 (lokal ist z. B. `UV_PYTHON=3.13` bereits verbreitet) |
|
||||
|
||||
---
|
||||
|
||||
## Erledigt
|
||||
|
||||
| Version | Thema |
|
||||
|---|---|
|
||||
| 1.2.0 | Client-Verwaltung: Client-Datei auf RWX-PVC mit Hot-Reload, Token-Hashes, Token- und Netz-Clients, Ablaufdatum, Sperren, Limits pro Client |
|
||||
| 1.2.0 | CLI `mcp-calculator-admin` und Admin-API (eigener Port, Admin-Tokens), Audit-Log |
|
||||
| 1.2.0 | Überlastschutz: Limit pro Client, Wartezeit-Limit in der Queue, Abbruch bei Verbindungsende, Verbindungslimit; Client-Label in Metriken und Logs |
|
||||
| 1.1.0 | Backpressure (`MCP_CALC_MAX_QUEUE`), sofortige „busy“-Ablehnung |
|
||||
| 1.1.0 | Prometheus-Metriken unter `/metrics` |
|
||||
| 1.1.0 | Graceful Shutdown: Worker-Pool wird bei SIGTERM geordnet gestoppt (behebt die Semaphore-Warnung) |
|
||||
| 1.1.0 | Vorgewärmte Worker, Readiness erst nach dem Laden der Bibliotheken |
|
||||
| 1.1.0 | Abhängigkeiten festgeschrieben (`uv.lock`, `requirements.lock` mit Hashes), Python ≥ 3.12 |
|
||||
| 1.1.0 | Tests auf `unittest` umgestellt |
|
||||
| 1.0.x | Speicheroptimierung (Server ohne Mathe-Bibliotheken, Worker ohne Server-Stack), siehe [docs/memory.md](docs/memory.md) |
|
||||
| 1.0.0 | 13 Tools, stdio + Streamable HTTP, sicherer Parser, Worker-Pool mit Timeout und Speicherlimit |
|
||||
523
README.md
Normal file
523
README.md
Normal file
|
|
@ -0,0 +1,523 @@
|
|||
# mcp-calculator
|
||||
|
||||
MCP-Server, der LLMs **exakte, deterministische Mathematik** gibt. LLMs sagen Tokens voraus und
|
||||
verrechnen sich deshalb bei großen Zahlen, vielen Rechenschritten, Wahrscheinlichkeiten oder
|
||||
Einheiten. Dieser Server übernimmt das Rechnen. Das LLM formuliert nur den Aufruf und deutet das Ergebnis.
|
||||
|
||||
- **13 Tools**: Arithmetik mit beliebiger Präzision, Algebra, Gleichungen, Analysis, Kurvendiskussion,
|
||||
Differentialgleichungen, lineare Algebra, Statistik, Regression, Verteilungen, Hypothesentests,
|
||||
Zahlentheorie, Einheiten
|
||||
- **Beide Transporte**: `stdio` (lokal) und **Streamable HTTP** (zentral, zustandslos, skalierbar)
|
||||
- **Sicher**: Parser mit Whitelist statt `eval`, Worker-Prozesse mit hartem Timeout und
|
||||
Speicherlimit
|
||||
- **Client-Verwaltung**: API-Tokens (nur als Hash gespeichert) und Netz-Freigaben in einer Datei auf dem
|
||||
gemeinsamen Volume, änderbar zur Laufzeit per CLI oder Admin-API, mit Audit-Log
|
||||
- **Überlastschutz**: Verbindungslimit, Limit pro Client, Kapazitätsgrenze, Wartezeit-Limit, Timeout,
|
||||
Abbruch bei Verbindungsende
|
||||
- **Betriebsbereit**: Health/Readiness, Prometheus-Metriken, Graceful Shutdown, JSON-Logging,
|
||||
Konfiguration komplett über Env-Variablen, festgeschriebene Abhängigkeiten
|
||||
|
||||
Stack: Python ≥ 3.12, [MCP Python SDK](https://github.com/modelcontextprotocol/python-sdk) 2.x,
|
||||
SymPy, mpmath, NumPy, SciPy, Pint, Pebble, prometheus-client. Tests mit `unittest` (Standardbibliothek).
|
||||
|
||||
Offene Ideen und geplante Features: [BACKLOG.md](BACKLOG.md).
|
||||
|
||||
---
|
||||
|
||||
## Inhalt
|
||||
|
||||
1. [Schnellstart](#schnellstart)
|
||||
2. [Tools im Überblick](#tools-im-überblick)
|
||||
3. [Transporte](#transporte)
|
||||
4. [Clients anbinden](#clients-anbinden)
|
||||
5. [Konfiguration](#konfiguration)
|
||||
6. [Sicherheit](#sicherheit)
|
||||
7. [Betrieb](#betrieb)
|
||||
8. [Monitoring](#monitoring)
|
||||
9. [Architektur](#architektur)
|
||||
10. [Entwicklung](#entwicklung)
|
||||
11. [Grenzen](#grenzen)
|
||||
|
||||
Die ausführliche Referenz aller Parameter steht in [docs/tools.md](docs/tools.md).
|
||||
|
||||
---
|
||||
|
||||
## Schnellstart
|
||||
|
||||
Alles wird ausschließlich im virtuellen Environment des Projekts installiert, exakt in den Versionen
|
||||
aus dem Lockfile.
|
||||
|
||||
```bash
|
||||
# venv anlegen und exakt nach uv.lock installieren (inkl. Dev-Werkzeug ruff)
|
||||
uv sync --frozen --extra dev --python 3.12
|
||||
source .venv/bin/activate
|
||||
|
||||
# alternativ ohne uv, mit pip (Hashes werden geprüft)
|
||||
python3.12 -m venv .venv && source .venv/bin/activate
|
||||
pip install --require-hashes -r requirements.lock
|
||||
pip install --no-deps -e .
|
||||
|
||||
# Tests (unittest aus der Standardbibliothek)
|
||||
python -m unittest # alle Tests (~50 s)
|
||||
MCP_CALC_SKIP_SLOW=1 python -m unittest # ohne Subprozess-/Server-Tests (~1 s)
|
||||
|
||||
# Server starten
|
||||
mcp-calculator # stdio (Standard)
|
||||
mcp-calculator --transport http --port 8080 # HTTP auf 127.0.0.1:8080/mcp
|
||||
python -m mcp_calculator --help # alle Optionen
|
||||
```
|
||||
|
||||
Ausprobieren:
|
||||
|
||||
```bash
|
||||
# Beispiel-Client (startet den Server selbst über stdio)
|
||||
python examples/client_example.py
|
||||
|
||||
# gegen einen laufenden HTTP-Server
|
||||
python examples/client_example.py http://localhost:8080/mcp [TOKEN]
|
||||
|
||||
# interaktiv im Browser mit dem MCP Inspector (braucht Node.js)
|
||||
npx @modelcontextprotocol/inspector .venv/bin/mcp-calculator
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tools im Überblick
|
||||
|
||||
| Tool | Zweck | Beispiel |
|
||||
|---|---|---|
|
||||
| `evaluate` | Wert exakt und dezimal, beliebige Präzision | `1000*(1+0.05)^10` → `1628.89462678` |
|
||||
| `transform` | simplify, expand, factor, apart, substitute, nsimplify … | `x^2-5x+6` → `(x-3)(x-2)` |
|
||||
| `solve` | Gleichungen, Systeme, Ungleichungen, numerisch | `["x+y=10","x-y=2"]` → `x=6, y=4` |
|
||||
| `calculus` | Ableitung, Integral, Grenzwert, Reihe, Summe, Gradient, Hesse, Jacobi | `∫e^(-x²)` von -∞ bis ∞ → `sqrt(pi)` |
|
||||
| `analyze_function` | komplette Kurvendiskussion | Nullstellen, Extrema, Wendepunkte, Asymptoten … |
|
||||
| `solve_ode` | Differentialgleichungen mit Anfangswerten | `y''+y=0, y(0)=0, y'(0)=1` → `sin(x)` |
|
||||
| `matrix` | Determinante, Inverse, Eigenwerte, LGS, SVD … (exakt oder numerisch) | `[[1,2],[3,4]]` → det `-2` |
|
||||
| `statistics` | deskriptive Statistik, Korrelation | Mittel, Median, Quartile, Schiefe … |
|
||||
| `regression` | linear, polynomial, exponentiell, logarithmisch, Potenz | Koeffizienten, R², Vorhersage |
|
||||
| `probability` | 14 Verteilungen: pdf/cdf/sf/Quantil/Intervall | `P(85 < X ≤ 115)` bei N(100, 15) |
|
||||
| `hypothesis_test` | t-Tests, ANOVA, χ², Mann-Whitney, Wilcoxon, Shapiro, Binomial | p-Wert und Entscheidung im Klartext |
|
||||
| `number_theory` | Primzahlen, ggT/kgV, Modular, CRT, Kombinatorik, Basen | `factorize 360` → `2^3 * 3^2 * 5` |
|
||||
| `convert_units` | Einheiten umrechnen und mit Einheiten rechnen | `100 km/h` → `27.78 m/s` |
|
||||
|
||||
**Designentscheidungen, die die Treffsicherheit des LLMs erhöhen:**
|
||||
|
||||
- *Wenige Tools, klar abgegrenzt.* Verwandte Operationen hängen an einem `operation`-Enum. Das LLM wählt zuverlässiger aus 13 Tools als aus 60.
|
||||
- *Server-`instructions`* erklären beim Verbindungsaufbau, welches Tool wofür gedacht ist, und beschreiben die Syntax.
|
||||
- *Verkettbare Ergebnisse*: `result` hat dieselbe Syntax wie die Eingabe. Eine Ableitung lässt sich also direkt an `solve` weitergeben.
|
||||
- *Exakt statt Gleitkomma*: `0.1 + 0.2 = 3/10`. Dezimalwerte kommen als String mit der gewünschten Stellenzahl zurück.
|
||||
- *Selbsterklärende Ergebnisse*: `meaning` bei Wahrscheinlichkeiten (`P(X > 7) = P(X >= 8)`), `conclusion` bei Tests, `note` bei Sonderfällen.
|
||||
- *Hilfreiche Fehler*: Tool-Fehler mit konkretem Hinweis (`matrix is singular …; try 'pseudo_inverse'`), damit das LLM den Aufruf selbst korrigieren kann.
|
||||
|
||||
---
|
||||
|
||||
## Transporte
|
||||
|
||||
| | stdio | Streamable HTTP |
|
||||
|---|---|---|
|
||||
| Start | Client startet den Prozess | Server läuft dauerhaft |
|
||||
| Einsatz | lokal, Einzelplatz, Entwicklung | zentral fürs Team, Pod |
|
||||
| Auth | keine | API-Token pro Client oder Netz-Freigabe, siehe [docs/auth.md](docs/auth.md) |
|
||||
| Endpoint | – | `POST http://<host>:8080/mcp` |
|
||||
|
||||
Der HTTP-Transport läuft **zustandslos** (`stateless_http`, JSON-Antworten). Es gibt keine
|
||||
MCP-Sessions, deshalb braucht es keine Sticky Sessions, und beliebig viele Replicas sind möglich.
|
||||
|
||||
```bash
|
||||
# Client anlegen (Token wird einmalig angezeigt)
|
||||
mcp-calculator-admin --file ./data/clients.json create mein-client
|
||||
|
||||
# HTTP, wie im Container
|
||||
MCP_CALC_TRANSPORT=http \
|
||||
MCP_CALC_HOST=0.0.0.0 \
|
||||
MCP_CALC_CLIENTS_FILE=./data/clients.json \
|
||||
MCP_CALC_LOG_FORMAT=json \
|
||||
mcp-calculator
|
||||
```
|
||||
|
||||
Rohtest mit curl:
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://localhost:8080/mcp \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Accept: application/json, text/event-stream" \
|
||||
-H "Authorization: Bearer mcpc_…" \
|
||||
-H "MCP-Protocol-Version: 2025-06-18" \
|
||||
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"evaluate","arguments":{"expression":"2^100"}}}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Clients anbinden
|
||||
|
||||
> Die Konfigurationsformate der Clients ändern sich gelegentlich. Die Beispiele entsprechen dem
|
||||
> Stand bei der Erstellung. Bitte gegen die Doku der eingesetzten Client-Version prüfen.
|
||||
|
||||
Jeder Client (Team, IDE-Installation, Dienst) bekommt ein **eigenes Token**:
|
||||
`mcp-calculator-admin create <name>`. Das Token wird im Header `Authorization: Bearer <token>`
|
||||
mitgeschickt, alternativ als `X-API-Key: <token>`. Verwaltung und Sperren: [docs/auth.md](docs/auth.md).
|
||||
|
||||
### opencode
|
||||
|
||||
`opencode.json` (Projekt) oder `~/.config/opencode/opencode.json` (global):
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"mcp": {
|
||||
"calculator": {
|
||||
"type": "remote",
|
||||
"url": "https://calculator.intern.example/mcp",
|
||||
"enabled": true,
|
||||
"headers": {
|
||||
"Authorization": "Bearer {env:MCP_CALC_TOKEN}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Lokal über stdio:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcp": {
|
||||
"calculator": {
|
||||
"type": "local",
|
||||
"command": ["/pfad/zu/mcp-calculator/.venv/bin/mcp-calculator"],
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Cline
|
||||
|
||||
`cline_mcp_settings.json` (in Cline über „MCP Servers → Configure“ erreichbar):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"calculator": {
|
||||
"type": "streamableHttp",
|
||||
"url": "https://calculator.intern.example/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer <token>"
|
||||
},
|
||||
"disabled": false,
|
||||
"autoApprove": [
|
||||
"evaluate", "transform", "solve", "calculus", "analyze_function", "solve_ode", "matrix",
|
||||
"statistics", "regression", "probability", "hypothesis_test", "number_theory", "convert_units"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Alle Tools sind rein lesend, deshalb ist `autoApprove` unbedenklich. Lokal über stdio:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"calculator": {
|
||||
"command": "/pfad/zu/mcp-calculator/.venv/bin/mcp-calculator",
|
||||
"args": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Claude Code
|
||||
|
||||
```bash
|
||||
claude mcp add --transport http calculator https://calculator.intern.example/mcp \
|
||||
--header "Authorization: Bearer <token>"
|
||||
# oder lokal
|
||||
claude mcp add calculator -- /pfad/zu/mcp-calculator/.venv/bin/mcp-calculator
|
||||
```
|
||||
|
||||
### Eigene Tools / Agenten
|
||||
|
||||
Jede Anwendung mit MCP-Client-SDK (Python, TypeScript, Java, Go …) kann den Server nutzen.
|
||||
Ein lauffähiges Python-Beispiel liegt in [`examples/client_example.py`](examples/client_example.py):
|
||||
|
||||
```python
|
||||
import httpx2
|
||||
from mcp import Client
|
||||
from mcp.client.streamable_http import streamable_http_client
|
||||
|
||||
async with httpx2.AsyncClient(headers={"Authorization": f"Bearer {token}"}) as http:
|
||||
async with Client(streamable_http_client("https://calculator.intern.example/mcp", http_client=http)) as client:
|
||||
tools = (await client.list_tools()).tools # -> als Tool-Definitionen ans LLM
|
||||
result = await client.call_tool("solve", {"equations": "x^2 = 2", "domain": "real"})
|
||||
print(result.structured_content)
|
||||
```
|
||||
|
||||
Einbindung in eine eigene LLM-Schleife:
|
||||
1. `list_tools()` ausführen und `name`, `description` und `input_schema` jedes Tools als Tool-Definition an das Modell geben.
|
||||
2. Fordert das Modell einen Tool-Call an, `call_tool(name, arguments)` aufrufen.
|
||||
3. `result.content[0].text` zurückgeben. Bei `result.is_error` den Rückgabewert als Fehler markieren.
|
||||
|
||||
> **Hinweis:** Serverseitige MCP-Connectoren von LLM-Anbietern rufen den MCP-Server aus der Cloud
|
||||
> des Anbieters auf. Ein nur intern erreichbarer Server funktioniert damit nicht. Bei internem
|
||||
> Betrieb muss der eigene Code MCP-Client sein (wie oben).
|
||||
|
||||
---
|
||||
|
||||
## Konfiguration
|
||||
|
||||
Jede Option ist per Umgebungsvariable `MCP_CALC_<NAME>` oder per CLI-Flag `--<name>` setzbar.
|
||||
Die CLI hat Vorrang vor der Umgebung, die Umgebung vor den Defaults. Eine Vorlage liegt in
|
||||
[`.env.example`](.env.example). Die Datei wird **nicht** automatisch geladen, sie dient als
|
||||
Referenz für die Pod-Umgebung.
|
||||
|
||||
| Variable | Default | Beschreibung |
|
||||
|---|---|---|
|
||||
| `MCP_CALC_TRANSPORT` | `stdio` | `stdio` oder `http` |
|
||||
| `MCP_CALC_HOST` | `127.0.0.1` | Bind-Adresse; im Container `0.0.0.0` |
|
||||
| `MCP_CALC_PORT` | `8080` | HTTP-Port |
|
||||
| `MCP_CALC_PATH` | `/mcp` | Pfad des MCP-Endpoints |
|
||||
| `MCP_CALC_ALLOWED_HOSTS` | – | erlaubte `Host`-Header, kommagetrennt; aktiviert den DNS-Rebinding-Schutz |
|
||||
| `MCP_CALC_ALLOWED_ORIGINS` | – | erlaubte `Origin`-Header (nur für Browser-Clients relevant) |
|
||||
| `MCP_CALC_TRUSTED_PROXIES` | – | IPs/CIDRs von Proxys (OpenShift-Router), deren `X-Forwarded-For` ausgewertet wird |
|
||||
| `MCP_CALC_MAX_REQUEST_BODY_BYTES` | `1048576` | maximale Request-Größe |
|
||||
| `MCP_CALC_MAX_CONNECTIONS` | `200` | gleichzeitige HTTP-Verbindungen; darüber antwortet uvicorn mit `503` |
|
||||
| `MCP_CALC_METRICS` | `true` | Prometheus-Metriken unter `/metrics` (nur HTTP) |
|
||||
| **Authentifizierung** | | *nur HTTP; Details in [docs/auth.md](docs/auth.md)* |
|
||||
| `MCP_CALC_CLIENTS_FILE` | – | Client-Datei (z. B. `/data/clients.json` auf RWX-PVC); aktiviert die Authentifizierung |
|
||||
| `MCP_CALC_CLIENTS_RELOAD_SECONDS` | `5` | wie oft die Datei auf Änderungen geprüft wird |
|
||||
| `MCP_CALC_AUTH_TOKENS` | – | statische Tokens `name:token,…` (einfache Alternative, Änderung nur per Neustart) |
|
||||
| `MCP_CALC_AUDIT_FILE` | `audit.log` neben der Client-Datei | Änderungsprotokoll |
|
||||
| `MCP_CALC_ADMIN_TOKENS` | – | Admin-Tokens `name:token,…`; nur wenn gesetzt, startet die Admin-API |
|
||||
| `MCP_CALC_ADMIN_HOST` | `127.0.0.1` | Bind-Adresse der Admin-API (Default: nur per `oc port-forward`/`exec`) |
|
||||
| `MCP_CALC_ADMIN_PORT` | `9090` | Port der Admin-API |
|
||||
| `MCP_CALC_EXECUTOR` | `process` | `process` (isolierter Worker-Pool) oder `inline` (nur Entwicklung) |
|
||||
| `MCP_CALC_WORKERS` | `2` | Anzahl Worker-Prozesse (≈ CPU-Kerne) |
|
||||
| `MCP_CALC_MAX_QUEUE` | `20` | so viele Berechnungen dürfen auf einen freien Worker warten; darüber wird sofort mit „busy“ abgelehnt (0 = keine Warteschlange) |
|
||||
| `MCP_CALC_MAX_QUEUE_WAIT_SECONDS` | `30` | maximale Wartezeit auf einen freien Worker, danach „busy“ |
|
||||
| `MCP_CALC_CLIENT_MAX_CONCURRENT` | `10` | gleichzeitige Berechnungen pro Client (0 = unbegrenzt); pro Client überschreibbar |
|
||||
| `MCP_CALC_TIMEOUT_SECONDS` | `10` | harter Timeout pro Berechnung |
|
||||
| `MCP_CALC_MAX_TASKS_PER_WORKER` | `200` | Worker nach N Aufgaben recyceln (0 = nie) |
|
||||
| `MCP_CALC_MEMORY_LIMIT_MB` | `512` | zusätzlicher Speicher, den eine Berechnung pro Worker belegen darf (nur Linux; 0 = aus), siehe [docs/memory.md](docs/memory.md) |
|
||||
| `MCP_CALC_MAX_INPUT_LENGTH` | `5000` | max. Zeichen pro Ausdruck |
|
||||
| `MCP_CALC_MAX_OUTPUT_LENGTH` | `20000` | längere Ergebnisse werden gekürzt |
|
||||
| `MCP_CALC_MAX_PRECISION` | `1000` | max. signifikante Stellen |
|
||||
| `MCP_CALC_MAX_MATRIX_SIZE` | `30` | max. Zeilen/Spalten |
|
||||
| `MCP_CALC_MAX_DATA_POINTS` | `1000000` | max. Werte pro Datensatz |
|
||||
| `MCP_CALC_MAX_LIST_ITEMS` | `10000` | max. Einträge sonstiger Listen |
|
||||
| `MCP_CALC_LOG_LEVEL` | `INFO` | `DEBUG`, `INFO`, `WARNING`, `ERROR` |
|
||||
| `MCP_CALC_LOG_FORMAT` | `text` | `text` oder `json` (eine Zeile pro Eintrag) |
|
||||
|
||||
---
|
||||
|
||||
## Sicherheit
|
||||
|
||||
**Ausdrucks-Parser (`ops/parsing.py`).** SymPys `parse_expr` nutzt intern `eval`. Abgesichert wird das in drei Schichten:
|
||||
|
||||
1. Die Eingabe wird vorab geprüft: Zeichen-Whitelist (keine Quotes, `[]`, `{}`, `;`, `:`, `\`),
|
||||
kein `__`, kein Attributzugriff (`.name`), keine Python-Keywords, keine Namen mit `_`.
|
||||
2. Ausgewertet wird in einem Namensraum, der **nur** freigegebene SymPy-Funktionen und ein leeres
|
||||
`__builtins__` enthält. Unbekannte Namen werden zu mathematischen Symbolen, nie zu Python-Objekten.
|
||||
3. Die Berechnung läuft isoliert in einem Worker-Prozess (siehe unten).
|
||||
|
||||
Die Tests in `tests/test_parsing.py` decken typische Injection-Versuche ab (`__import__`, `().__class__`,
|
||||
`lambda`, `open(...)`, `getattr`, …).
|
||||
|
||||
**Worker-Pool (`executor.py`).** Jede Berechnung läuft in einem separaten Prozess (Pebble-Pool, `spawn`):
|
||||
- Wird der Timeout überschritten, wird der Worker hart beendet und ersetzt. `9^9^9^9` blockiert also nichts.
|
||||
- Unter Linux darf eine Berechnung pro Worker höchstens `MCP_CALC_MEMORY_LIMIT_MB` zusätzlich belegen (`RLIMIT_AS`, gesetzt nach dem Laden der Bibliotheken). Ein Ausreißer endet mit `MemoryError`, statt den Pod zu killen.
|
||||
- BLAS/OpenMP laufen pro Worker einthreadig. Die Parallelität kommt aus der Worker-Anzahl, das CPU-Limit des Pods wird nicht überbucht.
|
||||
- Worker werden nach N Aufgaben recycelt, damit die SymPy-Caches nicht unbegrenzt wachsen.
|
||||
- Ist der Pool defekt, startet er sich selbst neu.
|
||||
|
||||
**HTTP.**
|
||||
- Authentifizierung pro Client (API-Token oder Netz-Freigabe). Tokens sind 256 Bit zufällig und werden nur als SHA-256-Hash gespeichert; Sperren, Ablaufdatum, Netz-Einschränkung und Rotation sind zur Laufzeit möglich. Details: **[docs/auth.md](docs/auth.md)**.
|
||||
- Die Admin-API läuft auf eigenem Port, standardmäßig nur auf `127.0.0.1`, mit eigenen Admin-Tokens; jede Änderung landet im Audit-Log.
|
||||
- `/healthz`, `/readyz` und `/metrics` bleiben offen für Probes und Prometheus.
|
||||
- Request-Body-Limit (Default 1 MiB), dazu Längenlimits für alle Eingaben.
|
||||
- DNS-Rebinding-Schutz: bei `127.0.0.1` automatisch aktiv, bei `0.0.0.0` über `MCP_CALC_ALLOWED_HOSTS` einschaltbar.
|
||||
- Ohne Client-Konfiguration und nicht an localhost gebunden, protokolliert der Server beim Start eine Warnung.
|
||||
|
||||
Die Einheiten-Umrechnung (Pint) nutzt einen eigenen Tokenizer ohne `eval`.
|
||||
|
||||
---
|
||||
|
||||
## Betrieb
|
||||
|
||||
Die Anbindung an OpenShift ist nicht Teil dieses Projekts. Für die eigene Integration relevant:
|
||||
|
||||
| Punkt | Wert |
|
||||
|---|---|
|
||||
| Start | `mcp-calculator` (Entry Point) oder `python -m mcp_calculator` |
|
||||
| Pflicht-Env im Container | `MCP_CALC_TRANSPORT=http`, `MCP_CALC_HOST=0.0.0.0` |
|
||||
| Ports | `8080` MCP; `9090` Admin-API (nur mit `MCP_CALC_ADMIN_TOKENS`, Default nur `127.0.0.1`). Unprivilegiert, beliebige UID möglich |
|
||||
| Volume | RWX-PVC z. B. unter `/data` für `clients.json` und `audit.log`, sonst wird nichts geschrieben |
|
||||
| Liveness | `GET /healthz` → `200 {"status":"ok","version":…}` |
|
||||
| Readiness | `GET /readyz` → `200` erst, wenn die Worker gestartet sind und ihre Bibliotheken geladen haben, sonst `503` |
|
||||
| Metriken | `GET /metrics` (Prometheus, ohne Token), siehe [Monitoring](#monitoring) |
|
||||
| Shutdown | `SIGTERM`: keine neuen Verbindungen, laufende Requests werden fertig gerechnet, danach wird der Worker-Pool geordnet gestoppt (Log: `worker pool stopped`) |
|
||||
| Überlast | Sechs Schutzschichten (Verbindungen, pro Client, Kapazität, Wartezeit, Timeout, Abbruch bei Verbindungsende); Ablehnungen sind Tool-Fehler mit „retry“-Hinweis. Siehe [docs/auth.md → Überlastschutz](docs/auth.md#überlastschutz) |
|
||||
| Skalierung | zustandslos, horizontal skalierbar, keine Sticky Sessions |
|
||||
| Ressourcen | `MCP_CALC_WORKERS` ≈ CPU-Limit. Leerlauf gemessen (macOS, RSS): ~90 MB + ~140 MB je Worker, mit 2 Workern ~360 MB. Pod-Limit ≥ Leerlauf + `WORKERS` × `MEMORY_LIMIT_MB`. Analyse, Profile und Messskript: **[docs/memory.md](docs/memory.md)** |
|
||||
| Logs | stderr; `MCP_CALC_LOG_FORMAT=json` für Log-Aggregation. Pro Tool-Aufruf: `tool=<name> client=<client> status=… duration_ms=…`; abgelehnte Anfragen: `auth rejected: reason=… client=… ip=…` |
|
||||
| Startzeit | `/readyz` nach ~1,2 s (Worker vorgewärmt); der erste Aufruf ist danach sofort schnell |
|
||||
|
||||
---
|
||||
|
||||
## Monitoring
|
||||
|
||||
`GET /metrics` liefert Prometheus-Textformat. Wie `/healthz` und `/readyz` ist der Endpoint ohne Token
|
||||
erreichbar, damit Prometheus im Cluster scrapen kann. Er enthält Zähler und Client-Namen, keine Rechen-Eingaben.
|
||||
Wer das nicht möchte, schränkt den Zugriff per NetworkPolicy ein oder setzt `MCP_CALC_METRICS=false`.
|
||||
|
||||
| Metrik | Typ | Bedeutung |
|
||||
|---|---|---|
|
||||
| `mcp_calc_tool_calls_total{tool, client, status}` | Counter | Aufrufe je Tool, Client und Ergebnis: `ok`, `error` (Mathe-/Eingabefehler), `timeout`, `busy` (Kapazität, Wartezeit oder Client-Limit), `cancelled` (Client hat Verbindung getrennt), `crashed` (Worker gestorben). `client` ist `local` bei stdio, `anonymous` ohne Client-Konfiguration |
|
||||
| `mcp_calc_tool_duration_seconds{tool}` | Histogram | Dauer inkl. Wartezeit in der Queue |
|
||||
| `mcp_calc_inflight_computations` | Gauge | laufende + wartende Berechnungen |
|
||||
| `mcp_calc_capacity` | Gauge | `WORKERS + MAX_QUEUE`; darüber wird abgelehnt |
|
||||
| `mcp_calc_workers` | Gauge | konfigurierte Worker |
|
||||
| `mcp_calc_pool_restarts_total` | Counter | Neustarts des Pools nach internem Fehler |
|
||||
| `mcp_calc_client_inflight_computations{client}` | Gauge | laufende + wartende Berechnungen je Client |
|
||||
| `mcp_calc_auth_failures_total{reason}` | Counter | abgelehnte Anfragen: `missing_credentials`, `invalid_token`, `disabled`, `expired`, `network_denied` |
|
||||
| `mcp_calc_clients{status}` | Gauge | konfigurierte Clients: `active`, `disabled`, `expired` |
|
||||
| `mcp_calc_build_info{version}` | Info | Version |
|
||||
| `process_*`, `python_*` | Standard | CPU, RSS, Dateien des **Server**-Prozesses (Worker nicht enthalten) |
|
||||
|
||||
Nicht gezählt werden Aufrufe, die schon an der Schema-Validierung scheitern (falscher Parametertyp).
|
||||
Die beantwortet das MCP SDK, bevor der Tool-Code läuft.
|
||||
|
||||
Nützliche Abfragen:
|
||||
|
||||
```promql
|
||||
# Fehler- und Timeout-Quote je Tool (5 min)
|
||||
sum by (tool, status) (rate(mcp_calc_tool_calls_total{status!="ok"}[5m]))
|
||||
/ ignoring(status) group_left sum by (tool) (rate(mcp_calc_tool_calls_total[5m]))
|
||||
|
||||
# p95-Dauer je Tool
|
||||
histogram_quantile(0.95, sum by (tool, le) (rate(mcp_calc_tool_duration_seconds_bucket[5m])))
|
||||
|
||||
# Auslastung (Alarm z. B. > 0.8 über 5 min → mehr Worker oder Replicas)
|
||||
mcp_calc_inflight_computations / mcp_calc_capacity
|
||||
|
||||
# Überlast-Ablehnungen, nach Client
|
||||
sum by (client) (rate(mcp_calc_tool_calls_total{status="busy"}[5m]))
|
||||
|
||||
# Wer belegt gerade die Kapazität?
|
||||
topk(5, mcp_calc_client_inflight_computations)
|
||||
|
||||
# Auffällige Auth-Fehler (falsch konfigurierte Clients oder Rateversuche)
|
||||
sum by (reason) (rate(mcp_calc_auth_failures_total[5m]))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architektur
|
||||
|
||||
```
|
||||
┌────────────────────────── Server-Prozess ──────────────────────────┐
|
||||
Client ─stdio──►│ MCPServer (server.py) │
|
||||
─HTTP───►│ └ Starlette/uvicorn (http_app.py): Bearer-Auth, /healthz, /readyz │
|
||||
│ Tool-Funktionen: Validierung (Pydantic-Schemas) → Executor │
|
||||
└───────────────────────────────┬─────────────────────────────────────┘
|
||||
│ Task-Name + Argumente, Timeout
|
||||
┌──────────────────▼──────────────────┐
|
||||
│ Worker-Pool (executor.py, Pebble) │
|
||||
│ ops/*.py – reine Mathe-Funktionen │
|
||||
│ SymPy · mpmath · NumPy · SciPy · Pint
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Der Server-Prozess lädt nur MCP SDK und HTTP-Stack, die Worker nur die Mathe-Bibliotheken.
|
||||
Das halbiert den Speicherbedarf ungefähr (Details in [docs/memory.md](docs/memory.md)) und wird von
|
||||
`tests/test_footprint.py` abgesichert.
|
||||
|
||||
```
|
||||
src/mcp_calculator/
|
||||
├── __main__.py Entry Point: Konfiguration laden, Logging, Transport starten
|
||||
├── config.py Settings aus Env/CLI, Validierung, Limits
|
||||
├── server.py MCP-Tools: Schemas, Beschreibungen, Server-Instructions
|
||||
├── http_app.py HTTP-Transport: ASGI-App, Abbruch bei Verbindungsende, Health, Admin-Server
|
||||
├── auth.py Authentifizierung (Token, Netz) und Client-Identität je Request
|
||||
├── clients.py Client-Modell, Client-Datei (Lock, atomar), Hot-Reload
|
||||
├── admin.py Client-Verwaltung (gemeinsame Logik), Audit-Log, Admin-API
|
||||
├── admin_cli.py CLI mcp-calculator-admin
|
||||
├── ratelimit.py Limit gleichzeitiger Berechnungen pro Client
|
||||
├── executor.py Worker-Pool: Timeout, Speicherlimit, Backpressure, Vorwärmen, Selbstheilung
|
||||
├── metrics.py Prometheus-Metriken
|
||||
├── logging_setup.py Text-/JSON-Logging nach stderr
|
||||
└── ops/ reine, synchrone Mathe-Funktionen (dict rein, dict raus)
|
||||
├── spec.py Typen/Enums/Hilfetexte für die Tool-Schemas (import-leicht, auch im Server)
|
||||
├── context.py Fehlertyp und Limits (import-leicht)
|
||||
├── parsing.py sicherer Ausdrucks-Parser
|
||||
├── common.py Ergebnis-Formatierung, Limit-Prüfungen
|
||||
├── algebra.py evaluate, transform, solve
|
||||
├── calculus.py calculus, analyze_function, solve_ode
|
||||
├── linalg.py matrix
|
||||
├── stats.py statistics, regression, probability, hypothesis_test
|
||||
├── number_theory.py
|
||||
└── units.py
|
||||
tests/ unittest: pro Modul + End-to-End (in-process, HTTP, stdio, Pool, Footprint)
|
||||
examples/ lauffähiger MCP-Client
|
||||
scripts/ measure_memory.py – Speicher messen
|
||||
docs/tools.md vollständige Tool-Referenz
|
||||
docs/memory.md Speicheranalyse und Dimensionierung
|
||||
docs/auth.md Authentifizierung, Client-Verwaltung, Überlastschutz (Betriebshandbuch)
|
||||
uv.lock Lockfile (uv), Quelle der Wahrheit für Versionen
|
||||
requirements.lock daraus exportiert, mit Hashes, für pip/Container-Builds
|
||||
BACKLOG.md geplante Features und offene Themen
|
||||
```
|
||||
|
||||
### Neues Tool hinzufügen
|
||||
|
||||
1. Eine reine Funktion in `ops/<bereich>.py` schreiben: Parameter rein, JSON-fähiges `dict` raus.
|
||||
Erwartbare Fehler als `MathError("verständliche Meldung")` werfen. Bei einem neuen Modul dieses in
|
||||
`executor.TASK_MODULES` eintragen.
|
||||
2. Enums/Literal-Typen für die Parameter in `ops/spec.py` definieren, **nicht** im Rechenmodul.
|
||||
`server.py` darf nur `spec` importieren, sonst landen SymPy/SciPy wieder im Server-Prozess
|
||||
(`tests/test_footprint.py` schlägt dann fehl).
|
||||
3. In `server.py` ein `@mcp.tool(annotations=READ_ONLY)` mit typisierten, per `Field(description=…)`
|
||||
beschriebenen Parametern anlegen, das `await run("<name>", "<modul>.<funktion>", **kwargs)` aufruft.
|
||||
4. Das Tool in `INSTRUCTIONS` (Abschnitt „Which tool“) und in `docs/tools.md` ergänzen.
|
||||
5. Tests schreiben: Fachlogik direkt in `tests/test_<bereich>.py`, Aufruf über MCP in `tests/test_server.py`
|
||||
(dort auch die Liste `EXPECTED_TOOLS` anpassen).
|
||||
|
||||
---
|
||||
|
||||
## Entwicklung
|
||||
|
||||
```bash
|
||||
source .venv/bin/activate
|
||||
python -m unittest # alle Tests
|
||||
MCP_CALC_SKIP_SLOW=1 python -m unittest # schnelle Tests ohne Subprozesse
|
||||
python -m unittest tests.test_parsing -v # nur Parser/Sicherheit
|
||||
python -m unittest tests.test_algebra.SolveTest.test_quadratic # ein einzelner Test
|
||||
ruff check src tests examples scripts # Linting (Konfiguration in pyproject.toml)
|
||||
MCP_CALC_EXECUTOR=inline MCP_CALC_LOG_LEVEL=DEBUG mcp-calculator # Debugging ohne Worker-Pool
|
||||
```
|
||||
|
||||
**Abhängigkeiten aktualisieren.** `pyproject.toml` enthält die erlaubten Bereiche, `uv.lock` die exakt
|
||||
getesteten Versionen. Nach einer Aktualisierung immer beide Lock-Dateien committen:
|
||||
|
||||
```bash
|
||||
uv lock --upgrade # oder gezielt: uv lock --upgrade-package mcp
|
||||
uv export --frozen --format requirements-txt --no-emit-project --no-header -o requirements.lock
|
||||
uv sync --frozen --extra dev --python 3.12 && python -m unittest
|
||||
```
|
||||
|
||||
> **Hinweis zur Python-Version:** Ist in der Shell `UV_PYTHON` gesetzt (z. B. `UV_PYTHON=3.13`), hat das bei
|
||||
> uv Vorrang vor `.python-version`. Deshalb steht in den Befehlen oben `--python 3.12` explizit.
|
||||
|
||||
Code, Docstrings und Tool-Beschreibungen sind auf Englisch. Das ist die Sprache, mit der LLMs
|
||||
Tool-Schemas am zuverlässigsten interpretieren. Die Projektdokumentation ist auf Deutsch.
|
||||
|
||||
---
|
||||
|
||||
## Grenzen
|
||||
|
||||
- **Formulierung bleibt beim LLM.** Der Server rechnet korrekt, aber das Modell muss die Aufgabe richtig in einen Aufruf übersetzen. Diese Fehlerquelle bleibt.
|
||||
- **Symbolik hat Grenzen.** Nicht jedes Integral, jede Gleichung und jede DGL hat eine geschlossene Form. Der Server fällt dann auf numerische Verfahren zurück (Integral, Summe) oder gibt einen Hinweis (`numeric_only`). Aufwendige Probleme laufen in den Timeout.
|
||||
- **Numerisches Lösen** (`numeric_only`) liefert bei nicht-polynomialen Gleichungen eine Wurzel nahe dem Startwert, nicht alle.
|
||||
- **Speicherlimit pro Worker** greift nur unter Linux. Unter macOS gilt nur der Timeout. Die Speicherwerte in der Doku sind unter macOS gemessen; im Container bitte mit `scripts/measure_memory.py` nachmessen.
|
||||
- **Keine Plots**, keine Finanzmathematik-Spezialfunktionen (IRR, Annuitäten lassen sich über `evaluate`/`solve` abbilden). Geplant siehe [BACKLOG.md](BACKLOG.md).
|
||||
- **Warteschlange pro Replica.** `MAX_QUEUE` begrenzt nur den einzelnen Pod; Lastverteilung über mehrere Replicas übernimmt der Service/Router.
|
||||
248
docs/auth.md
Normal file
248
docs/auth.md
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
# Authentifizierung, Client-Verwaltung und Überlastschutz
|
||||
|
||||
Betriebshandbuch für den HTTP-Betrieb. Lokal über **stdio gibt es keine Authentifizierung** und keine
|
||||
Limits pro Client, denn dort startet der Nutzer den Prozess selbst.
|
||||
|
||||
- [Überblick](#überblick)
|
||||
- [Einrichtung im Pod](#einrichtung-im-pod)
|
||||
- [Clients verwalten](#clients-verwalten) (CLI und Admin-API)
|
||||
- [Einstellungen pro Client](#einstellungen-pro-client)
|
||||
- [Netzwerk-Clients und Proxys](#netzwerk-clients-und-proxys)
|
||||
- [Abläufe: anlegen, sperren, rotieren](#abläufe)
|
||||
- [Überlastschutz](#überlastschutz)
|
||||
- [Sicherheitshinweise](#sicherheitshinweise)
|
||||
- [Ausblick: Keycloak](#ausblick-keycloak)
|
||||
|
||||
---
|
||||
|
||||
## Überblick
|
||||
|
||||
```
|
||||
┌──────────────────────────── Pod (Replica 1..N) ────────────────────────────┐
|
||||
Client ──HTTP──► │ :8080 uvicorn ─► Auth (Token/Netz) ─► Limit pro Client ─► Queue ─► Worker │
|
||||
│ :9090 Admin-API (nur 127.0.0.1) │
|
||||
└────────────┬──────────────────────────────────────────────────▲────────────┘
|
||||
│ schreibt (Lock, atomar) │ liest bei Änderung
|
||||
▼ │ (alle 5 s geprüft)
|
||||
/data/clients.json + /data/audit.log (RWX-PVC, alle Replicas)
|
||||
```
|
||||
|
||||
Es gibt drei Betriebsarten:
|
||||
|
||||
| Konfiguration | Verhalten |
|
||||
|---|---|
|
||||
| `MCP_CALC_CLIENTS_FILE` gesetzt | **Empfohlen.** Clients aus der Datei, änderbar zur Laufzeit per CLI oder Admin-API |
|
||||
| nur `MCP_CALC_AUTH_TOKENS=name:token,…` | einfache statische Tokens (z. B. aus einem Secret), Änderung nur per Neustart |
|
||||
| nichts gesetzt | keine Authentifizierung (Warnung im Log, wenn nicht an localhost gebunden) |
|
||||
|
||||
Beide Quellen lassen sich kombinieren, die Client-Namen müssen dann eindeutig sein.
|
||||
|
||||
Jeder Request wird einem **Client** zugeordnet. Der Name erscheint in Logs (`tool=… client=…`) und
|
||||
Metriken (`client`-Label). Bei Überlast ist dadurch sofort sichtbar, wer sie verursacht.
|
||||
|
||||
---
|
||||
|
||||
## Einrichtung im Pod
|
||||
|
||||
| Einstellung | Wert | Zweck |
|
||||
|---|---|---|
|
||||
| PVC | RWX, gemountet z. B. unter `/data` | gemeinsame Client-Datei für alle Replicas |
|
||||
| `MCP_CALC_CLIENTS_FILE` | `/data/clients.json` | aktiviert die Authentifizierung; Datei darf anfangs fehlen (= keine Clients) |
|
||||
| `MCP_CALC_AUDIT_FILE` | Default `/data/audit.log` | Änderungsprotokoll |
|
||||
| `MCP_CALC_ADMIN_TOKENS` | `ops:<langes-zufälliges-token>` aus einem **Secret** | aktiviert die Admin-API; mehrere Admins kommagetrennt |
|
||||
| `MCP_CALC_ADMIN_HOST` / `_PORT` | `127.0.0.1` / `9090` | Admin-API nur im Pod erreichbar (siehe unten) |
|
||||
| `MCP_CALC_TRUSTED_PROXIES` | IP/CIDR des OpenShift-Routers | nur nötig für Netz-Einschränkungen hinter der Route |
|
||||
|
||||
Admin-Token erzeugen: `python -c "import secrets; print(secrets.token_urlsafe(32))"`.
|
||||
|
||||
**Zugriff auf die Admin-API.** Mit dem Default `127.0.0.1` ist sie nur aus dem Pod heraus erreichbar:
|
||||
|
||||
```bash
|
||||
oc port-forward deploy/mcp-calculator 9090:9090 # dann lokal: http://localhost:9090/admin/clients
|
||||
# oder direkt die CLI im Pod:
|
||||
oc exec deploy/mcp-calculator -- mcp-calculator-admin list
|
||||
```
|
||||
|
||||
Soll ein anderer Dienst im Cluster die API nutzen, `MCP_CALC_ADMIN_HOST=0.0.0.0` setzen, einen eigenen
|
||||
internen Service **ohne Route** anlegen und per NetworkPolicy einschränken.
|
||||
|
||||
**Wartung der Datei.** Alle Replicas lesen `/data/clients.json`. Geschrieben wird unter einer exklusiven
|
||||
POSIX-Sperre (`fcntl.lockf`, funktioniert auf NFS und CephFS) und atomar per Umbenennen, sodass nie eine
|
||||
halb geschriebene Datei gelesen wird. Ist die Datei nach einer Änderung ungültig (z. B. Tippfehler bei
|
||||
Handbearbeitung), **behalten die Replicas den letzten gültigen Stand** und loggen einen Fehler. Beim
|
||||
Start führt eine ungültige Datei dagegen zum Abbruch. Die Datei gehört ins Backup.
|
||||
|
||||
---
|
||||
|
||||
## Clients verwalten
|
||||
|
||||
### CLI
|
||||
|
||||
```bash
|
||||
export MCP_CALC_CLIENTS_FILE=/data/clients.json # im Pod bereits gesetzt
|
||||
|
||||
mcp-calculator-admin create opencode-team-a --max-concurrent 4 --expires 2027-03-31 --note "Team A, IDE"
|
||||
mcp-calculator-admin create reporting-service --networks 10.130.4.0/24
|
||||
mcp-calculator-admin create legacy-batch --network-only --networks 10.131.2.15
|
||||
mcp-calculator-admin list
|
||||
mcp-calculator-admin show opencode-team-a
|
||||
mcp-calculator-admin disable opencode-team-a # sperren
|
||||
mcp-calculator-admin enable opencode-team-a
|
||||
mcp-calculator-admin set opencode-team-a --max-concurrent 8 --expires never --networks none
|
||||
mcp-calculator-admin rotate opencode-team-a # neues Token, altes sofort ungültig
|
||||
mcp-calculator-admin delete opencode-team-a --yes
|
||||
mcp-calculator-admin --json list # maschinenlesbar
|
||||
```
|
||||
|
||||
Beispielausgabe von `list`:
|
||||
|
||||
```
|
||||
NAME STATUS AUTH LIMIT EXPIRES TOKEN NETWORKS
|
||||
legacy-batch active network default - - 10.131.2.15/32
|
||||
opencode-team-a active token 4 2027-03-31 mcpc_kFD5… -
|
||||
reporting-service disabled token default - mcpc_x81Q… 10.130.4.0/24
|
||||
```
|
||||
|
||||
### Admin-API
|
||||
|
||||
Alle Aufrufe brauchen `Authorization: Bearer <admin-token>`.
|
||||
|
||||
| Methode | Pfad | Body | Ergebnis |
|
||||
|---|---|---|---|
|
||||
| `GET` | `/admin/clients` | – | Liste aller Clients (ohne Hashes) |
|
||||
| `POST` | `/admin/clients` | `{"name", "auth"?, "max_concurrent"?, "expires"?, "allowed_networks"?, "note"?}` | `201`, Client + **Token (einmalig)** |
|
||||
| `GET` | `/admin/clients/{name}` | – | ein Client |
|
||||
| `PATCH` | `/admin/clients/{name}` | `{"enabled"?, "max_concurrent"?, "expires"?, "allowed_networks"?, "note"?}` | geänderter Client |
|
||||
| `POST` | `/admin/clients/{name}/rotate` | – | Client + **neues Token (einmalig)** |
|
||||
| `DELETE` | `/admin/clients/{name}` | – | `{"deleted": name}` |
|
||||
| `GET` | `/healthz` | – | ohne Token |
|
||||
|
||||
Fehler: `400` ungültige Eingabe, `401` fehlendes/falsches Admin-Token, `404` unbekannt, `409` existiert bereits.
|
||||
|
||||
```bash
|
||||
A="Authorization: Bearer $ADMIN_TOKEN"
|
||||
curl -s -H "$A" -H 'Content-Type: application/json' -X POST localhost:9090/admin/clients \
|
||||
-d '{"name": "agent-reporting", "max_concurrent": 2, "expires": "2027-06-30"}'
|
||||
curl -s -H "$A" -H 'Content-Type: application/json' -X PATCH localhost:9090/admin/clients/agent-reporting \
|
||||
-d '{"enabled": false}'
|
||||
```
|
||||
|
||||
Die Replica, die den Admin-Aufruf bearbeitet, übernimmt die Änderung sofort. Alle anderen Replicas
|
||||
folgen nach spätestens `MCP_CALC_CLIENTS_RELOAD_SECONDS` (Default 5 s).
|
||||
|
||||
### Clients verwenden
|
||||
|
||||
```
|
||||
Authorization: Bearer mcpc_… (Standard, z. B. opencode "headers", Cline "headers")
|
||||
X-API-Key: mcpc_… (Alternative für Dienste, die Authorization nicht setzen können)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Einstellungen pro Client
|
||||
|
||||
| Feld | Default | Bedeutung |
|
||||
|---|---|---|
|
||||
| `auth` | `token` | `token` = Token nötig; `network` = nur über die Quell-IP (siehe unten) |
|
||||
| `enabled` | `true` | `false` sperrt den Client, ohne ihn zu löschen (Antwort `403 client is disabled`) |
|
||||
| `expires` | – | letzter gültiger Tag (UTC), danach `403 API token has expired` |
|
||||
| `max_concurrent` | Server-Default | gleichzeitige Berechnungen dieses Clients; `0` = unbegrenzt; nicht gesetzt = `MCP_CALC_CLIENT_MAX_CONCURRENT` |
|
||||
| `allowed_networks` | – | IPs/CIDRs, aus denen der Client zugreifen darf; leer = überall |
|
||||
| `note` | – | Freitext (Team, Ansprechpartner, Zweck) |
|
||||
| `token_hint` | automatisch | die ersten Zeichen des Tokens zur Wiedererkennung (`mcpc_kFD5…`) |
|
||||
| `created`, `updated` | automatisch | Zeitstempel |
|
||||
|
||||
---
|
||||
|
||||
## Netzwerk-Clients und Proxys
|
||||
|
||||
Für einen anderen Dienst gibt es zwei Möglichkeiten:
|
||||
|
||||
1. **Token mit Netz-Einschränkung (empfohlen):** `create reporting-service --networks 10.130.4.0/24`.
|
||||
Das Token gilt nur aus diesem Netz. Ein gestohlenes Token ist von anderswo wertlos.
|
||||
2. **Nur Netz** (`--network-only`): kein Token, Zugang allein über die Quell-IP. Das ist bequem, aber schwächer:
|
||||
- In OpenShift wechseln Pod-IPs. Ein Netz muss daher den ganzen Bereich abdecken, aus dem der Dienst kommt.
|
||||
- Hinter der Route sieht der Server nur die IP des Routers. Die echte Client-IP steht in
|
||||
`X-Forwarded-For`, und der Server wertet sie **nur** aus, wenn der direkte Absender in
|
||||
`MCP_CALC_TRUSTED_PROXIES` steht. Sonst könnte jeder den Header fälschen.
|
||||
- Innerhalb des Clusters (Aufruf über den Service, ohne Route) sieht der Server die echte Pod-IP.
|
||||
- Ergänzend sollte eine NetworkPolicy den Zugriff auf Namespace- oder Label-Ebene begrenzen.
|
||||
|
||||
Reihenfolge der Prüfung: Wird ein Token mitgeschickt, muss es gültig sein. Ein falsches Token wird
|
||||
auch aus einem freigegebenen Netz abgelehnt. Nur ohne Token greifen Netzwerk-Clients, bei mehreren
|
||||
Treffern gewinnt das spezifischste Netz.
|
||||
|
||||
Signierte Header (HMAC) sind vorgesehen, aber nicht umgesetzt, siehe [BACKLOG.md](../BACKLOG.md).
|
||||
|
||||
---
|
||||
|
||||
## Abläufe
|
||||
|
||||
**Neuer Nutzer oder Dienst:** `create`, das Token sicher übergeben (Passwort-Manager, Secret) und
|
||||
im Client als Header eintragen. Das Token lässt sich nicht erneut anzeigen. Geht es verloren: `rotate`.
|
||||
|
||||
**Sofort sperren** (z. B. Token geleakt oder Agent läuft Amok): `disable <name>`. Das wirkt auf der
|
||||
bearbeitenden Replica sofort, auf allen anderen nach spätestens 5 s. Danach `rotate` und das neue
|
||||
Token ausgeben, zum Schluss `enable`.
|
||||
|
||||
**Regelmäßig rotieren:** `expires` setzen. Vor Ablauf `rotate`, dann `set --expires <neues Datum>`.
|
||||
|
||||
**Überblick:** `list` bzw. die Metriken `mcp_calc_clients{status}` und
|
||||
`mcp_calc_auth_failures_total{reason}`. Häufige `invalid_token`-Fehler deuten auf falsch
|
||||
konfigurierte Clients oder Rateversuche hin.
|
||||
|
||||
**Audit:** `/data/audit.log`, eine JSON-Zeile pro Änderung, z. B.
|
||||
|
||||
```json
|
||||
{"time": "2026-09-11T17:30:42+00:00", "actor": "admin:ops", "action": "create", "client": "demo", "details": {"max_concurrent": 2, "token_hint": "mcpc_cv1k"}}
|
||||
```
|
||||
|
||||
`actor` ist `admin:<name>` (Admin-API) oder `cli:<user>` (CLI). Tokens und Hashes werden nie protokolliert.
|
||||
|
||||
---
|
||||
|
||||
## Überlastschutz
|
||||
|
||||
Mehrere Schutzschichten greifen nacheinander. Jede lehnt früh und billig ab, statt Last aufzustauen:
|
||||
|
||||
| # | Schicht | Einstellung (Default) | Wirkung bei Überschreitung | Metrik-Status |
|
||||
|---|---|---|---|---|
|
||||
| 1 | Verbindungen | `MCP_CALC_MAX_CONNECTIONS` (200) | HTTP `503` von uvicorn | – |
|
||||
| 2 | Limit pro Client | `MCP_CALC_CLIENT_MAX_CONCURRENT` (10) bzw. `max_concurrent` | Tool-Fehler „client busy“ | `busy` |
|
||||
| 3 | Gesamtkapazität | `MCP_CALC_WORKERS` + `MCP_CALC_MAX_QUEUE` (2 + 20) | Tool-Fehler „server busy“ | `busy` |
|
||||
| 4 | Wartezeit in der Queue | `MCP_CALC_MAX_QUEUE_WAIT_SECONDS` (30) | Tool-Fehler „no worker became free“ | `busy` |
|
||||
| 5 | Rechenzeit | `MCP_CALC_TIMEOUT_SECONDS` (10) | Worker wird beendet, Tool-Fehler „timeout“ | `timeout` |
|
||||
| 6 | Verbindungsabbruch | immer aktiv | wartende Berechnung verworfen, laufende abgebrochen (Worker beendet) | `cancelled` |
|
||||
|
||||
Die Ablehnungen der Schichten 2–5 sind **Tool-Fehler mit Hinweis „retry“**. Das LLM bzw. der Agent
|
||||
sieht, dass es später erneut versuchen soll. Schicht 6 verhindert, dass Berechnungen weiterlaufen,
|
||||
auf deren Ergebnis niemand mehr wartet.
|
||||
|
||||
Alle Zähler gelten **pro Replica**. Das passt zum Ziel Überlastschutz: Jede Replica schützt sich
|
||||
selbst, ein gemeinsamer Zähler ist nicht nötig. Mit mehr Replicas steigt die Gesamtkapazität linear.
|
||||
|
||||
**Hinweise zur Dimensionierung:**
|
||||
- `CLIENT_MAX_CONCURRENT` kleiner als `WORKERS + MAX_QUEUE` wählen, sonst kann ein einzelner Client alles belegen.
|
||||
- `MAX_QUEUE_WAIT_SECONDS` unter dem Timeout der Clients halten, sonst warten Anfragen, die niemand mehr abholt. Die Schicht 6 fängt das zusätzlich ab.
|
||||
- Alarm z. B. auf `mcp_calc_inflight_computations / mcp_calc_capacity > 0.8` oder auf `rate(mcp_calc_tool_calls_total{status="busy"}[5m]) > 0`, siehe README, Abschnitt Monitoring.
|
||||
- Lastspitzen einzelner Clients erkennt man an `mcp_calc_client_inflight_computations{client}` und `mcp_calc_tool_calls_total{client, status="busy"}`.
|
||||
|
||||
---
|
||||
|
||||
## Sicherheitshinweise
|
||||
|
||||
- **Nur Hashes gespeichert:** Wer die Datei lesen kann, kann damit nicht zugreifen, aber Clients anlegen oder entsperren, wenn er schreiben kann. PVC-Zugriff daher auf den Pod beschränken. Die Datei wird mit Rechten `0640` geschrieben.
|
||||
- **Tokens** sind 256 Bit zufällig (`mcpc_` + 43 Zeichen). Admin-Tokens mindestens ebenso lang wählen (Mindestlänge 16 Zeichen wird erzwungen).
|
||||
- **Admin-API** nie über eine Route veröffentlichen. Default ist `127.0.0.1`.
|
||||
- `/healthz`, `/readyz` und `/metrics` sind ohne Token erreichbar. Sie enthalten keine Rechen-Eingaben, `/metrics` aber Client-Namen. Bei Bedarf per NetworkPolicy einschränken oder `MCP_CALC_METRICS=false` setzen.
|
||||
- Abgelehnte Anfragen werden mit Grund, Client (falls bekannt) und IP geloggt (`auth rejected: reason=…`).
|
||||
|
||||
---
|
||||
|
||||
## Ausblick: Keycloak
|
||||
|
||||
Geplant ist die Prüfung von Keycloak-JWTs (OIDC) über den `TokenVerifier` des MCP SDK, zusätzlich zu den
|
||||
API-Tokens. Die Identität aus dem JWT (z. B. `azp`/Client-ID oder ein Claim) wird dann genauso wie
|
||||
heute ein Client behandelt: gleiche Limits, gleiche Metriken. API-Tokens bleiben für Dienste und
|
||||
Clients, die kein OIDC können. Offene Frage vor der Umsetzung: ob opencode und Cline den OAuth-Ablauf
|
||||
von MCP gegen Keycloak unterstützen. Details siehe [BACKLOG.md](../BACKLOG.md).
|
||||
154
docs/memory.md
Normal file
154
docs/memory.md
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
# Speicherbedarf
|
||||
|
||||
Analyse, woher der Speicherbedarf kommt, welche Optimierungen umgesetzt sind und wie man den
|
||||
Pod dimensioniert. Alle Werte sind **RSS**, gemessen unter macOS (arm64, Python 3.12). Unter
|
||||
Linux im Container liegen sie erfahrungsgemäß in derselben Größenordnung, **messt dort bitte
|
||||
selbst nach**:
|
||||
|
||||
```bash
|
||||
python scripts/measure_memory.py imports # Kosten pro Bibliothek
|
||||
python scripts/measure_memory.py server # kompletter Server, 2 Worker, nach Warm-up
|
||||
python scripts/measure_memory.py server --workers 4
|
||||
```
|
||||
|
||||
## Kurzfassung
|
||||
|
||||
| Konfiguration | Server | je Worker | Resource-Tracker | **Summe** |
|
||||
|---|---|---|---|---|
|
||||
| 1 Worker | 70 MB | 137 MB | 18 MB | **~225 MB** |
|
||||
| 2 Worker (Default) | 70 MB | 137 MB | 18 MB | **~360 MB** |
|
||||
| 4 Worker | 70 MB | ~135 MB | 18 MB | **~630 MB** |
|
||||
|
||||
Faustformel: **~90 MB + Anzahl Worker × ~140 MB** im Leerlauf. Dazu kommt Spielraum für laufende
|
||||
Berechnungen (siehe [Dimensionierung](#dimensionierung)).
|
||||
|
||||
## Woher der Speicher kommt
|
||||
|
||||
Das Rechnen selbst braucht kaum Speicher. Den Großteil belegen die **geladenen Bibliotheken**,
|
||||
und zwar in jedem Prozess, der sie importiert. Kosten pro Import-Schritt (kumulativ, frischer Prozess):
|
||||
|
||||
| Schritt | RSS gesamt | Zuwachs | Wofür |
|
||||
|---|---|---|---|
|
||||
| Python-Interpreter | 17 MB | | Basis |
|
||||
| + NumPy | 28 MB | +11 | Numerik, Basis für SciPy/Pint |
|
||||
| + SymPy (inkl. mpmath) | 62 MB | +35 | symbolische Mathematik, Parser |
|
||||
| + SciPy (`scipy.stats`) | 123 MB | **+60** | Statistik, Verteilungen, Tests; größter Einzelposten |
|
||||
| + Pint | 126 MB | +4 | Einheiten |
|
||||
| + MCP SDK, Pydantic | 161 MB | +35 | Protokoll, Schemas |
|
||||
| + uvicorn, Starlette, Pebble | 162 MB | +0 | HTTP-Server, Pool (von MCP bereits geladen) |
|
||||
|
||||
Dazu kommen zur Laufzeit ~10 MB für das Pint-Einheitenregister und die SymPy-Caches.
|
||||
|
||||
## Prozessmodell
|
||||
|
||||
```
|
||||
Server-Prozess MCP-Protokoll, HTTP, Validierung → braucht: MCP SDK, Pydantic, uvicorn
|
||||
├─ Worker 1..N die eigentlichen Berechnungen → braucht: NumPy, SymPy, SciPy, Pint
|
||||
└─ Resource-Tracker Hilfsprozess von multiprocessing → ~18 MB, fix
|
||||
```
|
||||
|
||||
Die Worker sind nötig, damit Berechnungen einen harten Timeout und ein Speicherlimit bekommen
|
||||
(siehe README, Abschnitt Sicherheit). Jeder Worker ist ein vollständiger Python-Prozess mit eigenem Speicher.
|
||||
|
||||
## Umgesetzte Optimierungen
|
||||
|
||||
Vor der Optimierung luden **alle** Prozesse **alle** Bibliotheken (Server 167 MB, je Worker 162 MB,
|
||||
zusammen ~510 MB bei 2 Workern). Zwei Ursachen, zwei Maßnahmen:
|
||||
|
||||
### 1. Server-Prozess ohne Mathe-Bibliotheken (−97 MB)
|
||||
|
||||
**Ursache:** Der Server übergab den Workern Funktionsreferenzen (`algebra.evaluate`). Pickle
|
||||
braucht dafür das importierte Modul, also lud der Server SymPy und SciPy, ohne je damit zu rechnen.
|
||||
Zusätzlich kamen die Enum-Typen der Tool-Schemas aus den Rechenmodulen.
|
||||
|
||||
**Maßnahme:**
|
||||
- Aufträge gehen **per Name** an die Worker (`executor.run("algebra.evaluate", …)`). Der Worker löst den
|
||||
Namen selbst auf. Erlaubt sind nur öffentliche Funktionen der Module in `executor.TASK_MODULES`.
|
||||
- Alle Typen, Enums und Hilfetexte, die der Server braucht, liegen in `ops/spec.py`. Fehlertyp und Limits liegen in
|
||||
`ops/context.py`. Beide Module importieren keine Mathe-Bibliothek.
|
||||
|
||||
**Ergebnis:** Server 167 → 70 MB. Der Server-Prozess startet dadurch auch schneller (0,3 statt 0,8 s bis zum HTTP-Listen).
|
||||
|
||||
### 2. Worker ohne MCP/HTTP-Stack (−25 MB je Worker)
|
||||
|
||||
**Ursache:** Worker werden mit `spawn` gestartet. Dabei importiert Python in jedem Worker das
|
||||
Hauptmodul des Elternprozesses neu. Das war `mcp_calculator.__main__`, und das importierte auf
|
||||
oberster Ebene Server, MCP SDK und uvicorn.
|
||||
|
||||
**Maßnahme:** `__main__.py` importiert Server, HTTP-App und anyio erst innerhalb von `main()`/`_serve()`.
|
||||
|
||||
**Ergebnis:** Worker 162 → 137 MB. Bei 2 Workern ~50 MB, bei 4 Workern ~100 MB weniger.
|
||||
|
||||
### Absicherung
|
||||
|
||||
`tests/test_footprint.py` prüft das automatisch:
|
||||
- Server-, HTTP- und Executor-Module importieren weder SymPy, NumPy, SciPy, Pint noch mpmath.
|
||||
- Ein Worker, gestartet wie über das Konsolenskript, hat kein `mcp`, `uvicorn` und `starlette` geladen,
|
||||
dafür SymPy/SciPy vorgeladen, und läuft mit `OPENBLAS_NUM_THREADS=1`.
|
||||
- Das Speicherlimit wird relativ zur geladenen Größe berechnet, und ein Fehler beim Setzen legt den Worker nicht lahm.
|
||||
- Über den Task-Namen lassen sich nur öffentliche Funktionen der Rechenmodule aufrufen.
|
||||
|
||||
Wer später einen Import in `server.py`, `http_app.py`, `executor.py` oder `__main__.py` ergänzt, bekommt
|
||||
einen roten Test, falls der Import eine Mathe-Bibliothek in den Server oder den Server-Stack in die
|
||||
Worker zieht.
|
||||
|
||||
### 3. Vorgewärmte Worker
|
||||
|
||||
Pebble startet Worker erst beim ersten Auftrag. Der erste Tool-Aufruf hätte dann ~0,5 s auf das Laden
|
||||
von SymPy/SciPy gewartet. Der Pool bekommt deshalb beim Start einen leeren Auftrag, und `/readyz` meldet
|
||||
erst danach „bereit“ (~1,2 s nach Start). Der Pod bekommt also keinen Traffic, bevor er rechnen kann,
|
||||
und der erste echte Aufruf ist sofort schnell. Speicher kostet das nicht zusätzlich, die Worker wären
|
||||
ohnehin gestartet.
|
||||
|
||||
## Bewusst nicht umgesetzt
|
||||
|
||||
| Idee | Warum nicht |
|
||||
|---|---|
|
||||
| SciPy erst beim ersten Statistik-Aufruf laden | Worker werden wiederverwendet. Nach kurzer Zeit hat jeder Worker SciPy doch geladen, die Ersparnis wäre also nur vorübergehend. Außerdem würde der erste Statistik-Aufruf pro Worker ~0,5 s langsamer. |
|
||||
| `fork` statt `spawn` (Copy-on-Write) | Worker könnten die vom Parent geladenen Bibliotheken teilen. Forken eines Prozesses mit laufenden Threads (asyncio, uvicorn) ist aber unsicher und ab Python 3.14 nicht mehr Default. Copy-on-Write zerfällt in Python zudem schnell, weil der Referenzzähler Speicherseiten beschreibt. |
|
||||
| `forkserver` mit vorgeladenen Modulen | Theoretisch die Vorteile von `fork` ohne die Thread-Probleme. Mehr Komplexität, plattformabhängig (nicht unter macOS-Default), Nutzen wegen Copy-on-Write-Zerfall unsicher. Erst angehen, wenn viele Worker pro Pod nötig sind. |
|
||||
| Worker ohne Pool (Berechnung im Server-Thread) | Spart die Worker komplett, aber dann gibt es keinen harten Timeout und keine Isolation mehr. `9^9^9^9` würde den Server blockieren. Nur für Entwicklung (`MCP_CALC_EXECUTOR=inline`). |
|
||||
|
||||
## Dimensionierung
|
||||
|
||||
**Worker-Anzahl.** Ein Worker rechnet genau eine Anfrage gleichzeitig. `MCP_CALC_WORKERS` legt
|
||||
also die Parallelität fest. Weitere Anfragen warten in der Queue. Faustregel: Worker ≈ CPU-Limit
|
||||
des Pods. Mehr Worker als CPUs bringt bei CPU-lastigen Berechnungen nichts.
|
||||
|
||||
**Speicher.** Im Leerlauf gilt ~90 MB + Worker × ~140 MB. Unter Linux darf eine laufende
|
||||
Berechnung pro Worker zusätzlich höchstens `MCP_CALC_MEMORY_LIMIT_MB` belegen (Default 512 MB).
|
||||
Darüber wird sie mit `MemoryError` abgebrochen, der Worker bleibt stabil.
|
||||
|
||||
Technisch ist das ein `RLIMIT_AS` auf den **virtuellen** Adressraum. Er wird erst *nach* dem Laden
|
||||
der Bibliotheken gesetzt, als „aktueller Stand + Limit“. Hintergrund: NumPy/SciPy reservieren deutlich mehr
|
||||
virtuellen Speicher, als sie belegen (RSS). Ein absolutes Limit vor den Imports könnte schon den
|
||||
Import scheitern lassen. Weil virtueller Zuwachs ≥ echter Zuwachs ist, ist die Formel konservativ.
|
||||
Zusätzlich laufen BLAS/OpenMP pro Worker einthreadig (`OPENBLAS_NUM_THREADS=1` usw., sofern nicht
|
||||
anders gesetzt). Das verhindert Thread-Puffer pro CPU-Kern und die Überbuchung des CPU-Limits.
|
||||
|
||||
Empfehlung für das Pod-Limit:
|
||||
|
||||
```
|
||||
limit ≥ 90 MB + WORKERS × (140 MB + MEMORY_LIMIT_MB)
|
||||
```
|
||||
|
||||
| Profil | `WORKERS` | `MEMORY_LIMIT_MB` | Pod-Limit | Pod-Request |
|
||||
|---|---|---|---|---|
|
||||
| klein (Einzelteam) | 1 | 512 | ~768 Mi | ~256 Mi |
|
||||
| **Standard (Defaults)** | 2 | 512 | ~1.4 Gi | ~400 Mi |
|
||||
| großzügig | 2 | 1024 | ~2.4 Gi | ~400 Mi |
|
||||
| parallel | 4 | 512 | ~2.7 Gi | ~700 Mi |
|
||||
|
||||
Request ≈ Leerlaufbedarf, Limit ≈ Worst Case (alle Worker gleichzeitig am Speicherlimit). Wird das
|
||||
Pod-Limit kleiner gewählt, kann ein einzelner Ausreißer den ganzen Pod per OOM-Kill beenden, statt
|
||||
nur seine eigene Berechnung.
|
||||
|
||||
> **Ungetestet unter Linux:** Das Speicherlimit und die Thread-Einstellungen greifen nur unter Linux
|
||||
> und konnten hier (macOS) nicht im Betrieb geprüft werden, nur die Berechnung des Limits per Unit-Test.
|
||||
> Bitte im Pod einmal prüfen: `python scripts/measure_memory.py server` sowie eine bewusst
|
||||
> speicherhungrige Anfrage (z. B. `evaluate` mit `factorial(10^7)`, die mit einer Memory- oder
|
||||
> Timeout-Meldung enden sollte, ohne dass der Pod neu startet).
|
||||
|
||||
**Alterung.** SymPy baut interne Caches auf. Deshalb wird jeder Worker nach
|
||||
`MCP_CALC_MAX_TASKS_PER_WORKER` Aufgaben (Default 200) durch einen frischen ersetzt. Der Neustart
|
||||
kostet ~0,5 s in einem Worker, während die anderen weiterrechnen.
|
||||
236
docs/tools.md
Normal file
236
docs/tools.md
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
# Tool-Referenz
|
||||
|
||||
13 Tools, nach Fachgebiet gruppiert. Die Tools mit vielen Operationen (`calculus`,
|
||||
`matrix`, `number_theory` …) nutzen ein `operation`-Enum. So bleibt die Tool-Liste
|
||||
für das LLM überschaubar, und die Schemas bleiben trotzdem präzise.
|
||||
|
||||
Alle Tools sind `readOnly`, `idempotent` und `openWorld=false` annotiert. Clients können
|
||||
sie also ohne Rückfrage freigeben (z. B. Cline `autoApprove`).
|
||||
|
||||
## Gemeinsame Konventionen
|
||||
|
||||
**Ausdrucks-Syntax** (für alle String-Ausdrücke):
|
||||
|
||||
| Schreibweise | Bedeutung |
|
||||
|---|---|
|
||||
| `x^2` oder `x**2` | Potenz |
|
||||
| `2x`, `3(x+1)` | implizite Multiplikation |
|
||||
| `5!` | Fakultät |
|
||||
| `a % b`, `Mod(a, b)` | Modulo |
|
||||
| `pi`, `e`, `I`, `oo` | π, Euler'sche Zahl, imaginäre Einheit, ∞ |
|
||||
| `sin(30*deg)` | Grad (deg = π/180) |
|
||||
| `ln(x)`, `log(x)`, `log(x, 2)`, `log10(x)` | Logarithmen |
|
||||
| `sqrt`, `cbrt`, `root(x, n)`, `abs`, `floor`, `ceiling` | Wurzeln, Betrag, Runden |
|
||||
| `sin cos tan …`, `asin`/`arcsin` …, `sinh …` | Trigonometrie |
|
||||
| `factorial`, `binomial`, `gamma`, `erf`, `min`, `max`, `gcd`, `lcm` | Sonderfunktionen |
|
||||
| `x^2 = 4` | Gleichung (genau ein `=`) |
|
||||
| `x^2 < 4`, `<=`, `>`, `>=`, `!=` | Ungleichungen |
|
||||
|
||||
- Dezimalpunkt, kein Komma. Dezimalzahlen werden **exakt** als Brüche behandelt (`0.1 + 0.2 = 3/10`).
|
||||
- Unicode wird normalisiert: `π × ÷ − ² ³ √ ∞ ≤ ≥ ≠`.
|
||||
- Mehrbuchstabige Namen sind **ein** Symbol (`rate*time`, nicht `r*a*t*e`).
|
||||
|
||||
**Ergebnisse:**
|
||||
- `result` bzw. `exact`: symbolisches Ergebnis in derselben Syntax, direkt als Eingabe für das nächste Tool verwendbar
|
||||
- `latex`: für die Darstellung
|
||||
- `numeric`: Dezimalwert als String, damit keine Präzision verloren geht
|
||||
- Sehr lange Ergebnisse werden gekürzt (`MCP_CALC_MAX_OUTPUT_LENGTH`), große Ganzzahlen enthalten zusätzlich `digits`.
|
||||
|
||||
**Fehler** kommen als Tool-Fehler (`isError: true`) mit einer verständlichen Meldung zurück,
|
||||
z. B. `matrix is singular (determinant 0) and has no inverse; try 'pseudo_inverse'`.
|
||||
Das LLM kann darauf reagieren und den Aufruf korrigieren.
|
||||
|
||||
---
|
||||
|
||||
## evaluate
|
||||
|
||||
Wert exakt und dezimal berechnen, mit beliebiger Präzision.
|
||||
|
||||
| Parameter | Typ | Default | |
|
||||
|---|---|---|---|
|
||||
| `expression` | string | – | Ausdruck |
|
||||
| `precision` | int | 15 | signifikante Stellen (1–1000) |
|
||||
| `variables` | object | – | Werte einsetzen, z. B. `{"x": 2, "r": "1/3"}` |
|
||||
|
||||
```json
|
||||
{"expression": "1000 * (1 + 0.05)^10", "precision": 12}
|
||||
→ {"exact": "16679880978201/10240000000", "numeric": "1628.89462678", ...}
|
||||
```
|
||||
|
||||
## transform
|
||||
|
||||
Symbolisch umformen.
|
||||
|
||||
| Parameter | Typ | Default | |
|
||||
|---|---|---|---|
|
||||
| `expression` | string | – | |
|
||||
| `operation` | enum | `simplify` | `simplify`, `expand`, `factor`, `collect`, `cancel`, `apart`, `together`, `trigsimp`, `expand_trig`, `powsimp`, `expand_log`, `logcombine`, `radsimp`, `rationalize_denominator`, `nsimplify`, `substitute` |
|
||||
| `variable` | string | – | Hauptvariable für `collect`, `apart`, `factor` |
|
||||
| `substitutions` | object | – | für `substitute`: `{"x": "2t"}` |
|
||||
| `assume` | enum | `none` | `none`, `real`, `positive`, `integer` |
|
||||
|
||||
`equivalent_to_input: true` bestätigt, dass die Umformung mathematisch identisch ist.
|
||||
`nsimplify` findet geschlossene Formen zu Dezimalzahlen (`1.41421356…` → `sqrt(2)`).
|
||||
|
||||
## solve
|
||||
|
||||
Gleichungen, Gleichungssysteme, Ungleichungen.
|
||||
|
||||
| Parameter | Typ | Default | |
|
||||
|---|---|---|---|
|
||||
| `equations` | string \| string[] | – | eine Gleichung oder ein System; bloßer Ausdruck = `… = 0` |
|
||||
| `variables` | string[] | alle freien | wonach aufgelöst wird |
|
||||
| `domain` | enum | `complex` | `complex`, `real`, `positive`, `integer` |
|
||||
| `numeric_only` | bool | false | numerisch lösen (Newton / Polynomwurzeln) |
|
||||
| `initial_guess` | number[] | 1.0 je Variable | Startwerte für `numeric_only` |
|
||||
| `precision` | int | 15 | |
|
||||
|
||||
- Endliche Lösungen: `solutions: [{"x": "2"}, {"x": "3"}]` plus `numeric`
|
||||
- Periodische Lösungen (z. B. `sin(x) = 1/2`): `solution_set` als Menge plus `principal_solutions`
|
||||
- Unterbestimmte Systeme: parametrisierte Lösung plus Hinweis in `note`
|
||||
- Ungleichungen: `solution_set: "Interval.open(-2, 2)"`
|
||||
- Transzendente Gleichungen ohne geschlossene Form: `numeric_only: true`
|
||||
|
||||
## calculus
|
||||
|
||||
| `operation` | benötigt | Ergebnis |
|
||||
|---|---|---|
|
||||
| `derivative` | `variable`, `order` | Ableitung; mehrere Variablen `"x, y"` = gemischte partielle Ableitung |
|
||||
| `integral` | optional `lower`+`upper` | unbestimmt (+C-Hinweis) oder bestimmt; fällt numerisch zurück, wenn keine geschlossene Form existiert |
|
||||
| `limit` | `point`, `direction` (`+-`, `+`, `-`) | Grenzwert |
|
||||
| `series` | `point` (Default 0), `terms` | Taylorreihe mit O-Term, zusätzlich `polynomial` |
|
||||
| `sum` / `product` | `lower`, `upper` (auch `oo`) | geschlossene Form oder numerisch; `converges` bei unendlichen Summen |
|
||||
| `gradient`, `hessian` | `variable: "x, y"` | Vektor / Matrix |
|
||||
| `jacobian` | `expression: "f1, f2"`, `variable: "x, y"` | Matrix |
|
||||
|
||||
Weitere Parameter: `assume`, `precision`.
|
||||
|
||||
## analyze_function
|
||||
|
||||
Komplette Kurvendiskussion einer reellen Funktion f(x) in einem Aufruf:
|
||||
Definitionsbereich, f′/f″/f‴, Nullstellen, y-Achsenabschnitt, Extrema mit Art, Wendepunkte,
|
||||
Symmetrie, Grenzverhalten ±∞, senkrechte Asymptoten, Monotonie- und Krümmungsintervalle.
|
||||
Teilschritte, die sich nicht bestimmen lassen, liefern `"not determinable"`, statt den ganzen Aufruf scheitern zu lassen.
|
||||
|
||||
| Parameter | Default |
|
||||
|---|---|
|
||||
| `expression` | – |
|
||||
| `variable` | `x` |
|
||||
| `precision` | 10 |
|
||||
|
||||
## solve_ode
|
||||
|
||||
Gewöhnliche Differentialgleichungen, symbolisch gelöst.
|
||||
|
||||
| Parameter | Default | |
|
||||
|---|---|---|
|
||||
| `equation` | – | Strich-Notation: `"y'' + 2y' + y = sin(x)"` |
|
||||
| `function` | `y` | Name der gesuchten Funktion |
|
||||
| `variable` | `x` | unabhängige Variable |
|
||||
| `initial_conditions` | – | `{"y(0)": 1, "y'(0)": 0}` |
|
||||
|
||||
Ohne Anfangsbedingungen kommt die allgemeine Lösung mit `C1, C2, …`. Zusätzlich wird das Lösungsverfahren (`method`) zurückgegeben.
|
||||
|
||||
## matrix
|
||||
|
||||
| Parameter | Default | |
|
||||
|---|---|---|
|
||||
| `operation` | – | siehe unten |
|
||||
| `matrix` | – | Liste von Zeilen `[[1, 2], [3, 4]]`; Einträge dürfen Ausdrücke sein (`"1/3"`, `"a"`); flache Liste = Spaltenvektor |
|
||||
| `other` | – | zweiter Operand bzw. rechte Seite `b` bei `solve` |
|
||||
| `exponent` | – | für `power` |
|
||||
| `numeric_mode` | false | Gleitkomma (NumPy) statt exakt |
|
||||
|
||||
Operationen (exakt): `determinant`, `inverse`, `transpose`, `rank`, `trace`, `rref`, `eigenvalues`,
|
||||
`eigenvectors`, `characteristic_polynomial`, `diagonalize`, `nullspace`, `columnspace`, `multiply`, `add`,
|
||||
`subtract`, `power`, `solve`, `dot`, `cross`, `norm`, `pseudo_inverse`.
|
||||
Nur numerisch: `svd`, `condition_number`. Bei `numeric_mode` löst `solve` nicht-quadratische Systeme per Least Squares.
|
||||
|
||||
## statistics
|
||||
|
||||
Deskriptive Statistik: count, sum, mean, median, mode, min/max, range, variance, std_dev, Quartile, IQR,
|
||||
Standardfehler, Variationskoeffizient, Schiefe, Exzess, geometrisches/harmonisches Mittel.
|
||||
|
||||
| Parameter | Default | |
|
||||
|---|---|---|
|
||||
| `data` | – | Zahlen |
|
||||
| `sample` | true | Stichprobe (n−1) oder Grundgesamtheit (n) |
|
||||
| `percentiles` | – | z. B. `[5, 95]` |
|
||||
| `y` | – | gepaarte zweite Variable: Kovarianz, Pearson, Spearman |
|
||||
|
||||
## regression
|
||||
|
||||
| Parameter | Default | |
|
||||
|---|---|---|
|
||||
| `x`, `y` | – | Datenpunkte |
|
||||
| `model` | `linear` | `linear`, `polynomial`, `exponential`, `logarithmic`, `power` |
|
||||
| `degree` | 2 | für `polynomial` |
|
||||
| `predict` | – | x-Werte für Vorhersagen |
|
||||
|
||||
Liefert Koeffizienten, Gleichung, R², RMSE; beim linearen Modell zusätzlich r, p-Wert und Standardfehler.
|
||||
|
||||
## probability
|
||||
|
||||
| Parameter | |
|
||||
|---|---|
|
||||
| `distribution` | `normal(mean, std)`, `t(df)`, `chi2(df)`, `f(dfn, dfd)`, `exponential(rate)`, `uniform(low, high)`, `lognormal(mu, sigma)`, `gamma(shape, scale)`, `beta(a, b)`, `binomial(n, p)`, `poisson(lambda)`, `geometric(p)`, `hypergeometric(population, successes, draws)`, `negative_binomial(successes, p)` |
|
||||
| `operation` | `pdf` (Dichte bzw. P(X=x)), `cdf` P(X≤x), `sf` P(X>x), `between` P(lower<X≤upper), `quantile`, `interval`, `summary` |
|
||||
| `parameters` | z. B. `{"n": 10, "p": 0.3}` |
|
||||
| `x`, `lower`, `upper`, `q`, `confidence` | je nach Operation |
|
||||
|
||||
Das Feld `meaning` sagt genau, was berechnet wurde. Bei diskreten Verteilungen z. B.
|
||||
`"P(X > 7) = P(X >= 8)"`. Das verhindert die typischen Off-by-one-Fehler von LLMs.
|
||||
|
||||
## hypothesis_test
|
||||
|
||||
| `test` | Eingaben |
|
||||
|---|---|
|
||||
| `one_sample_t` | `sample`, `mu` |
|
||||
| `two_sample_t` | `sample`, `sample2`, `equal_variance` (Default Welch) |
|
||||
| `paired_t` | `sample`, `sample2` |
|
||||
| `one_way_anova` | `groups` |
|
||||
| `mann_whitney` | `sample`, `sample2` |
|
||||
| `wilcoxon` | `sample` + `sample2` oder `mu` |
|
||||
| `chi2_goodness_of_fit` | `sample` (beobachtete Häufigkeiten), `expected` (Häufigkeiten oder Wahrscheinlichkeiten) |
|
||||
| `chi2_independence` | `table` (Kontingenztafel), zusätzlich Cramér's V |
|
||||
| `shapiro_wilk` | `sample` |
|
||||
| `binomial` | `sample: [Erfolge, Versuche]`, `p` |
|
||||
| `pearson_correlation` | `sample`, `sample2` |
|
||||
|
||||
Gemeinsam: `alternative` (`two-sided`, `less`, `greater`) und `alpha`. Ergebnis: Teststatistik,
|
||||
p-Wert, gegebenenfalls Freiheitsgrade und Konfidenzintervall, `reject_null` und eine Schlussfolgerung im Klartext.
|
||||
|
||||
## number_theory
|
||||
|
||||
| `operation` | `values` |
|
||||
|---|---|
|
||||
| `gcd`, `lcm` | `[a, b, …]` |
|
||||
| `factorize`, `is_prime`, `next_prime`, `previous_prime`, `divisors`, `totient`, `factorial`, `fibonacci` | `[n]` |
|
||||
| `nth_prime` | `[k]` |
|
||||
| `primes_in_range` | `[low, high]` |
|
||||
| `mod` | `[a, m]` |
|
||||
| `mod_pow` | `[base, exp, mod]` |
|
||||
| `mod_inverse` | `[a, m]` |
|
||||
| `crt` | Reste in `values`, Moduli in `moduli` |
|
||||
| `binomial`, `permutations` | `[n, k]` |
|
||||
| `continued_fraction` | `[x]` (z. B. `"415/93"`, `"sqrt(2)"`) |
|
||||
| `base_convert` | `["ff"]` plus `from_base`, `to_base` (2–36) |
|
||||
|
||||
Ganzzahlen über 2^53 bitte als String übergeben. JSON-Zahlen verlieren sonst in manchen
|
||||
Clients Stellen. Große Ergebnisse kommen ebenfalls als String zurück.
|
||||
|
||||
## convert_units
|
||||
|
||||
| Parameter | Default | |
|
||||
|---|---|---|
|
||||
| `quantity` | – | `"100 km/h"`, `"25 degC"`, `"5 km/h * 2 h"`, `"1.5 kg * 9.81 m/s^2"` |
|
||||
| `to_unit` | – | Zieleinheit; ohne Angabe SI-Basiseinheiten plus `compact` |
|
||||
| `precision` | 12 | |
|
||||
|
||||
Die Dimensionen werden geprüft (`kg` → `m` ergibt einen Fehler). Unterstützt werden SI- und imperiale Einheiten, Präfixe,
|
||||
Temperaturen, Datengrößen (`B`, `KiB`, `MB`), Energie, Druck usw. (Pint-Einheitenregister).
|
||||
|
||||
## Ressource `calculator://syntax`
|
||||
|
||||
Kurzreferenz der Syntax und Verteilungen als Text. Dieselbe Information steckt auch in den
|
||||
Server-`instructions`, die jeder Client beim Verbindungsaufbau erhält.
|
||||
59
examples/client_example.py
Normal file
59
examples/client_example.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
"""Minimal MCP client for your own tools and agents.
|
||||
|
||||
Usage:
|
||||
python examples/client_example.py # starts the server via stdio
|
||||
python examples/client_example.py http://localhost:8080/mcp [TOKEN]
|
||||
|
||||
Wiring into your own LLM loop:
|
||||
1. ``list_tools()`` -> pass name / description / input_schema of each tool to the
|
||||
model as tool definitions (every major LLM API uses this JSON-schema shape).
|
||||
2. When the model requests a tool call -> ``call_tool(name, arguments)``.
|
||||
3. Return ``result.content[0].text`` (or ``structured_content``) to the model;
|
||||
mark it as an error if ``result.is_error`` is true.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
|
||||
import httpx2
|
||||
from mcp import Client, StdioServerParameters
|
||||
from mcp.client.streamable_http import streamable_http_client
|
||||
|
||||
|
||||
async def demo(client: Client) -> None:
|
||||
print("Server instructions:\n", client.instructions, "\n", sep="")
|
||||
|
||||
tools = (await client.list_tools()).tools
|
||||
print("Tools:", ", ".join(t.name for t in tools), "\n")
|
||||
|
||||
calls = [
|
||||
("evaluate", {"expression": "sqrt(2)", "precision": 40}),
|
||||
("solve", {"equations": ["x + y = 10", "x - y = 2"]}),
|
||||
("calculus", {"operation": "integral", "expression": "x^2 * e^x"}),
|
||||
("probability", {"distribution": "binomial", "operation": "cdf", "parameters": {"n": 20, "p": 0.3}, "x": 5}),
|
||||
("convert_units", {"quantity": "60 mph", "to_unit": "km/h"}),
|
||||
("matrix", {"operation": "inverse", "matrix": [[1, 2], [2, 4]]}), # error on purpose
|
||||
]
|
||||
for name, arguments in calls:
|
||||
result = await client.call_tool(name, arguments)
|
||||
status = "ERROR" if result.is_error else "ok"
|
||||
print(f"[{status}] {name}({arguments})\n{result.content[0].text}\n")
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
if len(sys.argv) > 1:
|
||||
url = sys.argv[1]
|
||||
headers = {"Authorization": f"Bearer {sys.argv[2]}"} if len(sys.argv) > 2 else {}
|
||||
async with httpx2.AsyncClient(headers=headers, timeout=60) as http:
|
||||
async with Client(streamable_http_client(url, http_client=http)) as client:
|
||||
await demo(client)
|
||||
else:
|
||||
params = StdioServerParameters(command=sys.executable, args=["-m", "mcp_calculator"])
|
||||
async with Client(params) as client:
|
||||
await demo(client)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
44
pyproject.toml
Normal file
44
pyproject.toml
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
[build-system]
|
||||
requires = ["hatchling>=1.25"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "mcp-calculator"
|
||||
version = "1.2.0"
|
||||
description = "MCP server that gives LLMs exact, deterministic math: symbolic algebra, calculus, linear algebra, statistics, units."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
license = { text = "Proprietary" }
|
||||
dependencies = [
|
||||
"mcp>=2.2,<3",
|
||||
"sympy>=1.13",
|
||||
"mpmath>=1.3",
|
||||
"numpy>=2.0",
|
||||
"scipy>=1.13",
|
||||
"pint>=0.24",
|
||||
"pebble>=5.1",
|
||||
"prometheus-client>=0.20",
|
||||
"starlette>=0.40",
|
||||
"uvicorn>=0.30",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"ruff>=0.6",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
mcp-calculator = "mcp_calculator.__main__:main"
|
||||
mcp-calculator-admin = "mcp_calculator.admin_cli:main"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/mcp_calculator"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 160
|
||||
target-version = "py312"
|
||||
src = ["src", "tests"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "W", "I", "B", "UP"]
|
||||
ignore = ["E501", "B008", "UP007"]
|
||||
596
requirements.lock
Normal file
596
requirements.lock
Normal file
|
|
@ -0,0 +1,596 @@
|
|||
annotated-types==0.8.0 \
|
||||
--hash=sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7 \
|
||||
--hash=sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0
|
||||
# via pydantic
|
||||
anyio==4.15.1 \
|
||||
--hash=sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101 \
|
||||
--hash=sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94
|
||||
# via
|
||||
# httpx2
|
||||
# mcp
|
||||
# sse-starlette
|
||||
# starlette
|
||||
attrs==26.1.0 \
|
||||
--hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 \
|
||||
--hash=sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32
|
||||
# via
|
||||
# jsonschema
|
||||
# referencing
|
||||
cffi==2.1.1 ; platform_python_implementation != 'PyPy' \
|
||||
--hash=sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e \
|
||||
--hash=sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66 \
|
||||
--hash=sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2 \
|
||||
--hash=sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971 \
|
||||
--hash=sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c \
|
||||
--hash=sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d \
|
||||
--hash=sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517 \
|
||||
--hash=sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735 \
|
||||
--hash=sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80 \
|
||||
--hash=sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f \
|
||||
--hash=sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1 \
|
||||
--hash=sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29 \
|
||||
--hash=sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8 \
|
||||
--hash=sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c \
|
||||
--hash=sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e \
|
||||
--hash=sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48 \
|
||||
--hash=sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac \
|
||||
--hash=sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6 \
|
||||
--hash=sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688 \
|
||||
--hash=sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062 \
|
||||
--hash=sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779 \
|
||||
--hash=sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94 \
|
||||
--hash=sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50 \
|
||||
--hash=sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab \
|
||||
--hash=sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac \
|
||||
--hash=sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6 \
|
||||
--hash=sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676 \
|
||||
--hash=sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1 \
|
||||
--hash=sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13 \
|
||||
--hash=sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e \
|
||||
--hash=sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e \
|
||||
--hash=sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973 \
|
||||
--hash=sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527 \
|
||||
--hash=sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72 \
|
||||
--hash=sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890 \
|
||||
--hash=sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c \
|
||||
--hash=sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94 \
|
||||
--hash=sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3 \
|
||||
--hash=sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80 \
|
||||
--hash=sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5 \
|
||||
--hash=sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c \
|
||||
--hash=sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a \
|
||||
--hash=sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4 \
|
||||
--hash=sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e \
|
||||
--hash=sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6 \
|
||||
--hash=sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b \
|
||||
--hash=sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03 \
|
||||
--hash=sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231 \
|
||||
--hash=sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2 \
|
||||
--hash=sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3 \
|
||||
--hash=sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836 \
|
||||
--hash=sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5 \
|
||||
--hash=sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399 \
|
||||
--hash=sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96 \
|
||||
--hash=sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e \
|
||||
--hash=sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf \
|
||||
--hash=sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc \
|
||||
--hash=sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0 \
|
||||
--hash=sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7 \
|
||||
--hash=sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692 \
|
||||
--hash=sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54 \
|
||||
--hash=sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3 \
|
||||
--hash=sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b \
|
||||
--hash=sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be \
|
||||
--hash=sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d \
|
||||
--hash=sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358 \
|
||||
--hash=sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7 \
|
||||
--hash=sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc \
|
||||
--hash=sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960 \
|
||||
--hash=sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125 \
|
||||
--hash=sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb \
|
||||
--hash=sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a \
|
||||
--hash=sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf \
|
||||
--hash=sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4 \
|
||||
--hash=sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264
|
||||
# via cryptography
|
||||
click==8.5.0 \
|
||||
--hash=sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360 \
|
||||
--hash=sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34
|
||||
# via uvicorn
|
||||
cryptography==50.0.1 \
|
||||
--hash=sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71 \
|
||||
--hash=sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23 \
|
||||
--hash=sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6 \
|
||||
--hash=sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e \
|
||||
--hash=sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361 \
|
||||
--hash=sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054 \
|
||||
--hash=sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f \
|
||||
--hash=sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6 \
|
||||
--hash=sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49 \
|
||||
--hash=sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5 \
|
||||
--hash=sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149 \
|
||||
--hash=sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88 \
|
||||
--hash=sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad \
|
||||
--hash=sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a \
|
||||
--hash=sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f \
|
||||
--hash=sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2 \
|
||||
--hash=sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20 \
|
||||
--hash=sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45 \
|
||||
--hash=sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527 \
|
||||
--hash=sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3 \
|
||||
--hash=sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367 \
|
||||
--hash=sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0 \
|
||||
--hash=sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239 \
|
||||
--hash=sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b \
|
||||
--hash=sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a \
|
||||
--hash=sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9 \
|
||||
--hash=sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5 \
|
||||
--hash=sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc \
|
||||
--hash=sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648 \
|
||||
--hash=sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986 \
|
||||
--hash=sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959 \
|
||||
--hash=sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0 \
|
||||
--hash=sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17 \
|
||||
--hash=sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733 \
|
||||
--hash=sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f \
|
||||
--hash=sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8 \
|
||||
--hash=sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf \
|
||||
--hash=sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80 \
|
||||
--hash=sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558 \
|
||||
--hash=sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef
|
||||
# via pyjwt
|
||||
flexcache==0.3 \
|
||||
--hash=sha256:18743bd5a0621bfe2cf8d519e4c3bfdf57a269c15d1ced3fb4b64e0ff4600656 \
|
||||
--hash=sha256:d43c9fea82336af6e0115e308d9d33a185390b8346a017564611f1466dcd2e32
|
||||
# via pint
|
||||
flexparser==0.4 \
|
||||
--hash=sha256:266d98905595be2ccc5da964fe0a2c3526fbbffdc45b65b3146d75db992ef6b2 \
|
||||
--hash=sha256:3738b456192dcb3e15620f324c447721023c0293f6af9955b481e91d00179846
|
||||
# via pint
|
||||
h11==0.16.0 \
|
||||
--hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \
|
||||
--hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86
|
||||
# via
|
||||
# httpcore2
|
||||
# uvicorn
|
||||
httpcore2==2.12.0 ; sys_platform != 'emscripten' \
|
||||
--hash=sha256:7e04258ce01013d7d615e5b910a3b27fac937d7a95038227e79652b4ba3b4ceb \
|
||||
--hash=sha256:9293522bba0aa7c4c8e9e3f040c16575bd8868e155a77fa30c7a9085a5eae648
|
||||
# via httpx2
|
||||
httpx2==2.12.0 \
|
||||
--hash=sha256:7631fe9887a8a2275f4a2540e053aa670fcc50742864a9ae7c66e609fdcf12cf \
|
||||
--hash=sha256:cc8b6eecb8661c146b8f89a60e97456ee086e91a784ed31ac450c3a9e613dd36
|
||||
# via mcp
|
||||
httpx2-jsfetch==1.0 ; sys_platform == 'emscripten' \
|
||||
--hash=sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60 \
|
||||
--hash=sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32
|
||||
# via httpx2
|
||||
idna==3.19 \
|
||||
--hash=sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15 \
|
||||
--hash=sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4
|
||||
# via
|
||||
# anyio
|
||||
# httpx2
|
||||
jsonschema==4.26.0 \
|
||||
--hash=sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326 \
|
||||
--hash=sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce
|
||||
# via mcp
|
||||
jsonschema-specifications==2025.9.1 \
|
||||
--hash=sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe \
|
||||
--hash=sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d
|
||||
# via jsonschema
|
||||
mcp==2.2.0 \
|
||||
--hash=sha256:2dc37ecb1974becdcebdbf7561e7c15a07dbbf20ba21ba16c3593b3038b3afbd \
|
||||
--hash=sha256:bde982589473a060ae145e3406e9a5333fe538c97229ba841f5a7f92be004f81
|
||||
# via mcp-calculator
|
||||
mcp-types==2.2.0 \
|
||||
--hash=sha256:d3ed53703ddd10d9c6399f29d322bb66f3f67ab41348ac8556ba23e07fedefad \
|
||||
--hash=sha256:ea476b73ee86709ab5abc9452385ed36cc05907e582355622e294595c9a04f13
|
||||
# via mcp
|
||||
mpmath==1.3.0 \
|
||||
--hash=sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f \
|
||||
--hash=sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c
|
||||
# via
|
||||
# mcp-calculator
|
||||
# sympy
|
||||
numpy==2.5.3 \
|
||||
--hash=sha256:012e66aca395d795496446e52aeeb5866312a5d4d3f27da270e5a0b43f70dc5c \
|
||||
--hash=sha256:09d5a423c71ad5feb5625844ad58050e35df43871004b52ac9c0ad44a56775be \
|
||||
--hash=sha256:09ffa5d903faeaa5c4dd05009cf81c8bab9f2cb37c548b8d39b65b4cfa7c97f7 \
|
||||
--hash=sha256:0a59a421a32580a009e8a1751345bf829631b990dc1794b80514ab722b435def \
|
||||
--hash=sha256:116f96cadd935c6122e9228d676fe7ede19e741f5c8bb1c3cddbe0c51ccebea2 \
|
||||
--hash=sha256:1302b90c0e52281681b2975adfe8a860cb7b12216a27b4b0b4207c44bf7bccf0 \
|
||||
--hash=sha256:15aa985ac73a8db02db7663381aa109510449d3819d37206caed27b33a65a8a6 \
|
||||
--hash=sha256:1aad64d99730d013cfc6debafed22783b4fc5a7f4b8bc744d2d8cf7dcc880551 \
|
||||
--hash=sha256:1c80eabb4035ecf4ca9cd49cde8a9fdd69a729e63e6474887d1523ade7aa277f \
|
||||
--hash=sha256:1f3ed25271581281f2fccb1adcedfcde4c07362eec69189b50baf6f90e3ae159 \
|
||||
--hash=sha256:1fb6f8fb9ff0b3a69f52c66ce397b0246583e9f28616231b0e32ca49259a5fa6 \
|
||||
--hash=sha256:214045a5bf00113a146ab9ee9730c44501af6723cdf1f6830932f7b5ef2e7af0 \
|
||||
--hash=sha256:26e15e4aecd8617dfbaecb37d223e365d7b39411fba20454be2670a96aa74cb5 \
|
||||
--hash=sha256:2c25dfa72943e4336ddb6b0ee4277b47a0c85bede0807530ec68103bf58e2c10 \
|
||||
--hash=sha256:2d8240cb4c16fd831074aa2b2cf9fc54664d826341d61c372245b96a74a49a9a \
|
||||
--hash=sha256:350ba9783ce969cf9f7ce6e6a9a58e1a6e2a19ca025b7ee448c4db727706212a \
|
||||
--hash=sha256:4c8a6d2ebce6305fd82fbefca827775437147052a976ee7c94b36a0c1b52ac6c \
|
||||
--hash=sha256:4f8929ee6c96bfbd7b4ed2032e0c03af86fe1826740ab61ddabf9072d06e57ff \
|
||||
--hash=sha256:536f963710a4e63934d80ac0dc4f478804a83e9a84b6828018f25d09953ada33 \
|
||||
--hash=sha256:54a115e5a73b8fc44f0cebef486365a1894b5c9760685d4558b72b7c3eb846e0 \
|
||||
--hash=sha256:595d020938c84e320bcf40ad71089e108eac0d377cd018e14a8c094f39e98d85 \
|
||||
--hash=sha256:66a78fe4556c60aceda5916f9eacd638b18e9e681016ec302dcb4682d6d4d034 \
|
||||
--hash=sha256:6b05c171afb3aa07adbd20abc00aea86fe375beb0fdb9ef780ec5b7f63bab1c0 \
|
||||
--hash=sha256:6cef4bb1706dfec49243c05d921eefb4e190d41e2528b30d8035ea1f36b4c24a \
|
||||
--hash=sha256:6f24021b9f22bc6301c37b196974a92c1c18dccedb6fef3dd252e95f2d6adbe4 \
|
||||
--hash=sha256:71b39d9f935b6ec0f8753e3e2afb51e3efba6f2e05b68b32a40754d24bcd4a3c \
|
||||
--hash=sha256:71cad2b2a7451ab79d8f5e71b453485b6775963d5cf794179144a7463fe6e8ec \
|
||||
--hash=sha256:76c2c1e6bfa5c84adc6434dfbf013aa92096a7985221762c8f11fedfd20fff58 \
|
||||
--hash=sha256:8617bbfae4486cf99c9f899966699428d19da931d06ca94ad3da986c76e15997 \
|
||||
--hash=sha256:86bff898a431c0fb71f7610b75726e75a54d47b37edc9d537f48de63bb3c0b90 \
|
||||
--hash=sha256:8e4dd766076855b5ff7ea52fa5f07ce26286726e0f8bff446b7739d02e6ea204 \
|
||||
--hash=sha256:92f30e89b8ee0ecf363033576c422b2f58fed6a80bed0aa48dff6d14c654663e \
|
||||
--hash=sha256:93e1f5447e2b1e479d7bd74701e84746b86450cff1fc368b132d195e2b8f8211 \
|
||||
--hash=sha256:9a37475425b431b4d060f23b4f52cd2f3aef6bc7c654bd760adf0040eec9d435 \
|
||||
--hash=sha256:9deb49575e5b0b94ed72c8a64ec4d033381adc27e9060ae842971f697ba96104 \
|
||||
--hash=sha256:a5fa86b80fd24bcd1aff83ad23be44ea323de3f787be8f8b15d4a65621e25321 \
|
||||
--hash=sha256:a6391fafaba97500887132cd582abc6e19452b1ac775a47caa7b24490e152058 \
|
||||
--hash=sha256:a72f874bc9e10e4b8f80426fb49716d5141f64442a0c8418065093ec8017fbb0 \
|
||||
--hash=sha256:ac7bb1c52d445bd4f8f7f97fefe6abc3a084dc4d63df50d79b17fa2b78e89297 \
|
||||
--hash=sha256:adc1ada2662f8a5f960b8a10d9986897e7499ef07e06d4cfe7197f8cce923c07 \
|
||||
--hash=sha256:b00eefbcf0f292945c4b4dec2ae845389ef5bcdcd596e6e4328051db5b5ba694 \
|
||||
--hash=sha256:b0521d0f4aebb6e06189451025fa17a913287b13c03d5fe05c017333b654ea5b \
|
||||
--hash=sha256:b5d93cf48f687479941d12b69c873ad2cc76bbd487f0091c2200636497f34034 \
|
||||
--hash=sha256:b7e18c623bb5c95acb3b3328861272816ba199fb531921c5d6d0b675f1fde9e3 \
|
||||
--hash=sha256:bd4cb9ad3c7889b9b3fe0a9a9fb5d2ed26f9879bff2608d9f01aed147a20d231 \
|
||||
--hash=sha256:be5a8381859b6da607c84f4f7d6847725f1cf1853ef8a2c9e115b7d58bef47dc \
|
||||
--hash=sha256:befa1ae5bd6030b3f512b43ff3fa5290bbed6b84411a44244b14adf835f5b89d \
|
||||
--hash=sha256:bf63afbe037eb5d2fe87fbcc7778e61da53ebaf21d938a4515aa73b62532a5d4 \
|
||||
--hash=sha256:c00abe94c1a69d75d827dcf1c025b25c8a45d230b3bcd77a9020883a1b047653 \
|
||||
--hash=sha256:c2381f82999704f818e2c987a865050e285ec3621262c66d40f5a96c8f899f8e \
|
||||
--hash=sha256:c76d5dde9f445058f83d0c02af00557a4db91de9a9a57c0df87d1535001d654b \
|
||||
--hash=sha256:cb189f09db39283b26bfd061ec16189e14f71c6755207f72a0f7540867afe5b9 \
|
||||
--hash=sha256:ccb32e0525d29e8b0572eb84c9a57af0e7a4e615726927506f55063c62414034 \
|
||||
--hash=sha256:ccbc4665079665c3cf3bab4db9f6b095370cd6437d66be549b6c2a1fd19e1958 \
|
||||
--hash=sha256:d1c89973648c85069c5046ad460f7b8a00218b29a2e42359ac8cc63e9ab94832 \
|
||||
--hash=sha256:df2d5874ff183595a4ba404edd04f6bd9b5505c1d7708573f6a6c17489a67563 \
|
||||
--hash=sha256:e01c918ac3d48e18a927cf7b14a26a3e29ff2bdf2eacb976da0aecd6a43ed034 \
|
||||
--hash=sha256:e6ab667ba76450084eb64013762c438ea76d9d29cc676dcd6c2e9892ba37f841 \
|
||||
--hash=sha256:e931e4f499e0dc7ef29d269a8e5b35dd722e5d14be07df6240166ea7c6532fae \
|
||||
--hash=sha256:f54660b0eb6b0b9f36e7fe1cdfdff472028dd0d14acd9b9b65098efbad059469 \
|
||||
--hash=sha256:f59a878c33d6b88122d80d239bb3b845d58708750b0cb06a09aebb9b18ec696c \
|
||||
--hash=sha256:f7fabeb6cea87d65f3b926de33d03fb016cfdc29314c90974383b5582ae72891 \
|
||||
--hash=sha256:f9579f383d1bf9df80081e72760e84960a7fd4f88cf0c9e535a8597c9bb646f5 \
|
||||
--hash=sha256:f9a2353b37a1a9e78fd82b27ad7e2a32a2d036604d18f02b05e3136c62ca3b09 \
|
||||
--hash=sha256:fc36dc566135b5eceec4cf89758fcb719266a019ef07dae1754ae7c9f617ef3e \
|
||||
--hash=sha256:ffdc76bfcae6b255dff75202c5e7feaf95b40246bc0a17944facc1fecf9f79ab
|
||||
# via
|
||||
# mcp-calculator
|
||||
# scipy
|
||||
opentelemetry-api==1.44.0 \
|
||||
--hash=sha256:67647e5e9566edcf421166fdf022b3537f818635daa852b289e34604dc6fb33a \
|
||||
--hash=sha256:94b98c893a91b88657eaac1e3ba89618cdb85be6918196705354f34728b2cdef
|
||||
# via mcp
|
||||
pebble==5.2.2 \
|
||||
--hash=sha256:1058e3ed41c6bb5cc16fcf6a249502cd4637a597211b997d9a0a499f46bc91a4 \
|
||||
--hash=sha256:eb49a0f6862f7e75df4a40107f1c8c267cf8adfbd6d29a0dac29d17ac188c878
|
||||
# via mcp-calculator
|
||||
pint==0.26.1 \
|
||||
--hash=sha256:1bbde36eae57a5a289cd05081c6405618a5899814940752064ce351cd0204f71 \
|
||||
--hash=sha256:e982b129415c09c63308f314ae44697d83e5c96f253bcc0d5b833fc3329eb6d4
|
||||
# via mcp-calculator
|
||||
platformdirs==4.11.8 \
|
||||
--hash=sha256:52f2f181bbfde907966932cc8312d967d02976422d66d537ea16092b8e291081 \
|
||||
--hash=sha256:f23abafea7dd4276d1f29104b83598d7dcc567cafd07c9c951e66665645437fc
|
||||
# via pint
|
||||
prometheus-client==0.26.0 \
|
||||
--hash=sha256:04a91bcf94e2cf74a44a1a874d651a2e853ed354b6e822f3b7487751465d5c2b \
|
||||
--hash=sha256:fa93d06737aa02bacd05794768508bb97d2fbee28cb3bca04eaae92f0ca953d6
|
||||
# via mcp-calculator
|
||||
pycparser==3.0 ; implementation_name != 'PyPy' and platform_python_implementation != 'PyPy' \
|
||||
--hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \
|
||||
--hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992
|
||||
# via cffi
|
||||
pydantic==2.13.5 \
|
||||
--hash=sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73 \
|
||||
--hash=sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08
|
||||
# via
|
||||
# mcp
|
||||
# mcp-types
|
||||
pydantic-core==2.46.5 \
|
||||
--hash=sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942 \
|
||||
--hash=sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821 \
|
||||
--hash=sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5 \
|
||||
--hash=sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184 \
|
||||
--hash=sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2 \
|
||||
--hash=sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc \
|
||||
--hash=sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5 \
|
||||
--hash=sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0 \
|
||||
--hash=sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931 \
|
||||
--hash=sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e \
|
||||
--hash=sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038 \
|
||||
--hash=sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b \
|
||||
--hash=sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a \
|
||||
--hash=sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074 \
|
||||
--hash=sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433 \
|
||||
--hash=sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034 \
|
||||
--hash=sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21 \
|
||||
--hash=sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c \
|
||||
--hash=sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29 \
|
||||
--hash=sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3 \
|
||||
--hash=sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa \
|
||||
--hash=sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38 \
|
||||
--hash=sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f \
|
||||
--hash=sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b \
|
||||
--hash=sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8 \
|
||||
--hash=sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e \
|
||||
--hash=sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c \
|
||||
--hash=sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1 \
|
||||
--hash=sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f \
|
||||
--hash=sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761 \
|
||||
--hash=sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869 \
|
||||
--hash=sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111 \
|
||||
--hash=sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5 \
|
||||
--hash=sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7 \
|
||||
--hash=sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c \
|
||||
--hash=sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f \
|
||||
--hash=sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea \
|
||||
--hash=sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9 \
|
||||
--hash=sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519 \
|
||||
--hash=sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a \
|
||||
--hash=sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e \
|
||||
--hash=sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2 \
|
||||
--hash=sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f \
|
||||
--hash=sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47 \
|
||||
--hash=sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0 \
|
||||
--hash=sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4 \
|
||||
--hash=sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d \
|
||||
--hash=sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3 \
|
||||
--hash=sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355 \
|
||||
--hash=sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f \
|
||||
--hash=sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5 \
|
||||
--hash=sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829 \
|
||||
--hash=sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575 \
|
||||
--hash=sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290 \
|
||||
--hash=sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62 \
|
||||
--hash=sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f \
|
||||
--hash=sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a \
|
||||
--hash=sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7 \
|
||||
--hash=sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed \
|
||||
--hash=sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f \
|
||||
--hash=sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3 \
|
||||
--hash=sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff \
|
||||
--hash=sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084 \
|
||||
--hash=sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0 \
|
||||
--hash=sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9
|
||||
# via pydantic
|
||||
pyjwt==2.14.0 \
|
||||
--hash=sha256:77283c83fb56ecf566a886c757a714bc83668e38156de2cce8263302f42e0b86 \
|
||||
--hash=sha256:ad0cef71c756a56e74863c2919cf0985f72decbcfcb550ee2f422e7c62b5eedc
|
||||
# via mcp
|
||||
python-multipart==0.0.32 \
|
||||
--hash=sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e \
|
||||
--hash=sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23
|
||||
# via mcp
|
||||
pywin32==312 ; sys_platform == 'win32' \
|
||||
--hash=sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831 \
|
||||
--hash=sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed \
|
||||
--hash=sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950 \
|
||||
--hash=sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c \
|
||||
--hash=sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa \
|
||||
--hash=sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e \
|
||||
--hash=sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b \
|
||||
--hash=sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9 \
|
||||
--hash=sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc \
|
||||
--hash=sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5 \
|
||||
--hash=sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9 \
|
||||
--hash=sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b
|
||||
# via mcp
|
||||
referencing==0.37.0 \
|
||||
--hash=sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231 \
|
||||
--hash=sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8
|
||||
# via
|
||||
# jsonschema
|
||||
# jsonschema-specifications
|
||||
rpds-py==2026.6.3 \
|
||||
--hash=sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5 \
|
||||
--hash=sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680 \
|
||||
--hash=sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538 \
|
||||
--hash=sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf \
|
||||
--hash=sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4 \
|
||||
--hash=sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97 \
|
||||
--hash=sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96 \
|
||||
--hash=sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a \
|
||||
--hash=sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187 \
|
||||
--hash=sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975 \
|
||||
--hash=sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f \
|
||||
--hash=sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703 \
|
||||
--hash=sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f \
|
||||
--hash=sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa \
|
||||
--hash=sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05 \
|
||||
--hash=sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba \
|
||||
--hash=sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223 \
|
||||
--hash=sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885 \
|
||||
--hash=sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698 \
|
||||
--hash=sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7 \
|
||||
--hash=sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed \
|
||||
--hash=sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f \
|
||||
--hash=sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf \
|
||||
--hash=sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e \
|
||||
--hash=sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f \
|
||||
--hash=sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24 \
|
||||
--hash=sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a \
|
||||
--hash=sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc \
|
||||
--hash=sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d \
|
||||
--hash=sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146 \
|
||||
--hash=sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e \
|
||||
--hash=sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e \
|
||||
--hash=sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4 \
|
||||
--hash=sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12 \
|
||||
--hash=sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6 \
|
||||
--hash=sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5 \
|
||||
--hash=sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7 \
|
||||
--hash=sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda \
|
||||
--hash=sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8 \
|
||||
--hash=sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342 \
|
||||
--hash=sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb \
|
||||
--hash=sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0 \
|
||||
--hash=sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77 \
|
||||
--hash=sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885 \
|
||||
--hash=sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617 \
|
||||
--hash=sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb \
|
||||
--hash=sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577 \
|
||||
--hash=sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80 \
|
||||
--hash=sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e \
|
||||
--hash=sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945 \
|
||||
--hash=sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0 \
|
||||
--hash=sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140 \
|
||||
--hash=sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822 \
|
||||
--hash=sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba \
|
||||
--hash=sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4 \
|
||||
--hash=sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a \
|
||||
--hash=sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8 \
|
||||
--hash=sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf \
|
||||
--hash=sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324 \
|
||||
--hash=sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53 \
|
||||
--hash=sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b \
|
||||
--hash=sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41 \
|
||||
--hash=sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9 \
|
||||
--hash=sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1 \
|
||||
--hash=sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d \
|
||||
--hash=sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690 \
|
||||
--hash=sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107 \
|
||||
--hash=sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2 \
|
||||
--hash=sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d \
|
||||
--hash=sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af \
|
||||
--hash=sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6 \
|
||||
--hash=sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db \
|
||||
--hash=sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369 \
|
||||
--hash=sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd \
|
||||
--hash=sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504 \
|
||||
--hash=sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13 \
|
||||
--hash=sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc \
|
||||
--hash=sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868 \
|
||||
--hash=sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2 \
|
||||
--hash=sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd \
|
||||
--hash=sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4 \
|
||||
--hash=sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6 \
|
||||
--hash=sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9 \
|
||||
--hash=sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00 \
|
||||
--hash=sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f \
|
||||
--hash=sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e \
|
||||
--hash=sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442 \
|
||||
--hash=sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90 \
|
||||
--hash=sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef
|
||||
# via
|
||||
# jsonschema
|
||||
# referencing
|
||||
scipy==1.18.1 \
|
||||
--hash=sha256:011413b7426b75012840e35649e00fe0a2c3bae89fed433876e3a99251572efc \
|
||||
--hash=sha256:0ac49ea97594532dd44b7136094d35f5440fa06e6d9c6384a74c01764df388c5 \
|
||||
--hash=sha256:0e82073ecc7acc6436fac4b31674109c7e1d3e596789767eda01258a8c9e8123 \
|
||||
--hash=sha256:0fcb3c93519f27bb4f0c4b0f7802cdcaca7fcf93267b75edda2e9f4e8a55cbd7 \
|
||||
--hash=sha256:10ac20c69d880f77f375db44c22e3e6a644f9fefa291d4cd2fb9790a89fc99fd \
|
||||
--hash=sha256:11c423f1049c5755ad4409af52a9ada1cff96fe9b50795d4af3619f292901239 \
|
||||
--hash=sha256:179ce34a8d0fe273d8883ba59e17e052247d08973dfcb743ca52bb1cce2d60b0 \
|
||||
--hash=sha256:1bca3b943fc2567ea49cd02c99abde49da4d5178ec46f624bd8255cda8755beb \
|
||||
--hash=sha256:1d73131e358976663dd969e1fb4ed1404b815cd977eaaedc3b3a133ba2d81c35 \
|
||||
--hash=sha256:2a0b02f9fc46f8520330c23d45e6560db7e3a0d927232139427637f98943e11d \
|
||||
--hash=sha256:2d3ab0e8c69a17dd3559eab8cbb88f258e285c94d572c2719033f90f83290c89 \
|
||||
--hash=sha256:30f464bee641fa8e282577c7dce027308403213c6ca8270bba73285c91024bc5 \
|
||||
--hash=sha256:33a834464fdabc0f26a45508df31b3cc5d028e04dbf6c5ed398541418e0a12fe \
|
||||
--hash=sha256:3ab3523da44749156e1f68b464dc56af11ae4cbc5c739a49d05f32b982eca9f3 \
|
||||
--hash=sha256:3c085faa2cfa879c5141df483f836f4d691045a078224a670fa570fa01612d89 \
|
||||
--hash=sha256:457fd7a2a8edeb044ab6ffbc0aa03ff6cd18491356e5e0c834d76ce621b916d1 \
|
||||
--hash=sha256:49023963c193dacee096301452f223ee24d86ec5807f8df93c0f7221d119e305 \
|
||||
--hash=sha256:52c4b7422442aba924d03ad4019852b08a92e64ea187b933135687bfe2747307 \
|
||||
--hash=sha256:559ed65f60c1af5a03f3912605a1b5114f522c7c32fb23c3376ae8f03219fe28 \
|
||||
--hash=sha256:5632e3ae3d09197c446310cd5187de63e28448ce22f0f67b2b93d97503c0c230 \
|
||||
--hash=sha256:5e4d44984abc0020154ea81b247adeddcc3ac5527b975ff798bd1ba0adc513c2 \
|
||||
--hash=sha256:75b00eb8fb802090aa903f4ea1c7f5a584779f967361e68b7e98e531cc2d7174 \
|
||||
--hash=sha256:78a0d7c918e74a232394117160e7e3db503377572a45bcef8826e4ab8a35feba \
|
||||
--hash=sha256:78c0665edead396b1abb4897c41a5c1d9bf090c8a637a4c20a61678e0a264e66 \
|
||||
--hash=sha256:7bbf207c4453ce1ad2e00b17313852b33310b83090c2311bdaf97f93c0380d12 \
|
||||
--hash=sha256:7f4b8bc363b6d65ee2152bec57568e3c52639bb34c46057b09857a307ed5e21d \
|
||||
--hash=sha256:82f201b4c878551d48558337aab270d3c6cca5507b8737c8d8a608d234cccde0 \
|
||||
--hash=sha256:83de5453a7799afc9048b4616bd085cef126e36412f0ea2f6370c36a2a3a51e7 \
|
||||
--hash=sha256:88f0e784020649f88ea48c9f5ddfa403bf9205820667c0914740b392035afb82 \
|
||||
--hash=sha256:8bcf3c1ba5d6456e2effd30fcbd3459b044d683fcdac79a2e6830f0bdf7de487 \
|
||||
--hash=sha256:911de823097db8b63f034299d12662db93344e6ffa0b881cbb57748974b70168 \
|
||||
--hash=sha256:92c14f5bdbfb6216315ce33e78080474082de8b3830122ba97809bfbe65f75c0 \
|
||||
--hash=sha256:95298364e251be3e60249facbeeca03631d3bb7584f85879516ec55ac717b81f \
|
||||
--hash=sha256:9554bcc6d715ee87a633a3cc8e7703c6628b100dd29cb8a2efc4c0533c7ff729 \
|
||||
--hash=sha256:9f2897bf7737392ad0d5213ea7b6add72a4edf5679b3153106aeb88b6507b3b9 \
|
||||
--hash=sha256:a1d33a7836f7ddc1993427966a0823468ec41bcbdb1a9f9942d1d7e57f803ba3 \
|
||||
--hash=sha256:ac0333bdf38309aa3dcbe7e3fa7ea29e7a2c37c6ea306a757b700ded8e4596ad \
|
||||
--hash=sha256:bff0b729edd992766136b34e39cc76bc2fad905aa58897ee72a9cd000a6d8443 \
|
||||
--hash=sha256:c24acac1e18912761c4700239bbc1fd32f615af690f1584d49b35859be51324d \
|
||||
--hash=sha256:c35d74ce0e193ff740c2f2be2ac913ddc232fe6c1ff40b26cfecb9c670c63314 \
|
||||
--hash=sha256:c825cef2f49e46753726a7181a8e199804a912b29519ada542c6ebc654951899 \
|
||||
--hash=sha256:c9d18a33309122074ea483dd92dd444189166b8b2ec429fe9ed5ac73c7a0aa23 \
|
||||
--hash=sha256:cbf38d043c1aa4ab306e1ada6ab6eddacc3322a20b7af1b30bc93254b366fe09 \
|
||||
--hash=sha256:cd479fc04dd9401e3b4f49e76518768ef99c4f517a98c284eb091fd725719adf \
|
||||
--hash=sha256:ceb30a00ce7c92d459819443d29ca486d882b83fb6738bdcbb2a1cce94ac5daa \
|
||||
--hash=sha256:cfbf154f2ba187f2ed6cce2639efff7d105f1140573642c0161615b6d91d6a87 \
|
||||
--hash=sha256:d2924a03db38dc2e848bca2fe9f077dafb891480b91a00a0963a8cf86dfc31c1 \
|
||||
--hash=sha256:d416b16cccfd70fbf62400e84d0bb2f4e6af519a45557f1692c749b37f14b315 \
|
||||
--hash=sha256:d65d448389b8436493abcf629cc94ad0cf32aecaf06e1acca1de53cc795f2f12 \
|
||||
--hash=sha256:d84a09d0dad90ba6525d8ac1c2334b33e64bf3ccfe9e841f02feb867a22681e4 \
|
||||
--hash=sha256:ddef79fb382df40104a19bb7151b3b23e57c1778fcf857c71ceecd9bd264513f \
|
||||
--hash=sha256:e3b417bf8c2c7c16e8f58ad91db17783ec911ac16e7b50eb6eab6e809b4f5b07 \
|
||||
--hash=sha256:e402cf31eb68f453dbb2d36fc6d722b33f24a55d68b2ae1d92fa6305ca71c298 \
|
||||
--hash=sha256:e6fb6a55cc0ba97b59a1f288fb86dc6fce8bdfc0fffcbfd015e3a954bf2a2d93 \
|
||||
--hash=sha256:e708533e8b2ae2497d65346538a7dcc92814410b25b81432eac66de0f2af8265 \
|
||||
--hash=sha256:ea324d9dd34c38bfb9bec8ca4d1b407db97dbb74029f566b8e322b1b6fe56fe6 \
|
||||
--hash=sha256:eb0dfcf4e28a99c12c999744a2ff67c9b06200e20401c7c88186e33552a46331 \
|
||||
--hash=sha256:eda632a7981f69730d6281f451db9c1c370993a2c0d7ddb43e2a809a2862b83a \
|
||||
--hash=sha256:f29633129f9fa7e88a3f0fca835de2d030bfc9643f7799e1a0c46cee24d38fc7 \
|
||||
--hash=sha256:f55fa87b6c612ecd6b058f167c53231b1d14e412efe361d3d6e38b3631c73218 \
|
||||
--hash=sha256:fdaf5ea890a6183d0565f51a61799d67081bd5b1cf03c5f4b3fd3732108625c9
|
||||
# via mcp-calculator
|
||||
sse-starlette==3.4.11 \
|
||||
--hash=sha256:1bae716c02f3e6f294be41ff333220692dae7c3cbab077c900f159676719dade \
|
||||
--hash=sha256:c7b2244bdff016fe7f64e10075e89a3e6bbf899649cc89b0fe884b5545042453
|
||||
# via mcp
|
||||
starlette==1.6.0 \
|
||||
--hash=sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c \
|
||||
--hash=sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b
|
||||
# via
|
||||
# mcp
|
||||
# mcp-calculator
|
||||
# sse-starlette
|
||||
sympy==1.14.0 \
|
||||
--hash=sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517 \
|
||||
--hash=sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5
|
||||
# via mcp-calculator
|
||||
truststore==0.10.4 ; sys_platform != 'emscripten' \
|
||||
--hash=sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301 \
|
||||
--hash=sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981
|
||||
# via
|
||||
# httpcore2
|
||||
# httpx2
|
||||
typing-extensions==4.16.0 \
|
||||
--hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \
|
||||
--hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5
|
||||
# via
|
||||
# anyio
|
||||
# flexcache
|
||||
# flexparser
|
||||
# httpx2
|
||||
# mcp
|
||||
# mcp-types
|
||||
# opentelemetry-api
|
||||
# pint
|
||||
# pydantic
|
||||
# pydantic-core
|
||||
# referencing
|
||||
# starlette
|
||||
# typing-inspection
|
||||
typing-inspection==0.4.4 \
|
||||
--hash=sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47 \
|
||||
--hash=sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147
|
||||
# via
|
||||
# mcp
|
||||
# pydantic
|
||||
uvicorn==0.52.4 \
|
||||
--hash=sha256:73acfee47a0b133c5de13d219492d62d8a31e935f4fe6e41a232451a15379f86 \
|
||||
--hash=sha256:f86e41a149d7d05a9969337e3946a9c171c06a5d42680896daaba624aeac8da1
|
||||
# via
|
||||
# mcp
|
||||
# mcp-calculator
|
||||
141
scripts/measure_memory.py
Normal file
141
scripts/measure_memory.py
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
"""Measure the memory footprint of mcp-calculator (see docs/memory.md).
|
||||
|
||||
Usage (inside the project venv):
|
||||
python scripts/measure_memory.py imports # RSS cost of each library, cumulative
|
||||
python scripts/measure_memory.py server # start an HTTP server, warm it up, measure all processes
|
||||
python scripts/measure_memory.py server --workers 4
|
||||
|
||||
Only the standard library is used, so it also runs in a container. RSS is read
|
||||
via ``ps`` (macOS and Linux/procps).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
# Cumulative import steps in the order the server loads them.
|
||||
IMPORT_STEPS = [
|
||||
("Python interpreter", []),
|
||||
("+ NumPy", ["numpy"]),
|
||||
("+ SymPy", ["sympy"]),
|
||||
("+ SciPy (scipy.stats)", ["scipy.stats"]),
|
||||
("+ Pint", ["pint"]),
|
||||
("+ MCP SDK, Pydantic", ["mcp.server.mcpserver"]),
|
||||
("+ uvicorn, Starlette", ["uvicorn", "starlette"]),
|
||||
("+ Pebble", ["pebble"]),
|
||||
]
|
||||
|
||||
WARMUP_CALLS = [
|
||||
("evaluate", {"expression": "sqrt(2)", "precision": 50}),
|
||||
("solve", {"equations": ["x^2 + y^2 = 25", "x - y = 1"]}),
|
||||
("calculus", {"operation": "integral", "expression": "x*e^x"}),
|
||||
("analyze_function", {"expression": "x^3 - 3x"}),
|
||||
("matrix", {"operation": "eigenvalues", "matrix": [[2, 1], [1, 2]]}),
|
||||
("statistics", {"data": [1, 2, 3, 4, 5]}),
|
||||
("probability", {"distribution": "normal", "operation": "cdf", "x": 1}),
|
||||
("convert_units", {"quantity": "100 km/h", "to_unit": "m/s"}),
|
||||
]
|
||||
|
||||
|
||||
def rss_mb(pid: int) -> float:
|
||||
return int(subprocess.check_output(["ps", "-o", "rss=", "-p", str(pid)])) / 1024
|
||||
|
||||
|
||||
def measure_imports() -> None:
|
||||
print(f"{'step':<28}{'RSS total':>10}{'delta':>8}")
|
||||
previous = None
|
||||
for label, _ in IMPORT_STEPS:
|
||||
modules = [m for _, mods in IMPORT_STEPS[: [s[0] for s in IMPORT_STEPS].index(label) + 1] for m in mods]
|
||||
code = "import importlib, os, subprocess\n"
|
||||
code += "".join(f"importlib.import_module({m!r})\n" for m in modules)
|
||||
code += "print(int(subprocess.check_output(['ps', '-o', 'rss=', '-p', str(os.getpid())])))"
|
||||
total = int(subprocess.check_output([sys.executable, "-c", code])) / 1024
|
||||
delta = "" if previous is None else f"+{total - previous:.0f}"
|
||||
print(f"{label:<28}{total:>8.0f} MB{delta:>8}")
|
||||
previous = total
|
||||
|
||||
|
||||
def _children(pid: int) -> list[tuple[int, str]]:
|
||||
out = subprocess.check_output(["ps", "-A", "-o", "pid=,ppid=,command="], text=True)
|
||||
result = []
|
||||
for line in out.splitlines():
|
||||
parts = line.split(None, 2)
|
||||
if len(parts) == 3 and int(parts[1]) == pid:
|
||||
result.append((int(parts[0]), parts[2]))
|
||||
return result
|
||||
|
||||
|
||||
def _call(url: str, tool: str, arguments: dict) -> None:
|
||||
body = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": tool, "arguments": arguments}}).encode()
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
data=body,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json, text/event-stream",
|
||||
"MCP-Protocol-Version": "2025-06-18",
|
||||
},
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=60) as response:
|
||||
response.read()
|
||||
|
||||
|
||||
def measure_server(workers: int) -> None:
|
||||
with socket.socket() as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
port = s.getsockname()[1]
|
||||
env = {**os.environ, "MCP_CALC_TRANSPORT": "http", "MCP_CALC_PORT": str(port), "MCP_CALC_WORKERS": str(workers), "MCP_CALC_LOG_LEVEL": "WARNING"}
|
||||
proc = subprocess.Popen([sys.executable, "-m", "mcp_calculator"], env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
base = f"http://127.0.0.1:{port}"
|
||||
try:
|
||||
started = time.perf_counter()
|
||||
while True:
|
||||
try:
|
||||
urllib.request.urlopen(f"{base}/readyz", timeout=1).read()
|
||||
break
|
||||
except OSError:
|
||||
if time.perf_counter() - started > 60:
|
||||
raise SystemExit("server did not become ready") from None
|
||||
time.sleep(0.1)
|
||||
ready_after = time.perf_counter() - started
|
||||
|
||||
first = time.perf_counter()
|
||||
_call(f"{base}/mcp", "evaluate", {"expression": "1+1"})
|
||||
first_call = time.perf_counter() - first
|
||||
for _ in range(workers): # every worker should have computed something
|
||||
for tool, arguments in WARMUP_CALLS:
|
||||
_call(f"{base}/mcp", tool, arguments)
|
||||
time.sleep(1)
|
||||
|
||||
rows = [("server", rss_mb(proc.pid))]
|
||||
for pid, command in _children(proc.pid):
|
||||
rows.append(("resource tracker" if "resource_tracker" in command else "worker", rss_mb(pid)))
|
||||
print(f"ready after {ready_after:.2f}s, first call {first_call:.2f}s, {workers} worker(s)\n")
|
||||
for name, mb in rows:
|
||||
print(f"{name:<18}{mb:>6.0f} MB")
|
||||
print(f"{'total':<18}{sum(mb for _, mb in rows):>6.0f} MB")
|
||||
finally:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=30)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("mode", choices=["imports", "server"])
|
||||
parser.add_argument("--workers", type=int, default=2)
|
||||
args = parser.parse_args()
|
||||
if args.mode == "imports":
|
||||
measure_imports()
|
||||
else:
|
||||
measure_server(args.workers)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
3
src/mcp_calculator/__init__.py
Normal file
3
src/mcp_calculator/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
"""MCP server providing exact, deterministic math tools for LLMs."""
|
||||
|
||||
__version__ = "1.2.0"
|
||||
51
src/mcp_calculator/__main__.py
Normal file
51
src/mcp_calculator/__main__.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
"""Entry point: ``mcp-calculator`` or ``python -m mcp_calculator``.
|
||||
|
||||
Keep the module-level imports of this file light. Worker processes are started
|
||||
with ``spawn``, which re-imports the parent's main module in every worker; heavy
|
||||
imports here (MCP SDK, uvicorn, Starlette) would be loaded into each worker for
|
||||
nothing. They are therefore imported inside the functions (see docs/memory.md).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from mcp_calculator.config import Settings, load_settings
|
||||
from mcp_calculator.logging_setup import setup_logging
|
||||
|
||||
logger = logging.getLogger("mcp_calculator")
|
||||
|
||||
|
||||
async def _serve(settings: Settings) -> None:
|
||||
from mcp_calculator.executor import Executor
|
||||
from mcp_calculator.server import create_server
|
||||
|
||||
executor = Executor(settings)
|
||||
executor.start()
|
||||
mcp = create_server(executor)
|
||||
try:
|
||||
if settings.transport == "http":
|
||||
from mcp_calculator.http_app import serve_http
|
||||
|
||||
await serve_http(mcp, settings, executor)
|
||||
else:
|
||||
logger.info("serving MCP over stdio")
|
||||
await mcp.run_stdio_async()
|
||||
finally:
|
||||
executor.stop()
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> None:
|
||||
import anyio
|
||||
|
||||
settings = load_settings(sys.argv[1:] if argv is None else argv)
|
||||
setup_logging(settings.log_level, settings.log_format)
|
||||
try:
|
||||
anyio.run(_serve, settings)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
214
src/mcp_calculator/admin.py
Normal file
214
src/mcp_calculator/admin.py
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
"""Client administration: one service layer used by the admin API and the CLI.
|
||||
|
||||
Every change is written to the audit log (one JSON object per line) with actor,
|
||||
action, client and the changed fields; token values and hashes are never logged.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from starlette.applications import Starlette
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.routing import Route
|
||||
|
||||
from mcp_calculator.clients import Client, ClientConfigError, ClientFile, hash_token, new_token, now_iso, validate_name
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
audit_logger = logging.getLogger("mcp_calculator.audit")
|
||||
|
||||
|
||||
class NotFound(LookupError):
|
||||
pass
|
||||
|
||||
|
||||
class Conflict(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class AuditLog:
|
||||
def __init__(self, path: str | os.PathLike[str] | None) -> None:
|
||||
self.path = Path(path) if path else None
|
||||
|
||||
def record(self, actor: str, action: str, client: str, details: dict[str, Any] | None = None) -> None:
|
||||
entry = {"time": datetime.now(UTC).isoformat(timespec="seconds"), "actor": actor, "action": action, "client": client}
|
||||
if details:
|
||||
entry["details"] = details
|
||||
line = json.dumps(entry, ensure_ascii=False, default=str)
|
||||
audit_logger.info(line)
|
||||
if self.path is not None:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.path, "a", encoding="utf-8") as f:
|
||||
f.write(line + "\n")
|
||||
|
||||
|
||||
class ClientAdmin:
|
||||
def __init__(self, file: ClientFile, audit: AuditLog, on_change: Callable[[], None] | None = None) -> None:
|
||||
self.file = file
|
||||
self.audit = audit
|
||||
self.on_change = on_change or (lambda: None)
|
||||
|
||||
def list(self) -> list[dict[str, Any]]:
|
||||
return [c.public_view() for _, c in sorted(self.file.read().items())]
|
||||
|
||||
def get(self, name: str) -> dict[str, Any]:
|
||||
clients = self.file.read()
|
||||
if name not in clients:
|
||||
raise NotFound(f"client {name!r} not found")
|
||||
return clients[name].public_view()
|
||||
|
||||
def create(
|
||||
self,
|
||||
name: str,
|
||||
*,
|
||||
actor: str,
|
||||
auth: str = "token",
|
||||
max_concurrent: int | None = None,
|
||||
expires: str | None = None,
|
||||
allowed_networks: list[str] | None = None,
|
||||
note: str = "",
|
||||
) -> tuple[dict[str, Any], str | None]:
|
||||
validate_name(name)
|
||||
token = new_token() if auth == "token" else None
|
||||
data: dict[str, Any] = {
|
||||
"auth": auth,
|
||||
"max_concurrent": max_concurrent,
|
||||
"expires": expires,
|
||||
"allowed_networks": allowed_networks or [],
|
||||
"note": note,
|
||||
"created": now_iso(),
|
||||
}
|
||||
if token:
|
||||
data["token_sha256"] = hash_token(token)
|
||||
data["token_hint"] = token[:9]
|
||||
client = Client.from_dict(name, data)
|
||||
with self.file.update() as clients:
|
||||
if name in clients:
|
||||
raise Conflict(f"client {name!r} already exists")
|
||||
clients[name] = client
|
||||
self._changed(actor, "create", name, client.to_dict(include_hash=False))
|
||||
return client.public_view(), token
|
||||
|
||||
def update(self, name: str, changes: dict[str, Any], *, actor: str) -> dict[str, Any]:
|
||||
with self.file.update() as clients:
|
||||
if name not in clients:
|
||||
raise NotFound(f"client {name!r} not found")
|
||||
clients[name] = clients[name].with_changes(changes)
|
||||
client = clients[name]
|
||||
self._changed(actor, "update", name, changes)
|
||||
return client.public_view()
|
||||
|
||||
def rotate(self, name: str, *, actor: str) -> tuple[dict[str, Any], str]:
|
||||
token = new_token()
|
||||
with self.file.update() as clients:
|
||||
if name not in clients:
|
||||
raise NotFound(f"client {name!r} not found")
|
||||
if clients[name].auth != "token":
|
||||
raise ClientConfigError(f"client {name!r} authenticates by network and has no token")
|
||||
old = clients[name]
|
||||
clients[name] = Client.from_dict(name, {**old.to_dict(), "token_sha256": hash_token(token), "token_hint": token[:9], "updated": now_iso()})
|
||||
client = clients[name]
|
||||
self._changed(actor, "rotate", name)
|
||||
return client.public_view(), token
|
||||
|
||||
def delete(self, name: str, *, actor: str) -> None:
|
||||
with self.file.update() as clients:
|
||||
if clients.pop(name, None) is None:
|
||||
raise NotFound(f"client {name!r} not found")
|
||||
self._changed(actor, "delete", name)
|
||||
|
||||
def _changed(self, actor: str, action: str, name: str, details: dict[str, Any] | None = None) -> None:
|
||||
self.audit.record(actor, action, name, details)
|
||||
self.on_change()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- admin API
|
||||
|
||||
|
||||
def build_admin_app(admin: ClientAdmin, admin_tokens: list[str]) -> Starlette:
|
||||
"""Admin REST API. Runs on its own port; every route requires an admin token."""
|
||||
tokens = {name: hash_token(token) for name, _, token in (entry.partition(":") for entry in admin_tokens)}
|
||||
|
||||
def actor_of(request: Request) -> str | None:
|
||||
scheme, _, credentials = request.headers.get("authorization", "").partition(" ")
|
||||
if scheme.lower() != "bearer" or not credentials:
|
||||
return None
|
||||
candidate = hash_token(credentials.strip())
|
||||
for name, expected in tokens.items():
|
||||
if hmac.compare_digest(candidate, expected):
|
||||
return f"admin:{name}"
|
||||
return None
|
||||
|
||||
def handler(fn: Callable[..., Any]) -> Callable[[Request], Any]:
|
||||
async def endpoint(request: Request) -> JSONResponse:
|
||||
actor = actor_of(request)
|
||||
if actor is None:
|
||||
logger.warning("admin API: rejected request without valid admin token from %s", request.client.host if request.client else "?")
|
||||
return JSONResponse({"error": "unauthorized"}, status_code=401, headers={"WWW-Authenticate": "Bearer"})
|
||||
try:
|
||||
body = await request.json() if request.method in ("POST", "PATCH") and await request.body() else {}
|
||||
if not isinstance(body, dict):
|
||||
raise ClientConfigError("request body must be a JSON object")
|
||||
status, payload = fn(request, body, actor)
|
||||
return JSONResponse(payload, status_code=status)
|
||||
except NotFound as exc:
|
||||
return JSONResponse({"error": "not_found", "message": str(exc)}, status_code=404)
|
||||
except Conflict as exc:
|
||||
return JSONResponse({"error": "conflict", "message": str(exc)}, status_code=409)
|
||||
except (ClientConfigError, json.JSONDecodeError, TypeError) as exc:
|
||||
return JSONResponse({"error": "invalid", "message": str(exc)}, status_code=400)
|
||||
|
||||
return endpoint
|
||||
|
||||
def list_clients(request: Request, body: dict, actor: str) -> tuple[int, Any]:
|
||||
return 200, {"clients": admin.list()}
|
||||
|
||||
def create_client(request: Request, body: dict, actor: str) -> tuple[int, Any]:
|
||||
allowed = {"name", "auth", "max_concurrent", "expires", "allowed_networks", "note"}
|
||||
if set(body) - allowed:
|
||||
raise ClientConfigError(f"unknown field(s) {sorted(set(body) - allowed)}")
|
||||
if "name" not in body:
|
||||
raise ClientConfigError("'name' is required")
|
||||
client, token = admin.create(body.pop("name"), actor=actor, **body)
|
||||
payload: dict[str, Any] = {"client": client}
|
||||
if token:
|
||||
payload["token"] = token
|
||||
payload["note"] = "Store this token now; it cannot be shown again."
|
||||
return 201, payload
|
||||
|
||||
def get_client(request: Request, body: dict, actor: str) -> tuple[int, Any]:
|
||||
return 200, admin.get(request.path_params["name"])
|
||||
|
||||
def update_client(request: Request, body: dict, actor: str) -> tuple[int, Any]:
|
||||
return 200, admin.update(request.path_params["name"], body, actor=actor)
|
||||
|
||||
def rotate_client(request: Request, body: dict, actor: str) -> tuple[int, Any]:
|
||||
client, token = admin.rotate(request.path_params["name"], actor=actor)
|
||||
return 200, {"client": client, "token": token, "note": "Store this token now; the previous token is invalid."}
|
||||
|
||||
def delete_client(request: Request, body: dict, actor: str) -> tuple[int, Any]:
|
||||
admin.delete(request.path_params["name"], actor=actor)
|
||||
return 200, {"deleted": request.path_params["name"]}
|
||||
|
||||
async def health(_: Request) -> JSONResponse:
|
||||
return JSONResponse({"status": "ok"})
|
||||
|
||||
return Starlette(
|
||||
routes=[
|
||||
Route("/healthz", health, methods=["GET"]),
|
||||
Route("/admin/clients", handler(list_clients), methods=["GET"]),
|
||||
Route("/admin/clients", handler(create_client), methods=["POST"]),
|
||||
Route("/admin/clients/{name}", handler(get_client), methods=["GET"]),
|
||||
Route("/admin/clients/{name}", handler(update_client), methods=["PATCH"]),
|
||||
Route("/admin/clients/{name}", handler(delete_client), methods=["DELETE"]),
|
||||
Route("/admin/clients/{name}/rotate", handler(rotate_client), methods=["POST"]),
|
||||
]
|
||||
)
|
||||
161
src/mcp_calculator/admin_cli.py
Normal file
161
src/mcp_calculator/admin_cli.py
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
"""``mcp-calculator-admin``: manage API clients directly in the clients file.
|
||||
|
||||
Works locally and inside the pod (``oc exec deploy/mcp-calculator -- mcp-calculator-admin list``).
|
||||
Running servers pick up changes automatically (hot reload).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import getpass
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from mcp_calculator.admin import AuditLog, ClientAdmin, Conflict, NotFound
|
||||
from mcp_calculator.clients import ClientConfigError, ClientFile
|
||||
|
||||
|
||||
def _actor() -> str:
|
||||
try:
|
||||
return f"cli:{getpass.getuser()}"
|
||||
except (KeyError, OSError): # arbitrary UID without passwd entry (OpenShift)
|
||||
return f"cli:uid{os.getuid()}"
|
||||
|
||||
|
||||
def _limit(value: str) -> int | None:
|
||||
return None if value == "default" else int(value)
|
||||
|
||||
|
||||
def _networks(value: str) -> list[str]:
|
||||
return [] if value in ("", "none") else [v.strip() for v in value.split(",") if v.strip()]
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(prog="mcp-calculator-admin", description="Manage API clients of mcp-calculator.")
|
||||
parser.add_argument("--file", default=os.environ.get("MCP_CALC_CLIENTS_FILE"), help="clients file (default: $MCP_CALC_CLIENTS_FILE)")
|
||||
parser.add_argument("--audit-file", default=os.environ.get("MCP_CALC_AUDIT_FILE"), help="audit log (default: audit.log next to the clients file)")
|
||||
parser.add_argument("--json", action="store_true", help="machine-readable output")
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
create = sub.add_parser("create", help="create a client and print its token once")
|
||||
create.add_argument("name")
|
||||
create.add_argument("--network-only", action="store_true", help="authenticate by source network only (no token); needs --networks")
|
||||
create.add_argument("--networks", type=_networks, default=[], help="comma-separated IPs/CIDRs the client may connect from")
|
||||
create.add_argument("--max-concurrent", type=_limit, default=None, help="concurrent computations (0 = unlimited, default = server default)")
|
||||
create.add_argument("--expires", default=None, help="last valid day, e.g. 2027-03-31")
|
||||
create.add_argument("--note", default="")
|
||||
|
||||
sub.add_parser("list", help="list clients")
|
||||
for name, text in (("show", "show one client"), ("enable", "enable a client"), ("disable", "disable (block) a client"), ("rotate", "issue a new token; the old one stops working")):
|
||||
p = sub.add_parser(name, help=text)
|
||||
p.add_argument("name")
|
||||
|
||||
delete = sub.add_parser("delete", help="delete a client")
|
||||
delete.add_argument("name")
|
||||
delete.add_argument("--yes", action="store_true", help="do not ask for confirmation")
|
||||
|
||||
# SUPPRESS: options that are not given do not appear in the namespace at all,
|
||||
# so "not given" and "reset to default" (None) can be told apart.
|
||||
update = sub.add_parser("set", help="change settings of a client", argument_default=argparse.SUPPRESS)
|
||||
update.add_argument("name")
|
||||
update.add_argument("--max-concurrent", type=_limit, help="number, 0 = unlimited, 'default' = server default")
|
||||
update.add_argument("--expires", help="date or 'never'")
|
||||
update.add_argument("--networks", type=_networks, help="comma-separated IPs/CIDRs or 'none'")
|
||||
update.add_argument("--note")
|
||||
return parser
|
||||
|
||||
|
||||
def _print(data: Any, as_json: bool) -> None:
|
||||
if as_json:
|
||||
print(json.dumps(data, indent=2, ensure_ascii=False))
|
||||
return
|
||||
if isinstance(data, dict) and "clients" in data:
|
||||
rows = data["clients"]
|
||||
if not rows:
|
||||
print("no clients")
|
||||
return
|
||||
print(f"{'NAME':<24} {'STATUS':<9} {'AUTH':<8} {'LIMIT':<7} {'EXPIRES':<11} {'TOKEN':<11} NETWORKS")
|
||||
for c in rows:
|
||||
limit = "default" if c.get("max_concurrent") is None else str(c["max_concurrent"])
|
||||
print(
|
||||
f"{c['name']:<24} {c['status']:<9} {c.get('auth', 'token'):<8} {limit:<7} {c.get('expires', '-'):<11} "
|
||||
f"{(c.get('token_hint', '') + '…') if c.get('token_hint') else '-':<11} {','.join(c.get('allowed_networks', [])) or '-'}"
|
||||
)
|
||||
return
|
||||
for key, value in data.items():
|
||||
print(f"{key}: {value}")
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
if not args.file:
|
||||
print("error: no clients file (use --file or set MCP_CALC_CLIENTS_FILE)", file=sys.stderr)
|
||||
return 2
|
||||
file = ClientFile(args.file)
|
||||
audit = AuditLog(args.audit_file or Path(args.file).with_name("audit.log"))
|
||||
admin = ClientAdmin(file, audit)
|
||||
actor = _actor()
|
||||
|
||||
try:
|
||||
match args.command:
|
||||
case "list":
|
||||
_print({"clients": admin.list()}, args.json)
|
||||
case "show":
|
||||
_print(admin.get(args.name), args.json)
|
||||
case "create":
|
||||
if args.network_only and not args.networks:
|
||||
raise ClientConfigError("--network-only needs --networks")
|
||||
client, token = admin.create(
|
||||
args.name,
|
||||
actor=actor,
|
||||
auth="network" if args.network_only else "token",
|
||||
max_concurrent=args.max_concurrent,
|
||||
expires=args.expires,
|
||||
allowed_networks=args.networks,
|
||||
note=args.note,
|
||||
)
|
||||
if args.json:
|
||||
_print({"client": client, "token": token}, True)
|
||||
else:
|
||||
_print(client, False)
|
||||
if token:
|
||||
print(f"\nTOKEN (shown only once, store it now):\n{token}")
|
||||
case "enable" | "disable":
|
||||
_print(admin.update(args.name, {"enabled": args.command == "enable"}, actor=actor), args.json)
|
||||
case "set":
|
||||
options = vars(args)
|
||||
changes: dict[str, Any] = {}
|
||||
if "max_concurrent" in options:
|
||||
changes["max_concurrent"] = options["max_concurrent"]
|
||||
if "expires" in options:
|
||||
changes["expires"] = None if options["expires"] == "never" else options["expires"]
|
||||
if "networks" in options:
|
||||
changes["allowed_networks"] = options["networks"]
|
||||
if "note" in options:
|
||||
changes["note"] = options["note"]
|
||||
if not changes:
|
||||
raise ClientConfigError("nothing to change (see --help)")
|
||||
_print(admin.update(args.name, changes, actor=actor), args.json)
|
||||
case "rotate":
|
||||
client, token = admin.rotate(args.name, actor=actor)
|
||||
if args.json:
|
||||
_print({"client": client, "token": token}, True)
|
||||
else:
|
||||
print(f"new TOKEN for {args.name} (shown only once; the old token no longer works):\n{token}")
|
||||
case "delete":
|
||||
if not args.yes and input(f"delete client {args.name!r}? [y/N] ").strip().lower() != "y":
|
||||
print("aborted")
|
||||
return 1
|
||||
admin.delete(args.name, actor=actor)
|
||||
print(f"deleted {args.name}")
|
||||
except (ClientConfigError, NotFound, Conflict) as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
153
src/mcp_calculator/auth.py
Normal file
153
src/mcp_calculator/auth.py
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
"""Authentication of HTTP requests against the client registry.
|
||||
|
||||
Order of checks:
|
||||
1. A token (``Authorization: Bearer <token>`` or ``X-API-Key: <token>``) must belong to an
|
||||
active client and, if the client has ``allowed_networks``, come from one of them.
|
||||
A presented but unknown token is always rejected, even from a trusted network.
|
||||
2. Without a token, a ``network`` client whose ``allowed_networks`` contain the caller's IP
|
||||
matches (the most specific network wins).
|
||||
3. Otherwise the request is rejected.
|
||||
|
||||
The caller's IP is ``scope["client"]``. Behind the OpenShift router it is taken from
|
||||
``X-Forwarded-For``, but only if the direct peer is listed in ``MCP_CALC_TRUSTED_PROXIES``
|
||||
(uvicorn's proxy-header handling).
|
||||
|
||||
The authenticated identity is stored in a context variable for the tool layer
|
||||
(per-client limits, logs, metrics). stdio never authenticates: identity "local".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import json
|
||||
import logging
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
from starlette.types import ASGIApp, Receive, Scope, Send
|
||||
|
||||
from mcp_calculator import metrics
|
||||
from mcp_calculator.clients import ClientRegistry, Snapshot, hash_token
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Endpoints for probes and scraping; they stay reachable without credentials.
|
||||
PUBLIC_PATHS = frozenset({"/healthz", "/readyz", "/metrics"})
|
||||
|
||||
FailureReason = Literal["missing_credentials", "invalid_token", "disabled", "expired", "network_denied"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Identity:
|
||||
name: str
|
||||
method: Literal["token", "network", "local", "anonymous"]
|
||||
max_concurrent: int | None = None # None = server default
|
||||
|
||||
@property
|
||||
def limited(self) -> bool:
|
||||
return self.method in ("token", "network", "anonymous")
|
||||
|
||||
|
||||
LOCAL = Identity("local", "local")
|
||||
ANONYMOUS = Identity("anonymous", "anonymous")
|
||||
|
||||
current_identity: ContextVar[Identity] = ContextVar("current_identity", default=LOCAL)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Failure:
|
||||
reason: FailureReason
|
||||
client: str | None = None
|
||||
|
||||
@property
|
||||
def status(self) -> int:
|
||||
return 401 if self.reason in ("missing_credentials", "invalid_token") else 403
|
||||
|
||||
@property
|
||||
def message(self) -> str:
|
||||
return {
|
||||
"missing_credentials": "missing API token (Authorization: Bearer <token> or X-API-Key)",
|
||||
"invalid_token": "invalid API token",
|
||||
"disabled": "client is disabled",
|
||||
"expired": "API token has expired",
|
||||
"network_denied": "API token is not allowed from this network",
|
||||
}[self.reason]
|
||||
|
||||
|
||||
def authenticate(snapshot: Snapshot, token: str | None, ip: str | None) -> Identity | Failure:
|
||||
if token:
|
||||
client = snapshot.by_hash.get(hash_token(token))
|
||||
if client is None:
|
||||
return Failure("invalid_token")
|
||||
status = client.status()
|
||||
if status != "active":
|
||||
return Failure(status, client.name)
|
||||
if not client.allows_ip(ip):
|
||||
return Failure("network_denied", client.name)
|
||||
return Identity(client.name, "token", client.max_concurrent)
|
||||
|
||||
matches = [c for c in snapshot.network_clients if c.status() == "active" and c.allows_ip(ip)]
|
||||
if not matches:
|
||||
return Failure("missing_credentials")
|
||||
address = ipaddress.ip_address(ip) # allows_ip() succeeded, so ip is valid
|
||||
best = max(matches, key=lambda c: max(n.prefixlen for n in c.allowed_networks if address in n))
|
||||
return Identity(best.name, "network", best.max_concurrent)
|
||||
|
||||
|
||||
def extract_token(headers: list[tuple[bytes, bytes]]) -> str | None:
|
||||
values = dict(headers)
|
||||
authorization = values.get(b"authorization", b"")
|
||||
scheme, _, credentials = authorization.partition(b" ")
|
||||
if scheme.lower() == b"bearer" and credentials.strip():
|
||||
return credentials.strip().decode("latin-1")
|
||||
api_key = values.get(b"x-api-key", b"").strip()
|
||||
return api_key.decode("latin-1") if api_key else None
|
||||
|
||||
|
||||
class AuthMiddleware:
|
||||
"""Rejects unauthenticated requests and records the identity of the others."""
|
||||
|
||||
def __init__(self, app: ASGIApp, registry: ClientRegistry) -> None:
|
||||
self.app = app
|
||||
self.registry = registry
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if scope["type"] != "http" or scope["path"] in PUBLIC_PATHS:
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
ip = scope["client"][0] if scope.get("client") else None
|
||||
result = authenticate(self.registry.snapshot(), extract_token(scope.get("headers") or []), ip)
|
||||
if isinstance(result, Failure):
|
||||
metrics.AUTH_FAILURES.labels(result.reason).inc()
|
||||
logger.warning("auth rejected: reason=%s client=%s ip=%s path=%s", result.reason, result.client or "-", ip, scope["path"])
|
||||
await _reject(send, result)
|
||||
return
|
||||
token = current_identity.set(result)
|
||||
try:
|
||||
await self.app(scope, receive, send)
|
||||
finally:
|
||||
current_identity.reset(token)
|
||||
|
||||
|
||||
class AnonymousMiddleware:
|
||||
"""HTTP without any configured client: everybody is 'anonymous' (still subject to limits)."""
|
||||
|
||||
def __init__(self, app: ASGIApp) -> None:
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
token = current_identity.set(ANONYMOUS)
|
||||
try:
|
||||
await self.app(scope, receive, send)
|
||||
finally:
|
||||
current_identity.reset(token)
|
||||
|
||||
|
||||
async def _reject(send: Send, failure: Failure) -> None:
|
||||
body = json.dumps({"error": failure.reason, "message": failure.message}).encode()
|
||||
headers = [(b"content-type", b"application/json")]
|
||||
if failure.status == 401:
|
||||
headers.append((b"www-authenticate", b"Bearer"))
|
||||
await send({"type": "http.response.start", "status": failure.status, "headers": headers})
|
||||
await send({"type": "http.response.body", "body": body})
|
||||
359
src/mcp_calculator/clients.py
Normal file
359
src/mcp_calculator/clients.py
Normal file
|
|
@ -0,0 +1,359 @@
|
|||
"""API clients: model, validation, token hashing and the shared JSON file.
|
||||
|
||||
The clients file lives on a shared (RWX) volume so that every replica reads the
|
||||
same data and the admin API / CLI can change it at runtime:
|
||||
|
||||
{
|
||||
"version": 1,
|
||||
"clients": {
|
||||
"opencode-team-a": {"token_sha256": "...", "token_hint": "mcpc_AbCd", "max_concurrent": 4},
|
||||
"legacy-batch": {"auth": "network", "allowed_networks": ["10.131.2.15/32"]}
|
||||
}
|
||||
}
|
||||
|
||||
Only SHA-256 hashes of tokens are stored; the plain token is shown once when it
|
||||
is created. Tokens are 256-bit random values, so an unsalted hash is sufficient.
|
||||
|
||||
Writes take an exclusive POSIX lock (``fcntl.lockf``, works on NFS/CephFS) and
|
||||
replace the file atomically; readers never see a half-written file.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import asdict, dataclass, field, replace
|
||||
from datetime import UTC, date, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TOKEN_PREFIX = "mcpc_"
|
||||
FILE_VERSION = 1
|
||||
NAME_PATTERN = re.compile(r"^[a-z0-9][a-z0-9._-]{0,63}$")
|
||||
_EDITABLE = {"enabled", "expires", "max_concurrent", "allowed_networks", "note"}
|
||||
|
||||
AuthMethod = Literal["token", "network"]
|
||||
Network = ipaddress.IPv4Network | ipaddress.IPv6Network
|
||||
|
||||
|
||||
class ClientConfigError(ValueError):
|
||||
"""Invalid client data or clients file; the message is safe to show to an admin."""
|
||||
|
||||
|
||||
def new_token() -> str:
|
||||
return TOKEN_PREFIX + secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def hash_token(token: str) -> str:
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
return datetime.now(UTC).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Client:
|
||||
name: str
|
||||
auth: AuthMethod = "token"
|
||||
token_sha256: str | None = None
|
||||
token_hint: str | None = None
|
||||
enabled: bool = True
|
||||
expires: date | None = None # valid through this day (UTC)
|
||||
max_concurrent: int | None = None # None = server default, 0 = unlimited
|
||||
allowed_networks: tuple[Network, ...] = ()
|
||||
note: str = ""
|
||||
created: str | None = None
|
||||
updated: str | None = None
|
||||
source: Literal["file", "env"] = field(default="file", compare=False)
|
||||
|
||||
def status(self, today: date | None = None) -> Literal["active", "disabled", "expired"]:
|
||||
if not self.enabled:
|
||||
return "disabled"
|
||||
if self.expires is not None and (today or datetime.now(UTC).date()) > self.expires:
|
||||
return "expired"
|
||||
return "active"
|
||||
|
||||
def allows_ip(self, ip: str | None) -> bool:
|
||||
if not self.allowed_networks:
|
||||
return True
|
||||
if ip is None:
|
||||
return False
|
||||
try:
|
||||
address = ipaddress.ip_address(ip)
|
||||
except ValueError:
|
||||
return False
|
||||
return any(address in network for network in self.allowed_networks)
|
||||
|
||||
def to_dict(self, *, include_hash: bool = True) -> dict[str, Any]:
|
||||
data = asdict(self)
|
||||
data.pop("name")
|
||||
data.pop("source")
|
||||
data["expires"] = self.expires.isoformat() if self.expires else None
|
||||
data["allowed_networks"] = [str(n) for n in self.allowed_networks]
|
||||
if not include_hash:
|
||||
data.pop("token_sha256")
|
||||
return {k: v for k, v in data.items() if v is not None and v != "" and v != []}
|
||||
|
||||
def public_view(self) -> dict[str, Any]:
|
||||
"""Representation for listings: no hash, plus the computed status."""
|
||||
return {"name": self.name, "status": self.status(), "source": self.source, **self.to_dict(include_hash=False)}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, name: str, data: dict[str, Any], *, source: Literal["file", "env"] = "file") -> Client:
|
||||
if not isinstance(data, dict):
|
||||
raise ClientConfigError(f"client {name!r}: entry must be an object")
|
||||
unknown = set(data) - {f for f in cls.__dataclass_fields__ if f not in ("name", "source")}
|
||||
if unknown:
|
||||
raise ClientConfigError(f"client {name!r}: unknown field(s) {sorted(unknown)}")
|
||||
auth = data.get("auth", "token")
|
||||
client = cls(
|
||||
name=validate_name(name),
|
||||
auth=auth,
|
||||
token_sha256=data.get("token_sha256"),
|
||||
token_hint=data.get("token_hint"),
|
||||
enabled=_bool_field(name, "enabled", data.get("enabled", True)),
|
||||
expires=_parse_expires(name, data.get("expires")),
|
||||
max_concurrent=_limit_field(name, data.get("max_concurrent")),
|
||||
allowed_networks=_parse_networks(name, data.get("allowed_networks", [])),
|
||||
note=str(data.get("note", "")),
|
||||
created=data.get("created"),
|
||||
updated=data.get("updated"),
|
||||
source=source,
|
||||
)
|
||||
client.validate()
|
||||
return client
|
||||
|
||||
def validate(self) -> None:
|
||||
if self.auth not in ("token", "network"):
|
||||
raise ClientConfigError(f"client {self.name!r}: auth must be 'token' or 'network'")
|
||||
if self.auth == "token" and not re.fullmatch(r"[0-9a-f]{64}", self.token_sha256 or ""):
|
||||
raise ClientConfigError(f"client {self.name!r}: token clients need a 64-character hex token_sha256")
|
||||
if self.auth == "network":
|
||||
if self.token_sha256:
|
||||
raise ClientConfigError(f"client {self.name!r}: network clients must not have a token")
|
||||
if not self.allowed_networks:
|
||||
raise ClientConfigError(f"client {self.name!r}: network clients need allowed_networks")
|
||||
|
||||
def with_changes(self, changes: dict[str, Any]) -> Client:
|
||||
unknown = set(changes) - _EDITABLE
|
||||
if unknown:
|
||||
raise ClientConfigError(f"cannot change field(s) {sorted(unknown)}; editable: {sorted(_EDITABLE)}")
|
||||
updated = replace(
|
||||
self,
|
||||
enabled=_bool_field(self.name, "enabled", changes.get("enabled", self.enabled)),
|
||||
expires=_parse_expires(self.name, changes["expires"]) if "expires" in changes else self.expires,
|
||||
max_concurrent=_limit_field(self.name, changes["max_concurrent"]) if "max_concurrent" in changes else self.max_concurrent,
|
||||
allowed_networks=_parse_networks(self.name, changes["allowed_networks"]) if "allowed_networks" in changes else self.allowed_networks,
|
||||
note=str(changes.get("note", self.note)),
|
||||
updated=now_iso(),
|
||||
)
|
||||
updated.validate()
|
||||
return updated
|
||||
|
||||
|
||||
def validate_name(name: str) -> str:
|
||||
if not isinstance(name, str) or not NAME_PATTERN.match(name):
|
||||
raise ClientConfigError(f"invalid client name {name!r}: use 1-64 characters a-z 0-9 . _ - (starting with a letter or digit)")
|
||||
return name
|
||||
|
||||
|
||||
def _bool_field(name: str, key: str, value: Any) -> bool:
|
||||
if not isinstance(value, bool):
|
||||
raise ClientConfigError(f"client {name!r}: {key} must be true or false")
|
||||
return value
|
||||
|
||||
|
||||
def _limit_field(name: str, value: Any) -> int | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
||||
raise ClientConfigError(f"client {name!r}: max_concurrent must be an integer >= 0 (0 = unlimited) or null")
|
||||
return value
|
||||
|
||||
|
||||
def _parse_expires(name: str, value: Any) -> date | None:
|
||||
if value in (None, ""):
|
||||
return None
|
||||
try:
|
||||
return date.fromisoformat(str(value))
|
||||
except ValueError:
|
||||
raise ClientConfigError(f"client {name!r}: expires must be a date like 2027-03-31") from None
|
||||
|
||||
|
||||
def _parse_networks(name: str, value: Any) -> tuple[Network, ...]:
|
||||
if not isinstance(value, list):
|
||||
raise ClientConfigError(f"client {name!r}: allowed_networks must be a list of IPs/CIDRs")
|
||||
try:
|
||||
return tuple(ipaddress.ip_network(str(v), strict=False) for v in value)
|
||||
except ValueError as exc:
|
||||
raise ClientConfigError(f"client {name!r}: invalid network in allowed_networks: {exc}") from None
|
||||
|
||||
|
||||
def clients_from_env(entries: list[str]) -> dict[str, Client]:
|
||||
"""Static clients from MCP_CALC_AUTH_TOKENS (name:token, validated in config)."""
|
||||
clients = {}
|
||||
for entry in entries:
|
||||
name, _, token = entry.partition(":")
|
||||
clients[name] = Client(name=validate_name(name), token_sha256=hash_token(token), token_hint=token[:9], source="env")
|
||||
return clients
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- file
|
||||
|
||||
|
||||
class ClientFile:
|
||||
"""Reads and atomically rewrites the clients file under an exclusive lock."""
|
||||
|
||||
def __init__(self, path: str | os.PathLike[str]) -> None:
|
||||
self.path = Path(path)
|
||||
self.lock_path = self.path.with_name(self.path.name + ".lock")
|
||||
|
||||
def fingerprint(self) -> tuple[int, int] | None:
|
||||
try:
|
||||
stat = self.path.stat()
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
return (stat.st_mtime_ns, stat.st_size)
|
||||
|
||||
def read(self) -> dict[str, Client]:
|
||||
if not self.path.exists():
|
||||
return {}
|
||||
try:
|
||||
raw = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ClientConfigError(f"{self.path}: invalid JSON ({exc})") from None
|
||||
if not isinstance(raw, dict) or not isinstance(raw.get("clients", {}), dict):
|
||||
raise ClientConfigError(f"{self.path}: expected an object with a 'clients' object")
|
||||
if raw.get("version", FILE_VERSION) != FILE_VERSION:
|
||||
raise ClientConfigError(f"{self.path}: unsupported version {raw.get('version')!r}")
|
||||
clients = {name: Client.from_dict(name, data) for name, data in raw.get("clients", {}).items()}
|
||||
hashes = [c.token_sha256 for c in clients.values() if c.token_sha256]
|
||||
if len(hashes) != len(set(hashes)):
|
||||
raise ClientConfigError(f"{self.path}: two clients share the same token")
|
||||
return clients
|
||||
|
||||
@contextmanager
|
||||
def update(self) -> Iterator[dict[str, Client]]:
|
||||
"""Lock, read, let the caller mutate the dict, write back atomically."""
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.lock_path, "a+") as lock:
|
||||
fcntl.lockf(lock, fcntl.LOCK_EX)
|
||||
try:
|
||||
clients = self.read()
|
||||
yield clients
|
||||
self._write(clients)
|
||||
finally:
|
||||
fcntl.lockf(lock, fcntl.LOCK_UN)
|
||||
|
||||
def _write(self, clients: dict[str, Client]) -> None:
|
||||
payload = {"version": FILE_VERSION, "clients": {name: c.to_dict() for name, c in sorted(clients.items())}}
|
||||
fd, tmp = tempfile.mkstemp(dir=self.path.parent, prefix=f".{self.path.name}.", suffix=".tmp")
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(payload, f, indent=2, ensure_ascii=False)
|
||||
f.write("\n")
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.chmod(tmp, 0o640)
|
||||
os.replace(tmp, self.path)
|
||||
except BaseException:
|
||||
Path(tmp).unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- registry
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Snapshot:
|
||||
"""Immutable view used for authentication."""
|
||||
|
||||
clients: dict[str, Client]
|
||||
by_hash: dict[str, Client]
|
||||
network_clients: tuple[Client, ...]
|
||||
loaded_at: float
|
||||
|
||||
@classmethod
|
||||
def build(cls, clients: dict[str, Client]) -> Snapshot:
|
||||
return cls(
|
||||
clients=clients,
|
||||
by_hash={c.token_sha256: c for c in clients.values() if c.token_sha256},
|
||||
network_clients=tuple(c for c in clients.values() if c.auth == "network"),
|
||||
loaded_at=time.time(),
|
||||
)
|
||||
|
||||
|
||||
class ClientRegistry:
|
||||
"""Current clients from the file (hot-reloaded) plus static clients from the environment.
|
||||
|
||||
The file is checked at most every ``reload_seconds`` (a cheap ``stat``). If a changed
|
||||
file is invalid, the previous state stays active and an error is logged, so a typo
|
||||
never locks everybody out, and never grants access either.
|
||||
"""
|
||||
|
||||
def __init__(self, file: ClientFile | None, static: dict[str, Client], reload_seconds: float = 5.0) -> None:
|
||||
self.file = file
|
||||
self.static = static
|
||||
self.reload_seconds = reload_seconds
|
||||
self._lock = threading.Lock()
|
||||
self._fingerprint: tuple[int, int] | None = None
|
||||
self._checked_at = 0.0
|
||||
self._snapshot = Snapshot.build(dict(static))
|
||||
if file is not None:
|
||||
self._load(initial=True)
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
"""Authentication is active when any client source is configured."""
|
||||
return self.file is not None or bool(self.static)
|
||||
|
||||
def snapshot(self) -> Snapshot:
|
||||
if self.file is not None and time.monotonic() - self._checked_at >= self.reload_seconds:
|
||||
self._load(initial=False)
|
||||
return self._snapshot
|
||||
|
||||
def reload_now(self) -> None:
|
||||
if self.file is not None:
|
||||
self._load(initial=False, force=True)
|
||||
|
||||
def _load(self, *, initial: bool, force: bool = False) -> None:
|
||||
assert self.file is not None
|
||||
with self._lock:
|
||||
self._checked_at = time.monotonic()
|
||||
fingerprint = self.file.fingerprint()
|
||||
if not force and not initial and fingerprint == self._fingerprint:
|
||||
return
|
||||
try:
|
||||
from_file = self.file.read()
|
||||
except ClientConfigError as exc:
|
||||
if initial:
|
||||
raise
|
||||
logger.error("clients file not reloaded, keeping previous state: %s", exc)
|
||||
self._fingerprint = fingerprint # do not retry until it changes again
|
||||
return
|
||||
clash = set(from_file) & set(self.static)
|
||||
if clash:
|
||||
message = f"client name(s) defined both in the clients file and MCP_CALC_AUTH_TOKENS: {sorted(clash)}"
|
||||
if initial:
|
||||
raise ClientConfigError(message)
|
||||
logger.error("clients file not reloaded, keeping previous state: %s", message)
|
||||
self._fingerprint = fingerprint
|
||||
return
|
||||
self._snapshot = Snapshot.build({**self.static, **from_file})
|
||||
self._fingerprint = fingerprint
|
||||
if not initial:
|
||||
logger.info("clients file reloaded: %d client(s)", len(from_file))
|
||||
198
src/mcp_calculator/config.py
Normal file
198
src/mcp_calculator/config.py
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
"""Runtime configuration.
|
||||
|
||||
Every setting can be given as environment variable (prefix ``MCP_CALC_``) or as
|
||||
CLI flag. CLI flags win over environment variables, which win over defaults.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
from dataclasses import dataclass, field, fields
|
||||
from typing import Literal
|
||||
|
||||
ENV_PREFIX = "MCP_CALC_"
|
||||
|
||||
Transport = Literal["stdio", "http"]
|
||||
ExecutorMode = Literal["process", "inline"]
|
||||
|
||||
|
||||
def _csv(value: str) -> list[str]:
|
||||
return [item.strip() for item in value.split(",") if item.strip()]
|
||||
|
||||
|
||||
def _bool(value: str) -> bool:
|
||||
normalized = value.strip().lower()
|
||||
if normalized in ("1", "true", "yes", "on"):
|
||||
return True
|
||||
if normalized in ("0", "false", "no", "off"):
|
||||
return False
|
||||
raise ValueError("expected true/false")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Limits:
|
||||
"""Guards applied to every computation. Shipped to worker processes."""
|
||||
|
||||
max_input_length: int = 5_000
|
||||
max_output_length: int = 20_000
|
||||
max_precision: int = 1_000
|
||||
max_matrix_size: int = 30
|
||||
max_data_points: int = 1_000_000
|
||||
max_list_items: int = 10_000
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Settings:
|
||||
transport: Transport = "stdio"
|
||||
|
||||
# HTTP transport
|
||||
host: str = "127.0.0.1"
|
||||
port: int = 8080
|
||||
path: str = "/mcp"
|
||||
allowed_hosts: list[str] = field(default_factory=list)
|
||||
allowed_origins: list[str] = field(default_factory=list)
|
||||
trusted_proxies: list[str] = field(default_factory=list)
|
||||
max_request_body_bytes: int = 1_048_576
|
||||
max_connections: int = 200
|
||||
metrics: bool = True
|
||||
|
||||
# Authentication (HTTP only; stdio never authenticates)
|
||||
clients_file: str = ""
|
||||
clients_reload_seconds: float = 5.0
|
||||
auth_tokens: list[str] = field(default_factory=list)
|
||||
audit_file: str = ""
|
||||
|
||||
# Admin API (separate port, only started when admin tokens are configured)
|
||||
admin_tokens: list[str] = field(default_factory=list)
|
||||
admin_host: str = "127.0.0.1"
|
||||
admin_port: int = 9090
|
||||
|
||||
# Execution
|
||||
executor: ExecutorMode = "process"
|
||||
workers: int = 2
|
||||
max_queue: int = 20
|
||||
max_queue_wait_seconds: float = 30.0
|
||||
client_max_concurrent: int = 10
|
||||
timeout_seconds: float = 10.0
|
||||
max_tasks_per_worker: int = 200
|
||||
memory_limit_mb: int = 512
|
||||
|
||||
limits: Limits = field(default_factory=Limits)
|
||||
|
||||
# Logging
|
||||
log_level: str = "INFO"
|
||||
log_format: Literal["text", "json"] = "text"
|
||||
|
||||
|
||||
# name -> (parser, help text). Names map to MCP_CALC_<NAME> and --<name-with-dashes>.
|
||||
_OPTIONS: dict[str, tuple[type | object, str]] = {
|
||||
"transport": (str, "stdio | http"),
|
||||
"host": (str, "HTTP bind address (use 0.0.0.0 inside containers)"),
|
||||
"port": (int, "HTTP port"),
|
||||
"path": (str, "HTTP path of the MCP endpoint"),
|
||||
"allowed_hosts": (_csv, "comma-separated Host header values; enables DNS-rebinding protection"),
|
||||
"allowed_origins": (_csv, "comma-separated Origin header values (browser clients only)"),
|
||||
"trusted_proxies": (_csv, "comma-separated IPs/CIDRs of proxies whose X-Forwarded-For is trusted (e.g. the OpenShift router)"),
|
||||
"max_request_body_bytes": (int, "maximum HTTP request body size"),
|
||||
"max_connections": (int, "concurrent HTTP connections; a new connection that would reach this number is answered with 503"),
|
||||
"metrics": (_bool, "expose Prometheus metrics at /metrics (HTTP only): true | false"),
|
||||
"clients_file": (str, "JSON file with API clients (tokens, limits, networks); enables authentication"),
|
||||
"clients_reload_seconds": (float, "how often the clients file is checked for changes"),
|
||||
"auth_tokens": (_csv, "static bearer tokens as name:token (comma-separated); simple alternative to clients_file"),
|
||||
"audit_file": (str, "audit log of client changes (default: audit.log next to clients_file)"),
|
||||
"admin_tokens": (_csv, "admin API tokens as name:token; the admin API only starts when set"),
|
||||
"admin_host": (str, "admin API bind address (127.0.0.1 = only via oc port-forward/exec)"),
|
||||
"admin_port": (int, "admin API port"),
|
||||
"executor": (str, "process (isolated worker pool, default) | inline (no isolation, dev/tests only)"),
|
||||
"workers": (int, "number of worker processes"),
|
||||
"max_queue": (int, "computations that may wait for a free worker; beyond that requests are rejected as busy"),
|
||||
"max_queue_wait_seconds": (float, "maximum time a computation waits for a free worker before it is rejected as busy"),
|
||||
"client_max_concurrent": (int, "default limit of concurrent computations per client (0 = unlimited); overridable per client"),
|
||||
"timeout_seconds": (float, "hard timeout per computation"),
|
||||
"max_tasks_per_worker": (int, "recycle a worker after N tasks (0 = never)"),
|
||||
"memory_limit_mb": (int, "extra memory a computation may allocate per worker (Linux only, 0 = off)"),
|
||||
"max_input_length": (int, "maximum characters per expression"),
|
||||
"max_output_length": (int, "results longer than this are truncated"),
|
||||
"max_precision": (int, "maximum significant digits for numeric evaluation"),
|
||||
"max_matrix_size": (int, "maximum rows/columns of a matrix"),
|
||||
"max_data_points": (int, "maximum values per statistics data set"),
|
||||
"max_list_items": (int, "maximum items in other list inputs"),
|
||||
"log_level": (str, "DEBUG | INFO | WARNING | ERROR"),
|
||||
"log_format": (str, "text | json"),
|
||||
}
|
||||
|
||||
_LIMIT_FIELDS = {f.name for f in fields(Limits)}
|
||||
|
||||
|
||||
def _build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="mcp-calculator",
|
||||
description="MCP server with exact math tools (stdio or Streamable HTTP).",
|
||||
epilog=f"Every option can also be set via environment variable {ENV_PREFIX}<OPTION>, "
|
||||
f"e.g. {ENV_PREFIX}TRANSPORT=http.",
|
||||
)
|
||||
for name, (_, help_text) in _OPTIONS.items():
|
||||
parser.add_argument(f"--{name.replace('_', '-')}", dest=name, default=None, help=help_text)
|
||||
return parser
|
||||
|
||||
|
||||
def load_settings(argv: list[str] | None = None, env: dict[str, str] | None = None) -> Settings:
|
||||
"""Build settings from defaults, environment and CLI arguments."""
|
||||
env = os.environ if env is None else env
|
||||
args = vars(_build_parser().parse_args(argv if argv is not None else []))
|
||||
|
||||
raw: dict[str, object] = {}
|
||||
for name, (convert, _) in _OPTIONS.items():
|
||||
value = args.get(name)
|
||||
if value is None:
|
||||
value = env.get(ENV_PREFIX + name.upper())
|
||||
if value is None:
|
||||
continue
|
||||
try:
|
||||
raw[name] = convert(value) # type: ignore[operator]
|
||||
except ValueError as exc:
|
||||
raise SystemExit(f"Invalid value for {name}: {value!r} ({exc})") from exc
|
||||
|
||||
limits = Limits(**{k: raw.pop(k) for k in list(raw) if k in _LIMIT_FIELDS})
|
||||
settings = Settings(limits=limits, **raw) # type: ignore[arg-type]
|
||||
_validate(settings)
|
||||
return settings
|
||||
|
||||
|
||||
def _validate(s: Settings) -> None:
|
||||
errors = []
|
||||
if s.transport not in ("stdio", "http"):
|
||||
errors.append(f"transport must be 'stdio' or 'http', got {s.transport!r}")
|
||||
if s.executor not in ("process", "inline"):
|
||||
errors.append(f"executor must be 'process' or 'inline', got {s.executor!r}")
|
||||
if s.log_format not in ("text", "json"):
|
||||
errors.append(f"log_format must be 'text' or 'json', got {s.log_format!r}")
|
||||
if not s.path.startswith("/"):
|
||||
errors.append("path must start with '/'")
|
||||
if s.workers < 1:
|
||||
errors.append("workers must be >= 1")
|
||||
if s.max_queue < 0:
|
||||
errors.append("max_queue must be >= 0")
|
||||
if s.max_queue_wait_seconds <= 0:
|
||||
errors.append("max_queue_wait_seconds must be > 0")
|
||||
if s.client_max_concurrent < 0:
|
||||
errors.append("client_max_concurrent must be >= 0")
|
||||
if s.max_connections < 1:
|
||||
errors.append("max_connections must be >= 1")
|
||||
if s.clients_reload_seconds <= 0:
|
||||
errors.append("clients_reload_seconds must be > 0")
|
||||
for name in ("auth_tokens", "admin_tokens"):
|
||||
for entry in getattr(s, name):
|
||||
label, sep, secret = entry.partition(":")
|
||||
if not sep or not label or len(secret) < 16:
|
||||
errors.append(f"{name} entries must look like name:token with a token of at least 16 characters")
|
||||
break
|
||||
if s.admin_tokens and s.admin_port == s.port:
|
||||
errors.append("admin_port must differ from port")
|
||||
if s.admin_tokens and not s.clients_file:
|
||||
errors.append("admin_tokens need clients_file (the admin API manages that file)")
|
||||
if s.timeout_seconds <= 0:
|
||||
errors.append("timeout_seconds must be > 0")
|
||||
if errors:
|
||||
raise SystemExit("Invalid configuration:\n " + "\n ".join(errors))
|
||||
272
src/mcp_calculator/executor.py
Normal file
272
src/mcp_calculator/executor.py
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
"""Runs math operations in isolated worker processes with a hard timeout.
|
||||
|
||||
Symbolic math can take unbounded time or memory (``solve`` on a nasty system,
|
||||
``9^9^9^9``). Each computation therefore runs in a pool of worker processes:
|
||||
|
||||
* a computation exceeding the timeout gets its worker killed and replaced,
|
||||
* on Linux every worker gets an address-space limit (``RLIMIT_AS``) of
|
||||
``memory_limit_mb`` on top of its size after loading the libraries, so a
|
||||
runaway computation dies with MemoryError instead of taking down the pod,
|
||||
* BLAS/OpenMP run single-threaded per worker (parallelism = number of workers),
|
||||
* workers are recycled after N tasks to bound memory growth (SymPy caches),
|
||||
* at most ``workers + max_queue`` computations are accepted at once; further
|
||||
requests are rejected immediately as "busy" instead of piling up (backpressure),
|
||||
* a computation waits at most ``max_queue_wait_seconds`` for a free worker; the
|
||||
queue is our own (a semaphore), so pebble only ever receives tasks it can start
|
||||
immediately and the timeout measures pure computation time,
|
||||
* if the caller is cancelled (client disconnected), a waiting computation leaves
|
||||
the queue and a running one is stopped by terminating its worker.
|
||||
|
||||
Tasks are addressed by name (``"algebra.evaluate"``) instead of function objects.
|
||||
That keeps SymPy, SciPy and friends out of the server process: only the workers
|
||||
import ``mcp_calculator.ops.*`` (see docs/memory.md).
|
||||
|
||||
The ``inline`` mode runs operations in a thread of the server process. It has no
|
||||
isolation and no enforceable timeout and is only meant for development and tests.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import concurrent.futures
|
||||
import importlib
|
||||
import logging
|
||||
import multiprocessing
|
||||
import os
|
||||
import resource
|
||||
import sys
|
||||
from concurrent.futures.process import BrokenProcessPool
|
||||
from typing import Any
|
||||
|
||||
from pebble import ProcessExpired, ProcessPool
|
||||
|
||||
from mcp_calculator import metrics
|
||||
from mcp_calculator.config import Limits, Settings
|
||||
from mcp_calculator.ops.context import MathError, set_limits
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Modules under mcp_calculator.ops whose functions may be called as tasks.
|
||||
TASK_MODULES = ("algebra", "calculus", "linalg", "number_theory", "stats", "units")
|
||||
|
||||
|
||||
class ComputationError(Exception):
|
||||
"""Raised to the tool layer; the message is shown to the model.
|
||||
|
||||
``kind`` classifies the failure for metrics and logs:
|
||||
error (math/input error), timeout, busy (queue full), crashed (worker died / pool failure).
|
||||
"""
|
||||
|
||||
def __init__(self, message: str, kind: str = "error") -> None:
|
||||
super().__init__(message)
|
||||
self.kind = kind
|
||||
|
||||
|
||||
def _noop() -> None:
|
||||
"""Warm-up task: makes the pool spawn its workers at start instead of on the first request."""
|
||||
|
||||
|
||||
# One BLAS/OpenMP thread per worker: parallelism comes from the worker count, and
|
||||
# per-core BLAS threads would oversubscribe the pod's CPU limit and inflate the
|
||||
# virtual address space. Must be set before NumPy is imported.
|
||||
_SINGLE_THREAD_ENV = ("OPENBLAS_NUM_THREADS", "OMP_NUM_THREADS", "MKL_NUM_THREADS")
|
||||
|
||||
|
||||
def _init_worker(limits: Limits, memory_limit_mb: int) -> None:
|
||||
set_limits(limits)
|
||||
for var in _SINGLE_THREAD_ENV:
|
||||
os.environ.setdefault(var, "1")
|
||||
# Import the math libraries once per worker instead of on the first request.
|
||||
for module in TASK_MODULES:
|
||||
importlib.import_module(f"mcp_calculator.ops.{module}")
|
||||
if memory_limit_mb > 0 and sys.platform.startswith("linux"):
|
||||
_limit_address_space(memory_limit_mb)
|
||||
|
||||
|
||||
def _limit_address_space(extra_mb: int) -> None:
|
||||
"""Allow ``extra_mb`` of address space on top of what the loaded libraries already use.
|
||||
|
||||
RLIMIT_AS counts virtual memory, which for NumPy/SciPy is far above the RSS. An
|
||||
absolute limit set before the imports could make the imports themselves fail, so
|
||||
the limit is applied afterwards, relative to the current size.
|
||||
"""
|
||||
try:
|
||||
with open("/proc/self/statm") as f:
|
||||
current = int(f.read().split()[0]) * os.sysconf("SC_PAGE_SIZE")
|
||||
limit = current + extra_mb * 1024 * 1024
|
||||
resource.setrlimit(resource.RLIMIT_AS, (limit, limit))
|
||||
except (OSError, ValueError) as exc:
|
||||
logger.warning("could not set memory limit for worker: %s", exc)
|
||||
|
||||
|
||||
def _resolve(task: str) -> Any:
|
||||
"""'algebra.evaluate' -> mcp_calculator.ops.algebra.evaluate (only public functions of TASK_MODULES)."""
|
||||
module, _, name = task.partition(".")
|
||||
if module not in TASK_MODULES or not name.isidentifier() or name.startswith("_"):
|
||||
raise ValueError(f"unknown task {task!r}")
|
||||
return getattr(importlib.import_module(f"mcp_calculator.ops.{module}"), name)
|
||||
|
||||
|
||||
def _guarded(task: str, kwargs: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Executed inside the worker. Converts every failure into a picklable result."""
|
||||
try:
|
||||
return {"ok": True, "value": _resolve(task)(**kwargs)}
|
||||
except MathError as exc:
|
||||
return {"ok": False, "error": str(exc)}
|
||||
except MemoryError:
|
||||
return {"ok": False, "error": "computation exceeded the memory limit"}
|
||||
except RecursionError:
|
||||
return {"ok": False, "error": "computation too deeply nested (recursion limit)"}
|
||||
except ZeroDivisionError:
|
||||
return {"ok": False, "error": "division by zero"}
|
||||
except (ValueError, TypeError, NotImplementedError, ArithmeticError, KeyError, IndexError, AttributeError) as exc:
|
||||
# SymPy/SciPy signal unsupported input with these; the message is useful to the model.
|
||||
return {"ok": False, "error": f"{type(exc).__name__}: {exc}"[:2000]}
|
||||
|
||||
|
||||
class Executor:
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
self._settings = settings
|
||||
self._pool: ProcessPool | None = None
|
||||
self._warmup: concurrent.futures.Future | None = None
|
||||
self._inflight = 0 # running + waiting computations; only touched from the event loop
|
||||
self._slots: asyncio.Semaphore | None = None # free workers; bound to the running event loop
|
||||
self._slots_loop: asyncio.AbstractEventLoop | None = None
|
||||
set_limits(settings.limits) # for inline mode and parsing in the server process
|
||||
metrics.WORKERS.set(settings.workers)
|
||||
metrics.CAPACITY.set(self.capacity)
|
||||
|
||||
@property
|
||||
def settings(self) -> Settings:
|
||||
return self._settings
|
||||
|
||||
@property
|
||||
def mode(self) -> str:
|
||||
return self._settings.executor
|
||||
|
||||
@property
|
||||
def capacity(self) -> int:
|
||||
"""Maximum number of computations accepted at the same time."""
|
||||
return self._settings.workers + self._settings.max_queue
|
||||
|
||||
@property
|
||||
def inflight(self) -> int:
|
||||
return self._inflight
|
||||
|
||||
def start(self) -> None:
|
||||
if self.mode != "process" or self._pool is not None:
|
||||
return
|
||||
self._pool = ProcessPool(
|
||||
max_workers=self._settings.workers,
|
||||
max_tasks=self._settings.max_tasks_per_worker,
|
||||
initializer=_init_worker,
|
||||
initargs=(self._settings.limits, self._settings.memory_limit_mb),
|
||||
# 'spawn' is safe with the asyncio/uvicorn threads of the parent process.
|
||||
context=multiprocessing.get_context("spawn"),
|
||||
)
|
||||
# Pebble spawns its workers lazily on the first task; do that now, not on the first request.
|
||||
# Readiness waits for this task, so no traffic arrives before the workers can compute.
|
||||
self._warmup = self._pool.schedule(_noop)
|
||||
logger.info(
|
||||
"worker pool started (%d workers, queue %d, timeout %.1fs)",
|
||||
self._settings.workers,
|
||||
self._settings.max_queue,
|
||||
self._settings.timeout_seconds,
|
||||
)
|
||||
|
||||
def stop(self, graceful: bool = True) -> None:
|
||||
if self._pool is None:
|
||||
return
|
||||
# close() lets running computations finish (bounded by their timeout); stop() kills them.
|
||||
if graceful and self._pool.active:
|
||||
self._pool.close()
|
||||
try:
|
||||
self._pool.join(timeout=self._settings.timeout_seconds + 1)
|
||||
except TimeoutError:
|
||||
graceful = False
|
||||
if not graceful or self._pool.active:
|
||||
self._pool.stop()
|
||||
self._pool.join(timeout=5)
|
||||
self._pool = None
|
||||
|
||||
@property
|
||||
def healthy(self) -> bool:
|
||||
"""Ready to compute: pool running and workers warmed up (libraries loaded)."""
|
||||
if self.mode == "inline":
|
||||
return True
|
||||
return self._pool is not None and self._pool.active and self._warmup is not None and self._warmup.done()
|
||||
|
||||
def _schedule(self, task: str, kwargs: dict[str, Any], timeout: float) -> concurrent.futures.Future:
|
||||
if self._pool is None or not self._pool.active:
|
||||
self.stop(graceful=False)
|
||||
self.start()
|
||||
assert self._pool is not None
|
||||
try:
|
||||
return self._pool.schedule(_guarded, args=(task, kwargs), timeout=timeout)
|
||||
except RuntimeError as exc: # pebble: "Unexpected error within the Pool"
|
||||
raise BrokenProcessPool(str(exc)) from exc
|
||||
|
||||
async def run(self, task: str, **kwargs: Any) -> dict[str, Any]:
|
||||
"""Run ``task`` (e.g. ``"algebra.evaluate"``) with keyword arguments and return its result dict."""
|
||||
if self._inflight >= self.capacity:
|
||||
raise ComputationError(
|
||||
f"server busy: {self._inflight} computations are running or queued (limit {self.capacity}). Retry in a few seconds.",
|
||||
kind="busy",
|
||||
)
|
||||
self._inflight += 1
|
||||
metrics.INFLIGHT.set(self._inflight)
|
||||
try:
|
||||
return await self._run(task, kwargs)
|
||||
finally:
|
||||
self._inflight -= 1
|
||||
metrics.INFLIGHT.set(self._inflight)
|
||||
|
||||
def _worker_slots(self) -> asyncio.Semaphore:
|
||||
loop = asyncio.get_running_loop()
|
||||
if self._slots is None or self._slots_loop is not loop:
|
||||
self._slots = asyncio.Semaphore(self._settings.workers)
|
||||
self._slots_loop = loop
|
||||
return self._slots
|
||||
|
||||
async def _run(self, task: str, kwargs: dict[str, Any]) -> dict[str, Any]:
|
||||
slots = self._worker_slots()
|
||||
wait = self._settings.max_queue_wait_seconds
|
||||
try:
|
||||
async with asyncio.timeout(wait):
|
||||
await slots.acquire()
|
||||
except TimeoutError:
|
||||
raise ComputationError(
|
||||
f"server busy: no worker became free within {wait:g}s. Retry in a few seconds.",
|
||||
kind="busy",
|
||||
) from None
|
||||
try:
|
||||
return await self._compute(task, kwargs)
|
||||
finally:
|
||||
slots.release()
|
||||
|
||||
async def _compute(self, task: str, kwargs: dict[str, Any]) -> dict[str, Any]:
|
||||
timeout = self._settings.timeout_seconds
|
||||
try:
|
||||
if self.mode == "inline":
|
||||
outcome = await asyncio.wait_for(asyncio.to_thread(_guarded, task, kwargs), timeout)
|
||||
else:
|
||||
outcome = await asyncio.wrap_future(self._schedule(task, kwargs, timeout))
|
||||
except TimeoutError: # asyncio and concurrent.futures timeouts are aliases since 3.11
|
||||
raise ComputationError(
|
||||
f"computation aborted after {timeout:g}s (timeout). Simplify the problem, "
|
||||
"use numeric methods, or split it into smaller steps.",
|
||||
kind="timeout",
|
||||
) from None
|
||||
except ProcessExpired as exc:
|
||||
raise ComputationError(f"computation aborted: worker process died ({exc}), probably out of memory", kind="crashed") from None
|
||||
except BrokenProcessPool:
|
||||
logger.error("worker pool broke; restarting it")
|
||||
metrics.POOL_RESTARTS.inc()
|
||||
self.stop(graceful=False)
|
||||
self.start()
|
||||
raise ComputationError("internal worker pool error; the pool was restarted, please retry", kind="crashed") from None
|
||||
|
||||
if not outcome["ok"]:
|
||||
raise ComputationError(outcome["error"])
|
||||
return outcome["value"]
|
||||
234
src/mcp_calculator/http_app.py
Normal file
234
src/mcp_calculator/http_app.py
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
"""Streamable HTTP transport: ASGI app, authentication, health, metrics and the admin API.
|
||||
|
||||
Endpoints (main port):
|
||||
POST {path} MCP Streamable HTTP endpoint (stateless, JSON responses)
|
||||
GET /healthz liveness - process is up
|
||||
GET /readyz readiness - workers are warm
|
||||
GET /metrics Prometheus metrics (unless MCP_CALC_METRICS=false)
|
||||
|
||||
Admin API (own port, only when MCP_CALC_ADMIN_TOKENS is set): see ``admin.py``.
|
||||
|
||||
Middleware order for a request: uvicorn (connection limit, proxy headers)
|
||||
→ AuthMiddleware (identity or 401/403) → CancelOnDisconnect → MCP SDK app.
|
||||
|
||||
The server is stateless (no MCP sessions), so any number of replicas can run
|
||||
behind a load balancer without sticky sessions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import collections
|
||||
import contextlib
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import anyio
|
||||
import uvicorn
|
||||
from mcp.server.mcpserver import MCPServer
|
||||
from mcp.server.transport_security import TransportSecuritySettings
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse, Response
|
||||
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
||||
|
||||
from mcp_calculator import __version__, metrics
|
||||
from mcp_calculator.admin import AuditLog, ClientAdmin, build_admin_app
|
||||
from mcp_calculator.auth import AnonymousMiddleware, AuthMiddleware
|
||||
from mcp_calculator.clients import ClientConfigError, ClientFile, ClientRegistry, clients_from_env
|
||||
from mcp_calculator.config import Settings
|
||||
from mcp_calculator.executor import Executor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_LOCAL_HOSTS = ("127.0.0.1", "localhost", "::1")
|
||||
|
||||
|
||||
class CancelOnDisconnect:
|
||||
"""Cancels the MCP request when the client disconnects before the response is sent.
|
||||
|
||||
Without this, a computation keeps running (and occupying a worker) until its timeout
|
||||
even though nobody waits for the result any more. The request body is read first
|
||||
(bounded by ``max_body``); afterwards a watcher waits for ``http.disconnect`` while
|
||||
the app runs, and cancels the app if the response is not complete yet. Cancellation
|
||||
propagates to the executor, which drops a queued computation or terminates the
|
||||
worker of a running one.
|
||||
"""
|
||||
|
||||
def __init__(self, app: ASGIApp, max_body: int) -> None:
|
||||
self.app = app
|
||||
self.max_body = max_body
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if scope["type"] != "http" or scope["method"] != "POST":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
buffered: collections.deque[Message] = collections.deque()
|
||||
size = 0
|
||||
while True:
|
||||
message = await receive()
|
||||
if message["type"] == "http.disconnect":
|
||||
return
|
||||
size += len(message.get("body", b""))
|
||||
if size > self.max_body:
|
||||
await send({"type": "http.response.start", "status": 413, "headers": [(b"content-type", b"application/json")]})
|
||||
await send({"type": "http.response.body", "body": b'{"error": "request body too large"}'})
|
||||
return
|
||||
buffered.append(message)
|
||||
if not message.get("more_body", False):
|
||||
break
|
||||
|
||||
disconnected = anyio.Event()
|
||||
response_finishing = False
|
||||
|
||||
async def replay_receive() -> Message:
|
||||
if buffered:
|
||||
return buffered.popleft()
|
||||
await disconnected.wait()
|
||||
return {"type": "http.disconnect"}
|
||||
|
||||
async def tracking_send(message: Message) -> None:
|
||||
nonlocal response_finishing
|
||||
if message["type"] == "http.response.body" and not message.get("more_body", False):
|
||||
response_finishing = True # set before sending: a disconnect after this point is normal
|
||||
await send(message)
|
||||
|
||||
async with anyio.create_task_group() as tg:
|
||||
|
||||
async def watch_disconnect() -> None:
|
||||
message = await receive() # the body is consumed; this only returns on disconnect
|
||||
if message["type"] == "http.disconnect":
|
||||
disconnected.set()
|
||||
if not response_finishing:
|
||||
logger.info("client disconnected before the response; cancelling the request")
|
||||
tg.cancel_scope.cancel()
|
||||
|
||||
tg.start_soon(watch_disconnect)
|
||||
await self.app(scope, replay_receive, tracking_send)
|
||||
tg.cancel_scope.cancel() # response sent; stop the watcher
|
||||
|
||||
|
||||
def build_registry(settings: Settings) -> ClientRegistry:
|
||||
try:
|
||||
file = ClientFile(settings.clients_file) if settings.clients_file else None
|
||||
return ClientRegistry(file, clients_from_env(settings.auth_tokens), settings.clients_reload_seconds)
|
||||
except ClientConfigError as exc:
|
||||
raise SystemExit(f"Invalid client configuration: {exc}") from None
|
||||
|
||||
|
||||
def _transport_security(settings: Settings) -> TransportSecuritySettings | None:
|
||||
if settings.allowed_hosts:
|
||||
return TransportSecuritySettings(
|
||||
enable_dns_rebinding_protection=True,
|
||||
allowed_hosts=settings.allowed_hosts,
|
||||
allowed_origins=settings.allowed_origins,
|
||||
)
|
||||
if settings.host in _LOCAL_HOSTS:
|
||||
return None # SDK default: DNS-rebinding protection for localhost
|
||||
# Bound to all interfaces without a host allow-list (typical behind an ingress/route).
|
||||
return TransportSecuritySettings(enable_dns_rebinding_protection=False)
|
||||
|
||||
|
||||
def build_app(mcp: MCPServer, settings: Settings, executor: Executor, registry: ClientRegistry) -> ASGIApp:
|
||||
@mcp.custom_route("/healthz", methods=["GET"], include_in_schema=False)
|
||||
async def healthz(_: Request) -> JSONResponse:
|
||||
return JSONResponse({"status": "ok", "version": __version__})
|
||||
|
||||
@mcp.custom_route("/readyz", methods=["GET"], include_in_schema=False)
|
||||
async def readyz(_: Request) -> JSONResponse:
|
||||
ready = executor.healthy
|
||||
return JSONResponse({"status": "ready" if ready else "not ready", "executor": executor.mode}, status_code=200 if ready else 503)
|
||||
|
||||
if settings.metrics:
|
||||
|
||||
@mcp.custom_route("/metrics", methods=["GET"], include_in_schema=False)
|
||||
async def metrics_endpoint(_: Request) -> Response:
|
||||
if registry.enabled:
|
||||
counts = collections.Counter(c.status() for c in registry.snapshot().clients.values())
|
||||
for status in ("active", "disabled", "expired"):
|
||||
metrics.CLIENTS.labels(status).set(counts.get(status, 0))
|
||||
body, content_type = metrics.render()
|
||||
return Response(body, media_type=content_type)
|
||||
|
||||
app: ASGIApp = mcp.streamable_http_app(
|
||||
streamable_http_path=settings.path,
|
||||
stateless_http=True,
|
||||
json_response=True,
|
||||
max_request_body_size=settings.max_request_body_bytes,
|
||||
transport_security=_transport_security(settings),
|
||||
host=settings.host,
|
||||
)
|
||||
app = CancelOnDisconnect(app, settings.max_request_body_bytes)
|
||||
return AuthMiddleware(app, registry) if registry.enabled else AnonymousMiddleware(app)
|
||||
|
||||
|
||||
class _MainServer(uvicorn.Server):
|
||||
"""Stops the admin server and the worker pool as part of uvicorn's shutdown.
|
||||
|
||||
On SIGTERM/SIGINT uvicorn finishes in-flight requests and then re-raises the
|
||||
signal, which ends the process immediately; code after ``serve()`` never runs.
|
||||
Cleanup therefore happens here, after the last request and before the re-raise.
|
||||
"""
|
||||
|
||||
def __init__(self, config: uvicorn.Config, executor: Executor, admin_server: uvicorn.Server | None) -> None:
|
||||
super().__init__(config)
|
||||
self._executor = executor
|
||||
self._admin_server = admin_server
|
||||
|
||||
async def shutdown(self, sockets: list | None = None) -> None:
|
||||
if self._admin_server is not None:
|
||||
self._admin_server.should_exit = True
|
||||
await super().shutdown(sockets)
|
||||
await asyncio.to_thread(self._executor.stop)
|
||||
logger.info("worker pool stopped")
|
||||
|
||||
|
||||
class _AdminServer(uvicorn.Server):
|
||||
"""Second uvicorn instance in the same process; signals are handled by the main server only."""
|
||||
|
||||
def capture_signals(self) -> contextlib.AbstractContextManager[None]: # type: ignore[override]
|
||||
return contextlib.nullcontext()
|
||||
|
||||
|
||||
def _uvicorn_config(app: ASGIApp, host: str, port: int, settings: Settings, **extra: object) -> uvicorn.Config:
|
||||
return uvicorn.Config(
|
||||
app,
|
||||
host=host,
|
||||
port=port,
|
||||
log_config=None, # use our logging setup
|
||||
server_header=False,
|
||||
proxy_headers=bool(settings.trusted_proxies),
|
||||
forwarded_allow_ips=settings.trusted_proxies,
|
||||
**extra,
|
||||
)
|
||||
|
||||
|
||||
async def serve_http(mcp: MCPServer, settings: Settings, executor: Executor) -> None:
|
||||
registry = build_registry(settings)
|
||||
app = build_app(mcp, settings, executor, registry)
|
||||
if registry.enabled:
|
||||
logger.info("authentication enabled: %d client(s)", len(registry.snapshot().clients))
|
||||
elif settings.host not in _LOCAL_HOSTS:
|
||||
logger.warning("HTTP transport is reachable from the network without authentication (set MCP_CALC_CLIENTS_FILE)")
|
||||
|
||||
admin_server = None
|
||||
if settings.admin_tokens:
|
||||
audit_path = settings.audit_file or Path(settings.clients_file).with_name("audit.log")
|
||||
admin = ClientAdmin(ClientFile(settings.clients_file), AuditLog(audit_path), on_change=registry.reload_now)
|
||||
admin_app = build_admin_app(admin, settings.admin_tokens)
|
||||
admin_server = _AdminServer(_uvicorn_config(admin_app, settings.admin_host, settings.admin_port, settings))
|
||||
logger.info("admin API on http://%s:%d/admin/clients", settings.admin_host, settings.admin_port)
|
||||
|
||||
main_config = _uvicorn_config(
|
||||
app,
|
||||
settings.host,
|
||||
settings.port,
|
||||
settings,
|
||||
limit_concurrency=settings.max_connections,
|
||||
timeout_graceful_shutdown=int(settings.timeout_seconds + settings.max_queue_wait_seconds) + 5,
|
||||
)
|
||||
logger.info("serving MCP over HTTP on http://%s:%d%s", settings.host, settings.port, settings.path)
|
||||
async with anyio.create_task_group() as tg:
|
||||
if admin_server is not None:
|
||||
tg.start_soon(admin_server.serve)
|
||||
await _MainServer(main_config, executor, admin_server).serve()
|
||||
36
src/mcp_calculator/logging_setup.py
Normal file
36
src/mcp_calculator/logging_setup.py
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
"""Logging to stderr (stdout is the protocol channel in stdio mode)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from datetime import UTC, datetime
|
||||
|
||||
|
||||
class JsonFormatter(logging.Formatter):
|
||||
"""One JSON object per line, for log aggregation."""
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
entry = {
|
||||
"time": datetime.fromtimestamp(record.created, tz=UTC).isoformat(timespec="milliseconds"),
|
||||
"level": record.levelname,
|
||||
"logger": record.name,
|
||||
"message": record.getMessage(),
|
||||
}
|
||||
if record.exc_info:
|
||||
entry["exception"] = self.formatException(record.exc_info)
|
||||
return json.dumps(entry, ensure_ascii=False)
|
||||
|
||||
|
||||
def setup_logging(level: str = "INFO", fmt: str = "text") -> None:
|
||||
handler = logging.StreamHandler(sys.stderr)
|
||||
if fmt == "json":
|
||||
handler.setFormatter(JsonFormatter())
|
||||
else:
|
||||
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)-7s %(name)s: %(message)s"))
|
||||
logging.basicConfig(level=level.upper(), handlers=[handler], force=True)
|
||||
# The SDK logs every request at INFO; keep it quieter unless debugging.
|
||||
if level.upper() != "DEBUG":
|
||||
for noisy in ("mcp.server.lowlevel.server", "mcp.server.streamable_http", "mcp.server.streamable_http_manager"):
|
||||
logging.getLogger(noisy).setLevel(logging.WARNING)
|
||||
62
src/mcp_calculator/metrics.py
Normal file
62
src/mcp_calculator/metrics.py
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
"""Prometheus metrics of the server process (exposed at ``/metrics`` in HTTP mode).
|
||||
|
||||
Uses a dedicated registry instead of the global default one, so creating several
|
||||
servers (tests) never registers a metric twice. Process metrics (``process_*``:
|
||||
CPU, RSS, open files) cover the server process only; the workers are separate
|
||||
processes and are not included.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from prometheus_client import (
|
||||
CONTENT_TYPE_LATEST,
|
||||
CollectorRegistry,
|
||||
Counter,
|
||||
Gauge,
|
||||
Histogram,
|
||||
Info,
|
||||
PlatformCollector,
|
||||
ProcessCollector,
|
||||
generate_latest,
|
||||
)
|
||||
|
||||
from mcp_calculator import __version__
|
||||
|
||||
REGISTRY = CollectorRegistry()
|
||||
ProcessCollector(registry=REGISTRY)
|
||||
PlatformCollector(registry=REGISTRY)
|
||||
|
||||
BUILD_INFO = Info("mcp_calc_build", "Version of mcp-calculator", registry=REGISTRY)
|
||||
BUILD_INFO.info({"version": __version__})
|
||||
|
||||
TOOL_CALLS = Counter(
|
||||
"mcp_calc_tool_calls",
|
||||
"Tool calls by client and outcome: ok | error (math/input error) | timeout | busy (capacity, queue or client limit) "
|
||||
"| cancelled (client disconnected) | crashed (worker died)",
|
||||
["tool", "client", "status"],
|
||||
registry=REGISTRY,
|
||||
)
|
||||
TOOL_DURATION = Histogram(
|
||||
"mcp_calc_tool_duration_seconds",
|
||||
"Tool call duration including queueing, all outcomes",
|
||||
["tool"],
|
||||
buckets=(0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60),
|
||||
registry=REGISTRY,
|
||||
)
|
||||
INFLIGHT = Gauge("mcp_calc_inflight_computations", "Computations currently running or waiting for a worker", registry=REGISTRY)
|
||||
CAPACITY = Gauge("mcp_calc_capacity", "Maximum concurrent computations (workers + queue); beyond this requests are rejected", registry=REGISTRY)
|
||||
WORKERS = Gauge("mcp_calc_workers", "Configured number of worker processes", registry=REGISTRY)
|
||||
POOL_RESTARTS = Counter("mcp_calc_pool_restarts", "Worker pool restarts after an internal pool failure", registry=REGISTRY)
|
||||
CLIENT_INFLIGHT = Gauge("mcp_calc_client_inflight_computations", "Computations currently running or waiting, per client", ["client"], registry=REGISTRY)
|
||||
AUTH_FAILURES = Counter(
|
||||
"mcp_calc_auth_failures",
|
||||
"Rejected requests: missing_credentials | invalid_token | disabled | expired | network_denied",
|
||||
["reason"],
|
||||
registry=REGISTRY,
|
||||
)
|
||||
CLIENTS = Gauge("mcp_calc_clients", "Configured API clients by status (active | disabled | expired)", ["status"], registry=REGISTRY)
|
||||
|
||||
|
||||
def render() -> tuple[bytes, str]:
|
||||
"""Metrics in Prometheus text format and the matching content type."""
|
||||
return generate_latest(REGISTRY), CONTENT_TYPE_LATEST
|
||||
7
src/mcp_calculator/ops/__init__.py
Normal file
7
src/mcp_calculator/ops/__init__.py
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
"""Pure math operations.
|
||||
|
||||
Everything in this package is synchronous, side-effect free and returns plain
|
||||
JSON-serialisable dicts. The functions run inside isolated worker processes
|
||||
(see ``mcp_calculator.executor``), so they may be slow or even hang without
|
||||
affecting the server.
|
||||
"""
|
||||
268
src/mcp_calculator/ops/algebra.py
Normal file
268
src/mcp_calculator/ops/algebra.py
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
"""Evaluation, algebraic transformations and equation solving."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import sympy as sp
|
||||
|
||||
from mcp_calculator.ops.common import MathError, check_list, check_precision, clip, fmt, numeric, symbols_of, to_str
|
||||
from mcp_calculator.ops.parsing import apply_assumptions, as_equation, parse, parse_symbol, parse_symbols
|
||||
from mcp_calculator.ops.spec import Assumption, Domain, TransformOperation
|
||||
|
||||
# --------------------------------------------------------------------------- evaluate
|
||||
|
||||
|
||||
def evaluate(
|
||||
expression: str,
|
||||
precision: int = 15,
|
||||
variables: dict[str, str | float | int] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
check_precision(precision)
|
||||
expr = parse(expression)
|
||||
if variables:
|
||||
check_list(list(variables), "variables")
|
||||
subs = {parse_symbol(name): parse(str(value)) for name, value in variables.items()}
|
||||
expr = expr.subs(subs)
|
||||
|
||||
out: dict[str, Any] = {"exact": to_str(expr), "latex": clip(sp.latex(expr))}
|
||||
approx = numeric(expr, precision)
|
||||
if approx is not None:
|
||||
out["numeric"] = approx
|
||||
elif expr.free_symbols:
|
||||
out["numeric"] = clip(str(sp.N(expr, precision)))
|
||||
out["free_symbols"] = symbols_of(expr)
|
||||
out["note"] = "Result still contains variables; pass values via 'variables' for a number."
|
||||
if isinstance(expr, sp.Integer) and abs(expr) >= 10**precision:
|
||||
out["digits"] = len(str(abs(expr)))
|
||||
return out
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- transform
|
||||
|
||||
|
||||
def transform(
|
||||
expression: str,
|
||||
operation: TransformOperation = "simplify",
|
||||
variable: str | None = None,
|
||||
substitutions: dict[str, str | float | int] | None = None,
|
||||
assume: Assumption = "none",
|
||||
) -> dict[str, Any]:
|
||||
expr = parse(expression, exact=operation != "nsimplify")
|
||||
expr, mapping = apply_assumptions(expr, assume)
|
||||
var = None
|
||||
if variable:
|
||||
var = parse_symbol(variable)
|
||||
var = mapping.get(var, var)
|
||||
|
||||
match operation:
|
||||
case "simplify":
|
||||
result = sp.simplify(expr)
|
||||
case "expand":
|
||||
result = sp.expand(expr)
|
||||
case "factor":
|
||||
result = sp.factor(expr, gaussian=False) if var is None else sp.factor(expr, var)
|
||||
case "collect":
|
||||
if var is None:
|
||||
raise MathError("'collect' needs 'variable'")
|
||||
result = sp.collect(sp.expand(expr), var)
|
||||
case "cancel":
|
||||
result = sp.cancel(expr)
|
||||
case "apart":
|
||||
result = sp.apart(expr, var) if var is not None else sp.apart(expr)
|
||||
case "together":
|
||||
result = sp.together(expr)
|
||||
case "trigsimp":
|
||||
result = sp.trigsimp(expr)
|
||||
case "expand_trig":
|
||||
result = sp.expand_trig(expr)
|
||||
case "powsimp":
|
||||
result = sp.powsimp(expr, force=assume == "positive")
|
||||
case "expand_log":
|
||||
result = sp.expand_log(expr, force=assume == "positive")
|
||||
case "logcombine":
|
||||
result = sp.logcombine(expr, force=assume == "positive")
|
||||
case "radsimp" | "rationalize_denominator":
|
||||
result = sp.radsimp(expr)
|
||||
case "nsimplify":
|
||||
result = sp.nsimplify(expr, [sp.pi, sp.E, sp.sqrt(2), sp.sqrt(3), sp.sqrt(5)])
|
||||
case "substitute":
|
||||
if not substitutions:
|
||||
raise MathError("'substitute' needs 'substitutions', e.g. {\"x\": \"2*t\"}")
|
||||
subs = {}
|
||||
for name, value in substitutions.items():
|
||||
sym = parse_symbol(name)
|
||||
subs[mapping.get(sym, sym)] = parse(str(value))
|
||||
result = sp.simplify(expr.subs(subs))
|
||||
case _:
|
||||
raise MathError(f"unknown operation {operation!r}")
|
||||
|
||||
out = fmt(result)
|
||||
out["operation"] = operation
|
||||
out["equivalent_to_input"] = _equivalent(expr, result) if operation not in ("substitute", "nsimplify") else None
|
||||
return {k: v for k, v in out.items() if v is not None}
|
||||
|
||||
|
||||
def _equivalent(a: sp.Basic, b: sp.Basic) -> bool | None:
|
||||
if isinstance(a, sp.Rel) or isinstance(b, sp.Rel):
|
||||
return None
|
||||
try:
|
||||
return bool(sp.simplify(a - b) == 0) or None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- solve
|
||||
|
||||
|
||||
def solve(
|
||||
equations: list[str],
|
||||
variables: list[str] | None = None,
|
||||
domain: Domain = "complex",
|
||||
numeric_only: bool = False,
|
||||
initial_guess: list[float] | None = None,
|
||||
precision: int = 15,
|
||||
) -> dict[str, Any]:
|
||||
check_precision(precision)
|
||||
check_list(equations, "equations", max_items=50)
|
||||
parsed = [parse(e) for e in equations]
|
||||
|
||||
syms = parse_symbols(variables) if variables else sorted(set().union(*(p.free_symbols for p in parsed)), key=lambda s: s.name)
|
||||
if not syms:
|
||||
raise MathError("no variable to solve for")
|
||||
|
||||
if domain != "complex":
|
||||
all_syms = set(syms).union(*(p.free_symbols for p in parsed))
|
||||
mapping = {s: sp.Symbol(s.name, **{domain: True}) for s in all_syms if isinstance(s, sp.Symbol)}
|
||||
parsed = [p.xreplace(mapping) for p in parsed]
|
||||
syms = [mapping[s] for s in syms]
|
||||
|
||||
# With assumptions an equation may already evaluate to True/False (x^2 = -1 over the reals).
|
||||
if any(p is sp.false for p in parsed):
|
||||
return {"variables": [s.name for s in syms], "domain": domain, "solutions": [], "count": 0, "note": f"No solution in the {domain} domain."}
|
||||
parsed = [p for p in parsed if p is not sp.true]
|
||||
if not parsed:
|
||||
return {"variables": [s.name for s in syms], "domain": domain, "note": f"Every value in the {domain} domain is a solution."}
|
||||
|
||||
if any(isinstance(p, sp.Rel) and not isinstance(p, (sp.Equality, sp.Unequality)) for p in parsed):
|
||||
return _solve_inequalities(parsed, syms)
|
||||
|
||||
eqs = [as_equation(p) for p in parsed]
|
||||
if numeric_only:
|
||||
return _solve_numeric(eqs, syms, initial_guess, precision)
|
||||
|
||||
if len(eqs) == 1 and len(syms) == 1:
|
||||
return _solve_single(eqs[0], syms[0], domain, precision)
|
||||
return _solve_system(eqs, syms, precision)
|
||||
|
||||
|
||||
def _solve_single(eq: sp.Basic, var: sp.Symbol, domain: Domain, precision: int) -> dict[str, Any]:
|
||||
sympy_domain = {"complex": sp.S.Complexes, "real": sp.S.Reals, "positive": sp.Interval.open(0, sp.oo), "integer": sp.S.Integers}[domain]
|
||||
solution_set = sp.solveset(eq, var, domain=sympy_domain)
|
||||
name = var.name
|
||||
out: dict[str, Any] = {"variables": [name], "domain": domain}
|
||||
|
||||
if isinstance(solution_set, sp.FiniteSet):
|
||||
solutions = sorted(solution_set, key=sp.default_sort_key)
|
||||
out["solutions"] = [{name: to_str(s)} for s in solutions]
|
||||
out["numeric"] = [{name: numeric(s, precision) or to_str(s)} for s in solutions]
|
||||
out["latex"] = [sp.latex(s) for s in solutions]
|
||||
out["count"] = len(solutions)
|
||||
return out
|
||||
|
||||
out["solution_set"] = to_str(solution_set)
|
||||
out["latex"] = sp.latex(solution_set)
|
||||
if solution_set is sp.S.EmptySet:
|
||||
out["solutions"] = []
|
||||
out["count"] = 0
|
||||
return out
|
||||
# Infinite or implicit solution sets: add principal solutions as a readable fallback.
|
||||
try:
|
||||
principal = sp.solve(eq, var)
|
||||
if principal:
|
||||
out["principal_solutions"] = [{name: to_str(s)} for s in principal]
|
||||
out["numeric"] = [{name: numeric(s, precision) or to_str(s)} for s in principal]
|
||||
except (NotImplementedError, ValueError, TypeError):
|
||||
pass
|
||||
if isinstance(solution_set, sp.ConditionSet):
|
||||
out["note"] = "No closed form found. Retry with numeric_only=true and an initial_guess."
|
||||
else:
|
||||
out["note"] = "Infinitely many solutions; 'solution_set' describes all of them (n is an integer)."
|
||||
return out
|
||||
|
||||
|
||||
def _solve_system(eqs: list[sp.Basic], syms: list[sp.Symbol], precision: int) -> dict[str, Any]:
|
||||
try:
|
||||
solutions = sp.solve(eqs, syms, dict=True)
|
||||
except NotImplementedError as exc:
|
||||
raise MathError(f"no symbolic solution method available ({exc}); retry with numeric_only=true") from None
|
||||
out: dict[str, Any] = {"variables": [s.name for s in syms]}
|
||||
out["solutions"] = [{str(k): to_str(v) for k, v in sol.items()} for sol in solutions]
|
||||
out["numeric"] = [{str(k): numeric(v, precision) or to_str(v) for k, v in sol.items()} for sol in solutions]
|
||||
out["count"] = len(solutions)
|
||||
free = sorted({str(s) for sol in solutions for v in sol.values() for s in v.free_symbols} & {s.name for s in syms})
|
||||
if free:
|
||||
out["note"] = f"Infinitely many solutions, parametrised by: {', '.join(free)}"
|
||||
elif not solutions:
|
||||
out["note"] = "The system has no solution."
|
||||
return out
|
||||
|
||||
|
||||
def _solve_numeric(eqs: list[sp.Basic], syms: list[sp.Symbol], guess: list[float] | None, precision: int) -> dict[str, Any]:
|
||||
exprs = [e.lhs - e.rhs for e in eqs]
|
||||
names = [s.name for s in syms]
|
||||
|
||||
# Univariate polynomial: all complex roots at once.
|
||||
if len(exprs) == 1 and len(syms) == 1 and guess is None:
|
||||
poly = sp.Poly(exprs[0], syms[0]) if exprs[0].is_polynomial(syms[0]) else None
|
||||
if poly is not None and poly.degree() > 0:
|
||||
roots = poly.nroots(n=precision)
|
||||
return {
|
||||
"variables": names,
|
||||
"method": "polynomial roots",
|
||||
"solutions": [{names[0]: str(r)} for r in roots],
|
||||
"count": len(roots),
|
||||
}
|
||||
|
||||
if len(exprs) != len(syms):
|
||||
raise MathError(f"numeric solving needs as many equations as variables ({len(exprs)} vs {len(syms)})")
|
||||
start = guess or [1.0] * len(syms)
|
||||
if len(start) != len(syms):
|
||||
raise MathError(f"initial_guess needs {len(syms)} value(s), one per variable in order {names}")
|
||||
try:
|
||||
sol = sp.nsolve(exprs if len(exprs) > 1 else exprs[0], syms if len(syms) > 1 else syms[0], start if len(start) > 1 else start[0], prec=precision)
|
||||
except (ValueError, ZeroDivisionError) as exc:
|
||||
raise MathError(f"numeric solver did not converge from {start}: {exc}. Try another initial_guess.") from None
|
||||
values = list(sol) if isinstance(sol, sp.MatrixBase) else [sol]
|
||||
return {
|
||||
"variables": names,
|
||||
"method": "Newton iteration (nsolve)",
|
||||
"initial_guess": start,
|
||||
"solutions": [{n: str(sp.N(v, precision)) for n, v in zip(names, values, strict=True)}],
|
||||
"count": 1,
|
||||
"note": "Numeric methods return one root near the initial guess; other roots may exist.",
|
||||
}
|
||||
|
||||
|
||||
def _solve_inequalities(parsed: list[sp.Basic], syms: list[sp.Symbol]) -> dict[str, Any]:
|
||||
if len(syms) != 1:
|
||||
raise MathError("inequalities can only be solved for a single variable")
|
||||
var = syms[0]
|
||||
if not var.is_real:
|
||||
var_real = sp.Symbol(var.name, real=True)
|
||||
parsed = [p.xreplace({var: var_real}) for p in parsed]
|
||||
var = var_real
|
||||
result = sp.reduce_inequalities([as_equation(p) for p in parsed], [var])
|
||||
if result is sp.true:
|
||||
as_set = sp.S.Reals
|
||||
elif result is sp.false:
|
||||
as_set = sp.S.EmptySet
|
||||
else:
|
||||
as_set = result.as_set()
|
||||
return {
|
||||
"variables": [var.name],
|
||||
"domain": "real",
|
||||
"solution": to_str(result),
|
||||
"solution_set": to_str(as_set),
|
||||
"latex": sp.latex(as_set),
|
||||
}
|
||||
338
src/mcp_calculator/ops/calculus.py
Normal file
338
src/mcp_calculator/ops/calculus.py
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
"""Calculus: derivatives, integrals, limits, series, sums, gradients, function analysis, ODEs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import sympy as sp
|
||||
from sympy.calculus.util import continuous_domain
|
||||
|
||||
from mcp_calculator.ops.common import MathError, check_precision, fmt, numeric, to_str
|
||||
from mcp_calculator.ops.parsing import apply_assumptions, parse, parse_symbol, parse_symbols, validate
|
||||
from mcp_calculator.ops.spec import Assumption, CalculusOperation, Direction
|
||||
|
||||
|
||||
def calculus(
|
||||
operation: CalculusOperation,
|
||||
expression: str,
|
||||
variable: str = "x",
|
||||
order: int = 1,
|
||||
lower: str | None = None,
|
||||
upper: str | None = None,
|
||||
point: str | None = None,
|
||||
direction: Direction = "+-",
|
||||
terms: int = 6,
|
||||
assume: Assumption = "none",
|
||||
precision: int = 15,
|
||||
) -> dict[str, Any]:
|
||||
check_precision(precision)
|
||||
if operation == "jacobian":
|
||||
exprs = [parse(e.strip()) for e in _split_top_level(expression)]
|
||||
else:
|
||||
exprs = [parse(expression)]
|
||||
combined = sp.Tuple(*exprs)
|
||||
combined, mapping = apply_assumptions(combined, assume)
|
||||
exprs = list(combined)
|
||||
variables = [mapping.get(s, s) for s in parse_symbols(variable)]
|
||||
if not variables:
|
||||
raise MathError("'variable' is required")
|
||||
expr, var = exprs[0], variables[0]
|
||||
|
||||
match operation:
|
||||
case "derivative":
|
||||
if not 1 <= order <= 50:
|
||||
raise MathError("order must be between 1 and 50")
|
||||
# Several variables mean a mixed partial derivative d/dx d/dy ...
|
||||
spec = [(v, order) for v in variables] if len(variables) > 1 else [(var, order)]
|
||||
result = sp.simplify(sp.diff(expr, *spec))
|
||||
out = fmt(result, precision)
|
||||
case "integral":
|
||||
out = _integral(expr, var, lower, upper, precision)
|
||||
case "limit":
|
||||
if point is None:
|
||||
raise MathError("'limit' needs 'point' (e.g. \"0\" or \"oo\")")
|
||||
result = sp.limit(expr, var, parse(point), dir=direction)
|
||||
out = fmt(result, precision)
|
||||
out["direction"] = direction
|
||||
case "series":
|
||||
if not 1 <= terms <= 50:
|
||||
raise MathError("terms must be between 1 and 50")
|
||||
x0 = parse(point) if point is not None else sp.Integer(0)
|
||||
series = sp.series(expr, var, x0, terms)
|
||||
out = fmt(series, precision)
|
||||
out["polynomial"] = to_str(series.removeO())
|
||||
out["point"] = str(x0)
|
||||
case "sum" | "product":
|
||||
if lower is None or upper is None:
|
||||
raise MathError(f"'{operation}' needs 'lower' and 'upper' bounds (upper may be \"oo\")")
|
||||
bounds = (var, parse(lower), parse(upper))
|
||||
unevaluated = sp.Sum(expr, bounds) if operation == "sum" else sp.Product(expr, bounds)
|
||||
result = unevaluated.doit()
|
||||
out = fmt(sp.simplify(result) if not result.has(sp.Sum, sp.Product) else result, precision)
|
||||
if result.has(sp.Sum, sp.Product):
|
||||
out["numeric"] = str(sp.N(unevaluated, precision))
|
||||
out["method"] = "numeric (no closed form found)"
|
||||
if operation == "sum" and parse(upper) is sp.oo:
|
||||
try:
|
||||
out["converges"] = bool(sp.Sum(expr, bounds).is_convergent())
|
||||
except (NotImplementedError, ValueError, TypeError):
|
||||
pass
|
||||
case "gradient":
|
||||
grad = [sp.simplify(sp.diff(expr, v)) for v in variables]
|
||||
out = {"result": [to_str(g) for g in grad], "latex": sp.latex(sp.Matrix(grad)), "variables": [v.name for v in variables]}
|
||||
case "hessian":
|
||||
hess = sp.simplify(sp.hessian(expr, variables))
|
||||
out = {"result": [[to_str(c) for c in row] for row in hess.tolist()], "latex": sp.latex(hess), "variables": [v.name for v in variables]}
|
||||
case "jacobian":
|
||||
jac = sp.simplify(sp.Matrix(exprs).jacobian(variables))
|
||||
out = {"result": [[to_str(c) for c in row] for row in jac.tolist()], "latex": sp.latex(jac), "variables": [v.name for v in variables]}
|
||||
case _:
|
||||
raise MathError(f"unknown operation {operation!r}")
|
||||
|
||||
out["operation"] = operation
|
||||
return out
|
||||
|
||||
|
||||
def _integral(expr: sp.Expr, var: sp.Symbol, lower: str | None, upper: str | None, precision: int) -> dict[str, Any]:
|
||||
if (lower is None) != (upper is None):
|
||||
raise MathError("give both 'lower' and 'upper' for a definite integral, or neither")
|
||||
if lower is None:
|
||||
result = sp.integrate(expr, var)
|
||||
if result.has(sp.Integral):
|
||||
return {"result": to_str(result), "latex": sp.latex(result), "note": "No closed-form antiderivative found."}
|
||||
out = fmt(sp.simplify(result), precision)
|
||||
out["note"] = "Indefinite integral; add an integration constant C."
|
||||
return out
|
||||
|
||||
a, b = parse(lower), parse(upper)
|
||||
result = sp.integrate(expr, (var, a, b))
|
||||
if not result.has(sp.Integral):
|
||||
out = fmt(sp.simplify(result), precision)
|
||||
out["method"] = "symbolic"
|
||||
return out
|
||||
value = sp.N(sp.Integral(expr, (var, a, b)), precision)
|
||||
return {"result": str(value), "numeric": str(value), "method": "numeric quadrature (no closed form found)"}
|
||||
|
||||
|
||||
def _split_top_level(text: str) -> list[str]:
|
||||
"""Split 'f, g, h' at top-level commas (commas inside function calls are kept)."""
|
||||
parts, depth, current = [], 0, []
|
||||
for ch in text:
|
||||
if ch == "(":
|
||||
depth += 1
|
||||
elif ch == ")":
|
||||
depth -= 1
|
||||
if ch == "," and depth == 0:
|
||||
parts.append("".join(current))
|
||||
current = []
|
||||
else:
|
||||
current.append(ch)
|
||||
parts.append("".join(current))
|
||||
return [p for p in parts if p.strip()]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- function analysis
|
||||
|
||||
|
||||
def analyze_function(expression: str, variable: str = "x", precision: int = 10) -> dict[str, Any]:
|
||||
"""Full curve sketching ("Kurvendiskussion") of a real function f(x)."""
|
||||
check_precision(precision)
|
||||
x_plain = parse_symbol(variable)
|
||||
x = sp.Symbol(x_plain.name, real=True)
|
||||
f = parse(expression).xreplace({x_plain: x})
|
||||
if f.free_symbols - {x}:
|
||||
others = ", ".join(sorted(s.name for s in f.free_symbols - {x}))
|
||||
raise MathError(f"function must depend only on {x.name}; found parameter(s): {others}")
|
||||
|
||||
out: dict[str, Any] = {"function": to_str(f), "latex": sp.latex(f)}
|
||||
d1 = sp.simplify(sp.diff(f, x))
|
||||
d2 = sp.simplify(sp.diff(d1, x))
|
||||
d3 = sp.simplify(sp.diff(d2, x))
|
||||
out["derivatives"] = {"f'": to_str(d1), "f''": to_str(d2), "f'''": to_str(d3)}
|
||||
|
||||
def attempt(key: str, fn) -> None:
|
||||
try:
|
||||
out[key] = fn()
|
||||
except (NotImplementedError, ValueError, TypeError, AttributeError):
|
||||
out[key] = "not determinable"
|
||||
|
||||
def points(values: list[sp.Expr]) -> list[dict[str, Any]]:
|
||||
return [{"x": to_str(v), "x_numeric": numeric(v, precision) or to_str(v)} for v in values]
|
||||
|
||||
domain = continuous_domain(f, x, sp.S.Reals)
|
||||
out["domain"] = to_str(domain)
|
||||
|
||||
def real_roots(expr: sp.Expr) -> sp.Set:
|
||||
return sp.solveset(expr, x, domain)
|
||||
|
||||
def zeros():
|
||||
s = real_roots(f)
|
||||
return points(sorted(s, key=sp.default_sort_key)) if isinstance(s, sp.FiniteSet) else to_str(s)
|
||||
|
||||
attempt("zeros", zeros)
|
||||
|
||||
def y_intercept():
|
||||
if not domain.contains(0):
|
||||
return None
|
||||
return fmt(f.subs(x, 0), precision)["result"]
|
||||
|
||||
attempt("y_intercept", y_intercept)
|
||||
|
||||
def extrema():
|
||||
candidates = real_roots(d1)
|
||||
if not isinstance(candidates, sp.FiniteSet):
|
||||
return to_str(candidates)
|
||||
found = []
|
||||
for c in sorted(candidates, key=sp.default_sort_key):
|
||||
curvature = sp.simplify(d2.subs(x, c))
|
||||
if curvature.is_positive:
|
||||
kind = "minimum"
|
||||
elif curvature.is_negative:
|
||||
kind = "maximum"
|
||||
else:
|
||||
kind = _classify_by_sign_change(d1, x, c)
|
||||
y = sp.simplify(f.subs(x, c))
|
||||
found.append({"x": to_str(c), "y": to_str(y), "type": kind, "x_numeric": numeric(c, precision) or to_str(c), "y_numeric": numeric(y, precision) or to_str(y)})
|
||||
return found
|
||||
|
||||
attempt("extrema", extrema)
|
||||
|
||||
def inflection_points():
|
||||
candidates = real_roots(d2)
|
||||
if not isinstance(candidates, sp.FiniteSet):
|
||||
return to_str(candidates)
|
||||
found = []
|
||||
for c in sorted(candidates, key=sp.default_sort_key):
|
||||
if sp.simplify(d3.subs(x, c)) != 0 or _classify_by_sign_change(d2, x, c) == "sign change":
|
||||
y = sp.simplify(f.subs(x, c))
|
||||
found.append({"x": to_str(c), "y": to_str(y), "x_numeric": numeric(c, precision) or to_str(c), "y_numeric": numeric(y, precision) or to_str(y)})
|
||||
return found
|
||||
|
||||
attempt("inflection_points", inflection_points)
|
||||
|
||||
def symmetry():
|
||||
if sp.simplify(f.subs(x, -x) - f) == 0:
|
||||
return "even (symmetric to the y-axis)"
|
||||
if sp.simplify(f.subs(x, -x) + f) == 0:
|
||||
return "odd (point-symmetric to the origin)"
|
||||
return "none"
|
||||
|
||||
attempt("symmetry", symmetry)
|
||||
attempt("limit_at_minus_infinity", lambda: to_str(sp.limit(f, x, -sp.oo)))
|
||||
attempt("limit_at_plus_infinity", lambda: to_str(sp.limit(f, x, sp.oo)))
|
||||
|
||||
def vertical_asymptotes():
|
||||
singular = sp.S.Reals - domain
|
||||
if not isinstance(singular, sp.FiniteSet):
|
||||
return [] if singular is sp.S.EmptySet else to_str(singular)
|
||||
return [to_str(p) for p in sorted(singular, key=sp.default_sort_key) if sp.limit(f, x, p, dir="+").is_infinite or sp.limit(f, x, p, dir="-").is_infinite]
|
||||
|
||||
attempt("vertical_asymptotes", vertical_asymptotes)
|
||||
|
||||
def monotonicity():
|
||||
increasing = sp.solveset(d1 > 0, x, domain)
|
||||
decreasing = sp.solveset(d1 < 0, x, domain)
|
||||
return {"increasing": to_str(increasing), "decreasing": to_str(decreasing)}
|
||||
|
||||
attempt("monotonicity", monotonicity)
|
||||
|
||||
def curvature():
|
||||
return {"convex": to_str(sp.solveset(d2 > 0, x, domain)), "concave": to_str(sp.solveset(d2 < 0, x, domain))}
|
||||
|
||||
attempt("curvature", curvature)
|
||||
return out
|
||||
|
||||
|
||||
def _classify_by_sign_change(derivative: sp.Expr, x: sp.Symbol, c: sp.Expr) -> str:
|
||||
eps = sp.Rational(1, 10**6)
|
||||
left, right = derivative.subs(x, c - eps), derivative.subs(x, c + eps)
|
||||
try:
|
||||
left_sign, right_sign = sp.sign(sp.N(left)), sp.sign(sp.N(right))
|
||||
except TypeError:
|
||||
return "undetermined"
|
||||
if left_sign < 0 < right_sign:
|
||||
return "minimum"
|
||||
if left_sign > 0 > right_sign:
|
||||
return "maximum"
|
||||
if left_sign == right_sign and left_sign != 0:
|
||||
return "saddle point (no sign change)"
|
||||
return "sign change" if left_sign != right_sign else "undetermined"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- ODEs
|
||||
|
||||
# Names may directly follow a digit ("2y'"), so a letter/underscore lookbehind is used instead of \b.
|
||||
_PRIMES = re.compile(r"(?<![^\W\d])([^\W\d]\w*)('+)")
|
||||
|
||||
|
||||
def solve_ode(
|
||||
equation: str,
|
||||
function: str = "y",
|
||||
variable: str = "x",
|
||||
initial_conditions: dict[str, str | float | int] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Solve an ordinary differential equation like y'' + y = sin(x)."""
|
||||
fname = parse_symbol(function).name
|
||||
x = parse_symbol(variable)
|
||||
y = sp.Function(fname)
|
||||
|
||||
text = _expand_ode_notation(equation, fname, x.name)
|
||||
eq = parse(text, local={fname: y, "Derivative": sp.Derivative})
|
||||
if not isinstance(eq, sp.Equality):
|
||||
eq = sp.Eq(eq, 0)
|
||||
if not eq.has(sp.Derivative):
|
||||
raise MathError(f"no derivative of {fname}({x.name}) found; write e.g. {fname}' or {fname}''")
|
||||
|
||||
ics = None
|
||||
if initial_conditions:
|
||||
ics = {}
|
||||
for key, value in initial_conditions.items():
|
||||
ics.update(_parse_initial_condition(key, value, fname, y, x))
|
||||
|
||||
try:
|
||||
solution = sp.dsolve(eq, y(x), ics=ics)
|
||||
except NotImplementedError as exc:
|
||||
raise MathError(f"no solution method available for this ODE ({exc})") from None
|
||||
except ValueError as exc:
|
||||
raise MathError(f"could not solve: {exc}") from None
|
||||
|
||||
solutions = solution if isinstance(solution, list) else [solution]
|
||||
try:
|
||||
classification = sp.classify_ode(eq, y(x))
|
||||
except (NotImplementedError, ValueError):
|
||||
classification = ()
|
||||
out: dict[str, Any] = {
|
||||
"equation": to_str(eq),
|
||||
"solutions": [to_str(s.rhs if isinstance(s, sp.Equality) else s) for s in solutions],
|
||||
"latex": [sp.latex(s) for s in solutions],
|
||||
"method": classification[0] if classification else None,
|
||||
}
|
||||
if ics is None and any(s.has(sp.Symbol("C1")) for s in solutions):
|
||||
out["note"] = "General solution; C1, C2, ... are integration constants. Pass initial_conditions to fix them."
|
||||
return {k: v for k, v in out.items() if v is not None}
|
||||
|
||||
|
||||
def _expand_ode_notation(text: str, fname: str, var: str) -> str:
|
||||
"""y'' -> Derivative(y(x), x, 2) and a bare y -> y(x)."""
|
||||
validate(text.replace("'", "")) # validate everything except the prime marks
|
||||
|
||||
def prime(match: re.Match[str]) -> str:
|
||||
if match.group(1) != fname:
|
||||
raise MathError(f"prime notation is only supported for the unknown function {fname!r}")
|
||||
return f"Derivative({fname}({var}), {var}, {len(match.group(2))})"
|
||||
|
||||
text = _PRIMES.sub(prime, text)
|
||||
if "'" in text:
|
||||
raise MathError("unexpected quote character")
|
||||
# Bare function name (not followed by '(') means y(x).
|
||||
return re.sub(rf"(?<![^\W\d]){re.escape(fname)}(?!\w)(?!\s*\()", f"{fname}({var})", text)
|
||||
|
||||
|
||||
def _parse_initial_condition(key: str, value: Any, fname: str, y: sp.Function, x: sp.Symbol) -> dict[sp.Basic, sp.Basic]:
|
||||
match = re.fullmatch(rf"\s*{re.escape(fname)}('*)\s*\((.+)\)\s*", key)
|
||||
if not match:
|
||||
raise MathError(f"initial condition keys look like \"{fname}(0)\" or \"{fname}'(0)\", got {key!r}")
|
||||
order, at = len(match.group(1)), parse(match.group(2))
|
||||
lhs = y(at) if order == 0 else y(x).diff(x, order).subs(x, at)
|
||||
return {lhs: parse(str(value))}
|
||||
69
src/mcp_calculator/ops/common.py
Normal file
69
src/mcp_calculator/ops/common.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
"""Shared helpers for the math operations: limits checks and result formatting."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import sympy as sp
|
||||
|
||||
from mcp_calculator.ops.context import MathError, limits
|
||||
|
||||
__all__ = ["MathError", "check_list", "check_precision", "clip", "fmt", "limits", "numeric", "symbols_of", "to_str"]
|
||||
|
||||
|
||||
def clip(text: str) -> str:
|
||||
"""Truncate very long output so a single result cannot flood the context window."""
|
||||
max_len = limits().max_output_length
|
||||
if len(text) <= max_len:
|
||||
return text
|
||||
return f"{text[:max_len]}... [truncated, {len(text)} characters total]"
|
||||
|
||||
|
||||
def check_precision(precision: int) -> int:
|
||||
if not 1 <= precision <= limits().max_precision:
|
||||
raise MathError(f"precision must be between 1 and {limits().max_precision}")
|
||||
return precision
|
||||
|
||||
|
||||
def check_list(items: list[Any], name: str, *, min_items: int = 1, max_items: int | None = None) -> None:
|
||||
max_items = max_items or limits().max_list_items
|
||||
if len(items) < min_items:
|
||||
raise MathError(f"'{name}' needs at least {min_items} item(s)")
|
||||
if len(items) > max_items:
|
||||
raise MathError(f"'{name}' has {len(items)} items; the limit is {max_items}")
|
||||
|
||||
|
||||
def numeric(expr: sp.Basic, precision: int = 15) -> str | None:
|
||||
"""Decimal approximation, or None if the expression is not a plain number."""
|
||||
try:
|
||||
if not getattr(expr, "is_number", False) or expr.has(sp.nan, sp.zoo):
|
||||
return None
|
||||
if expr.is_Integer and len(str(expr)) <= precision:
|
||||
return str(expr)
|
||||
return clip(str(sp.N(expr, precision)))
|
||||
except (TypeError, ValueError, AttributeError):
|
||||
return None
|
||||
|
||||
|
||||
def fmt(expr: Any, precision: int = 15) -> dict[str, Any]:
|
||||
"""Standard representation of a single SymPy result.
|
||||
|
||||
``result`` uses the same syntax the tools accept as input, so it can be fed
|
||||
straight into the next tool call.
|
||||
"""
|
||||
expr = sp.sympify(expr)
|
||||
out: dict[str, Any] = {"result": clip(str(expr)), "latex": clip(sp.latex(expr))}
|
||||
approx = numeric(expr, precision)
|
||||
if approx is not None and approx != out["result"]:
|
||||
out["numeric"] = approx
|
||||
if isinstance(expr, sp.Integer) and abs(expr) >= 10**precision:
|
||||
out["digits"] = len(str(abs(expr)))
|
||||
return out
|
||||
|
||||
|
||||
def to_str(expr: Any) -> str:
|
||||
return clip(str(expr))
|
||||
|
||||
|
||||
def symbols_of(expr: sp.Basic) -> list[str]:
|
||||
return sorted(str(s) for s in expr.free_symbols)
|
||||
20
src/mcp_calculator/ops/context.py
Normal file
20
src/mcp_calculator/ops/context.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
"""Error type and active limits. Import-light: used by the server process and the executor."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from mcp_calculator.config import Limits
|
||||
|
||||
_limits = Limits()
|
||||
|
||||
|
||||
class MathError(ValueError):
|
||||
"""An anticipated failure whose message is shown to the model verbatim."""
|
||||
|
||||
|
||||
def set_limits(limits: Limits) -> None:
|
||||
global _limits
|
||||
_limits = limits
|
||||
|
||||
|
||||
def limits() -> Limits:
|
||||
return _limits
|
||||
248
src/mcp_calculator/ops/linalg.py
Normal file
248
src/mcp_calculator/ops/linalg.py
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
"""Linear algebra on exact (SymPy) or floating point (NumPy) matrices."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Union
|
||||
|
||||
import numpy as np
|
||||
import sympy as sp
|
||||
|
||||
from mcp_calculator.ops.common import MathError, check_precision, clip, limits, numeric, to_str
|
||||
from mcp_calculator.ops.parsing import parse
|
||||
from mcp_calculator.ops.spec import MatrixOperation
|
||||
|
||||
Entry = Union[str, float, int]
|
||||
MatrixInput = Union[list[list[Entry]], list[Entry]]
|
||||
|
||||
_NEEDS_OTHER = {"multiply", "add", "subtract", "solve", "dot", "cross"}
|
||||
_NUMERIC_ONLY = {"svd", "condition_number"}
|
||||
|
||||
|
||||
def matrix(
|
||||
operation: MatrixOperation,
|
||||
matrix: MatrixInput,
|
||||
other: MatrixInput | None = None,
|
||||
exponent: int | None = None,
|
||||
numeric_mode: bool = False,
|
||||
precision: int = 15,
|
||||
) -> dict[str, Any]:
|
||||
check_precision(precision)
|
||||
a = _to_matrix(matrix, "matrix")
|
||||
b = _to_matrix(other, "other") if other is not None else None
|
||||
if operation in _NEEDS_OTHER and b is None:
|
||||
raise MathError(f"'{operation}' needs a second matrix/vector in 'other'")
|
||||
|
||||
if numeric_mode or operation in _NUMERIC_ONLY:
|
||||
return {"operation": operation, "mode": "numeric (float64)", **_numeric(operation, a, b, exponent)}
|
||||
return {"operation": operation, "mode": "exact", **_exact(operation, a, b, exponent, precision)}
|
||||
|
||||
|
||||
def _to_matrix(data: MatrixInput | None, name: str) -> sp.Matrix:
|
||||
if not isinstance(data, list) or not data:
|
||||
raise MathError(f"'{name}' must be a non-empty list of rows, e.g. [[1, 2], [3, 4]]")
|
||||
rows = data if isinstance(data[0], list) else [[v] for v in data] # flat list = column vector
|
||||
size = limits().max_matrix_size
|
||||
if len(rows) > size or any(len(r) > size for r in rows):
|
||||
raise MathError(f"'{name}' is larger than {size}x{size}")
|
||||
if len({len(r) for r in rows}) != 1:
|
||||
raise MathError(f"all rows of '{name}' must have the same length")
|
||||
return sp.Matrix([[parse(str(v)) for v in row] for row in rows])
|
||||
|
||||
|
||||
def _mat_out(m: sp.Matrix, precision: int) -> dict[str, Any]:
|
||||
out: dict[str, Any] = {
|
||||
"result": [[to_str(v) for v in row] for row in m.tolist()],
|
||||
"shape": list(m.shape),
|
||||
"latex": clip(sp.latex(m)),
|
||||
}
|
||||
if all(getattr(v, "is_number", False) for v in m) and any(not v.is_Integer for v in m):
|
||||
out["numeric"] = [[numeric(v, precision) or to_str(v) for v in row] for row in m.tolist()]
|
||||
return out
|
||||
|
||||
|
||||
def _scalar_out(value: sp.Expr, precision: int) -> dict[str, Any]:
|
||||
out: dict[str, Any] = {"result": to_str(value), "latex": sp.latex(value)}
|
||||
approx = numeric(value, precision)
|
||||
if approx is not None and approx != out["result"]:
|
||||
out["numeric"] = approx
|
||||
return out
|
||||
|
||||
|
||||
def _require_square(a: sp.Matrix, operation: str) -> None:
|
||||
if not a.is_square:
|
||||
raise MathError(f"'{operation}' needs a square matrix, got shape {a.shape[0]}x{a.shape[1]}")
|
||||
|
||||
|
||||
def _exact(operation: str, a: sp.Matrix, b: sp.Matrix | None, exponent: int | None, precision: int) -> dict[str, Any]:
|
||||
match operation:
|
||||
case "determinant":
|
||||
_require_square(a, operation)
|
||||
return _scalar_out(sp.simplify(a.det()), precision)
|
||||
case "inverse":
|
||||
_require_square(a, operation)
|
||||
if a.det() == 0:
|
||||
raise MathError("matrix is singular (determinant 0) and has no inverse; try 'pseudo_inverse'")
|
||||
return _mat_out(sp.simplify(a.inv()), precision)
|
||||
case "transpose":
|
||||
return _mat_out(a.T, precision)
|
||||
case "rank":
|
||||
return {"result": a.rank()}
|
||||
case "trace":
|
||||
_require_square(a, operation)
|
||||
return _scalar_out(a.trace(), precision)
|
||||
case "rref":
|
||||
reduced, pivots = a.rref()
|
||||
return {**_mat_out(reduced, precision), "pivot_columns": list(pivots)}
|
||||
case "eigenvalues":
|
||||
_require_square(a, operation)
|
||||
values = a.eigenvals()
|
||||
return {
|
||||
"result": [{"value": to_str(v), "numeric": numeric(v, precision), "multiplicity": m} for v, m in values.items()],
|
||||
}
|
||||
case "eigenvectors":
|
||||
_require_square(a, operation)
|
||||
return {
|
||||
"result": [
|
||||
{
|
||||
"value": to_str(v),
|
||||
"numeric": numeric(v, precision),
|
||||
"multiplicity": m,
|
||||
"vectors": [[to_str(c) for c in vec] for vec in vecs],
|
||||
}
|
||||
for v, m, vecs in a.eigenvects()
|
||||
]
|
||||
}
|
||||
case "characteristic_polynomial":
|
||||
_require_square(a, operation)
|
||||
lam = sp.Symbol("lambda")
|
||||
poly = a.charpoly(lam).as_expr()
|
||||
return {"result": to_str(poly), "latex": sp.latex(poly), "variable": "lambda"}
|
||||
case "diagonalize":
|
||||
_require_square(a, operation)
|
||||
if not a.is_diagonalizable():
|
||||
raise MathError("matrix is not diagonalizable")
|
||||
p, d = a.diagonalize()
|
||||
return {"P": _mat_out(p, precision)["result"], "D": _mat_out(d, precision)["result"], "note": "matrix = P * D * P^-1"}
|
||||
case "nullspace":
|
||||
return {"result": [[to_str(c) for c in vec] for vec in a.nullspace()]}
|
||||
case "columnspace":
|
||||
return {"result": [[to_str(c) for c in vec] for vec in a.columnspace()]}
|
||||
case "multiply":
|
||||
assert b is not None
|
||||
if a.shape[1] != b.shape[0]:
|
||||
raise MathError(f"shapes {a.shape} and {b.shape} are not compatible for multiplication")
|
||||
return _mat_out(a * b, precision)
|
||||
case "add" | "subtract":
|
||||
assert b is not None
|
||||
if a.shape != b.shape:
|
||||
raise MathError(f"shapes {a.shape} and {b.shape} differ")
|
||||
return _mat_out(a + b if operation == "add" else a - b, precision)
|
||||
case "power":
|
||||
_require_square(a, operation)
|
||||
if exponent is None:
|
||||
raise MathError("'power' needs 'exponent'")
|
||||
if abs(exponent) > 1000:
|
||||
raise MathError("|exponent| must be <= 1000")
|
||||
return _mat_out(sp.simplify(a**exponent), precision)
|
||||
case "solve":
|
||||
assert b is not None
|
||||
if b.shape[0] != a.shape[0]:
|
||||
raise MathError(f"'other' must have {a.shape[0]} rows (right-hand side b of A*x = b)")
|
||||
try:
|
||||
solution, params = a.gauss_jordan_solve(b)
|
||||
except ValueError:
|
||||
return {"result": None, "note": "The linear system has no solution (inconsistent)."}
|
||||
out = _mat_out(solution, precision)
|
||||
if params.shape[0]:
|
||||
out["note"] = f"Infinitely many solutions; free parameters: {', '.join(str(p) for p in params)}"
|
||||
return out
|
||||
case "dot":
|
||||
assert b is not None
|
||||
if len(a) != len(b):
|
||||
raise MathError("vectors must have the same length")
|
||||
return _scalar_out(sp.Matrix(list(a)).dot(sp.Matrix(list(b))), precision)
|
||||
case "cross":
|
||||
assert b is not None
|
||||
if len(a) != 3 or len(b) != 3:
|
||||
raise MathError("cross product needs two 3-vectors")
|
||||
return _mat_out(sp.Matrix(list(a)).cross(sp.Matrix(list(b))), precision)
|
||||
case "norm":
|
||||
value = a.norm() if 1 in a.shape else a.norm("fro")
|
||||
return {**_scalar_out(sp.simplify(value), precision), "kind": "euclidean" if 1 in a.shape else "frobenius"}
|
||||
case "pseudo_inverse":
|
||||
return _mat_out(sp.simplify(a.pinv()), precision)
|
||||
raise MathError(f"unknown operation {operation!r}")
|
||||
|
||||
|
||||
def _to_float(m: sp.Matrix) -> np.ndarray:
|
||||
try:
|
||||
return np.array(m.evalf(), dtype=complex if any(not v.is_real for v in m) else float)
|
||||
except TypeError:
|
||||
raise MathError("numeric mode needs numeric entries (no variables)") from None
|
||||
|
||||
|
||||
def _np_list(x: np.ndarray) -> Any:
|
||||
if np.iscomplexobj(x):
|
||||
if np.allclose(x.imag, 0):
|
||||
x = x.real
|
||||
else:
|
||||
return [str(v) for v in np.ravel(x)] if x.ndim == 1 else [[str(v) for v in row] for row in x]
|
||||
return np.round(x, 12).tolist()
|
||||
|
||||
|
||||
def _numeric(operation: str, a_sym: sp.Matrix, b_sym: sp.Matrix | None, exponent: int | None) -> dict[str, Any]:
|
||||
a = _to_float(a_sym)
|
||||
b = _to_float(b_sym) if b_sym is not None else None
|
||||
square = a.shape[0] == a.shape[1]
|
||||
try:
|
||||
match operation:
|
||||
case "determinant" if square:
|
||||
return {"result": float(np.linalg.det(a))}
|
||||
case "inverse" if square:
|
||||
return {"result": _np_list(np.linalg.inv(a))}
|
||||
case "transpose":
|
||||
return {"result": _np_list(a.T)}
|
||||
case "rank":
|
||||
return {"result": int(np.linalg.matrix_rank(a))}
|
||||
case "trace" if square:
|
||||
return {"result": float(np.trace(a))}
|
||||
case "eigenvalues" if square:
|
||||
return {"result": _np_list(np.linalg.eigvals(a))}
|
||||
case "eigenvectors" if square:
|
||||
values, vectors = np.linalg.eig(a)
|
||||
return {"eigenvalues": _np_list(values), "eigenvectors_as_columns": _np_list(vectors)}
|
||||
case "multiply":
|
||||
return {"result": _np_list(a @ b)}
|
||||
case "add":
|
||||
return {"result": _np_list(a + b)}
|
||||
case "subtract":
|
||||
return {"result": _np_list(a - b)}
|
||||
case "power" if square:
|
||||
if exponent is None:
|
||||
raise MathError("'power' needs 'exponent'")
|
||||
return {"result": _np_list(np.linalg.matrix_power(a, exponent))}
|
||||
case "solve":
|
||||
if square:
|
||||
return {"result": _np_list(np.linalg.solve(a, b))}
|
||||
x, residuals, rank, _ = np.linalg.lstsq(a, b, rcond=None)
|
||||
return {"result": _np_list(x), "method": "least squares", "rank": int(rank), "residuals": _np_list(residuals)}
|
||||
case "dot":
|
||||
return {"result": float(np.dot(a.ravel(), b.ravel()))}
|
||||
case "cross":
|
||||
return {"result": _np_list(np.cross(a.ravel(), b.ravel()))}
|
||||
case "norm":
|
||||
return {"result": float(np.linalg.norm(a))}
|
||||
case "svd":
|
||||
u, s, vt = np.linalg.svd(a)
|
||||
return {"U": _np_list(u), "singular_values": _np_list(s), "Vt": _np_list(vt)}
|
||||
case "pseudo_inverse":
|
||||
return {"result": _np_list(np.linalg.pinv(a))}
|
||||
case "condition_number":
|
||||
return {"result": float(np.linalg.cond(a))}
|
||||
case "determinant" | "inverse" | "trace" | "eigenvalues" | "eigenvectors" | "power":
|
||||
raise MathError(f"'{operation}' needs a square matrix")
|
||||
except np.linalg.LinAlgError as exc:
|
||||
raise MathError(f"numeric linear algebra failed: {exc}") from None
|
||||
except ValueError as exc:
|
||||
raise MathError(f"incompatible shapes: {exc}") from None
|
||||
raise MathError(f"'{operation}' is only available in exact mode (numeric_mode=false)")
|
||||
218
src/mcp_calculator/ops/number_theory.py
Normal file
218
src/mcp_calculator/ops/number_theory.py
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
"""Exact integer arithmetic: primes, divisibility, modular arithmetic, combinatorics, bases."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Union
|
||||
|
||||
import sympy as sp
|
||||
from sympy.ntheory.modular import crt
|
||||
|
||||
from mcp_calculator.ops.common import MathError, check_list, clip, to_str
|
||||
from mcp_calculator.ops.parsing import parse
|
||||
from mcp_calculator.ops.spec import NumberTheoryOperation
|
||||
|
||||
IntLike = Union[int, str]
|
||||
|
||||
# operation -> (min values, max values, value names for error messages)
|
||||
_ARITY: dict[str, tuple[int, int, str]] = {
|
||||
"gcd": (2, 10_000, "[a, b, ...]"),
|
||||
"lcm": (2, 10_000, "[a, b, ...]"),
|
||||
"factorize": (1, 1, "[n]"),
|
||||
"is_prime": (1, 1, "[n]"),
|
||||
"next_prime": (1, 1, "[n]"),
|
||||
"previous_prime": (1, 1, "[n]"),
|
||||
"nth_prime": (1, 1, "[k]"),
|
||||
"primes_in_range": (2, 2, "[low, high]"),
|
||||
"divisors": (1, 1, "[n]"),
|
||||
"totient": (1, 1, "[n]"),
|
||||
"mod": (2, 2, "[a, m]"),
|
||||
"mod_pow": (3, 3, "[base, exponent, modulus]"),
|
||||
"mod_inverse": (2, 2, "[a, m]"),
|
||||
"crt": (2, 10_000, "[r1, r2, ...] plus 'moduli'"),
|
||||
"factorial": (1, 1, "[n]"),
|
||||
"binomial": (2, 2, "[n, k]"),
|
||||
"permutations": (2, 2, "[n, k]"),
|
||||
"fibonacci": (1, 1, "[n]"),
|
||||
"continued_fraction": (1, 1, "[x] (rational or expression)"),
|
||||
"base_convert": (1, 1, "[value] plus 'from_base' / 'to_base'"),
|
||||
}
|
||||
|
||||
_MAX_FACTORIAL = 20_000
|
||||
_MAX_PRIMES = 10_000
|
||||
_DIGITS = "0123456789abcdefghijklmnopqrstuvwxyz"
|
||||
|
||||
|
||||
def number_theory(
|
||||
operation: NumberTheoryOperation,
|
||||
values: list[IntLike],
|
||||
moduli: list[IntLike] | None = None,
|
||||
from_base: int = 10,
|
||||
to_base: int = 10,
|
||||
) -> dict[str, Any]:
|
||||
if operation not in _ARITY:
|
||||
raise MathError(f"unknown operation {operation!r}")
|
||||
lo, hi, names = _ARITY[operation]
|
||||
check_list(values, "values", min_items=lo, max_items=hi)
|
||||
if len(values) > hi or len(values) < lo:
|
||||
raise MathError(f"'{operation}' expects values = {names}")
|
||||
|
||||
if operation == "base_convert":
|
||||
return _base_convert(str(values[0]), from_base, to_base)
|
||||
if operation == "continued_fraction":
|
||||
x = parse(str(values[0]))
|
||||
if not x.is_real:
|
||||
raise MathError("continued fractions need a real number")
|
||||
max_terms = 50 if x.is_Rational else 20
|
||||
terms = _continued_fraction(x, max_terms)
|
||||
out: dict[str, Any] = {"result": terms}
|
||||
if len(terms) == max_terms:
|
||||
out["note"] = f"first {max_terms} terms"
|
||||
return out
|
||||
|
||||
n = [_int(v) for v in values]
|
||||
match operation:
|
||||
case "gcd":
|
||||
result: Any = sp.gcd_list(n)
|
||||
case "lcm":
|
||||
result = sp.lcm_list(n)
|
||||
case "factorize":
|
||||
_check_magnitude(n[0], 10**60, "factorize")
|
||||
factors = sp.factorint(n[0])
|
||||
pretty = " * ".join(f"{p}^{e}" if e > 1 else str(p) for p, e in factors.items()) or str(n[0])
|
||||
return {"result": {str(p): e for p, e in factors.items()}, "product_form": pretty}
|
||||
case "is_prime":
|
||||
result = bool(sp.isprime(n[0]))
|
||||
case "next_prime":
|
||||
result = sp.nextprime(n[0])
|
||||
case "previous_prime":
|
||||
if n[0] <= 2:
|
||||
raise MathError("there is no prime below 2")
|
||||
result = sp.prevprime(n[0])
|
||||
case "nth_prime":
|
||||
if not 1 <= n[0] <= 10**7:
|
||||
raise MathError("k must be between 1 and 10^7")
|
||||
result = sp.prime(n[0])
|
||||
case "primes_in_range":
|
||||
low, high = n
|
||||
primes = list(sp.primerange(low, high + 1)) if high - low <= 10**7 else None
|
||||
if primes is None:
|
||||
raise MathError("range too large (max width 10^7)")
|
||||
out = {"result": primes[:_MAX_PRIMES], "count": len(primes)}
|
||||
if len(primes) > _MAX_PRIMES:
|
||||
out["note"] = f"only the first {_MAX_PRIMES} are listed"
|
||||
return out
|
||||
case "divisors":
|
||||
_check_magnitude(n[0], 10**40, "divisors")
|
||||
divs = sp.divisors(n[0])
|
||||
return {"result": divs[:_MAX_PRIMES], "count": len(divs), "sum": sum(divs)}
|
||||
case "totient":
|
||||
_check_magnitude(n[0], 10**60, "totient")
|
||||
result = sp.totient(n[0])
|
||||
case "mod":
|
||||
if n[1] == 0:
|
||||
raise MathError("modulus must not be 0")
|
||||
result = n[0] % n[1]
|
||||
case "mod_pow":
|
||||
if n[2] == 0:
|
||||
raise MathError("modulus must not be 0")
|
||||
try:
|
||||
result = pow(n[0], n[1], n[2])
|
||||
except ValueError:
|
||||
raise MathError("a negative exponent needs base and modulus to be coprime") from None
|
||||
case "mod_inverse":
|
||||
try:
|
||||
result = sp.mod_inverse(n[0], n[1])
|
||||
except ValueError:
|
||||
raise MathError(f"{n[0]} has no inverse modulo {n[1]} (not coprime)") from None
|
||||
case "crt":
|
||||
if not moduli or len(moduli) != len(n):
|
||||
raise MathError("'crt' needs 'moduli' with one modulus per residue in 'values'")
|
||||
m = [_int(v) for v in moduli]
|
||||
solution = crt(m, n)
|
||||
if solution is None:
|
||||
return {"result": None, "note": "no solution: the congruences are inconsistent"}
|
||||
return {"result": int(solution[0]), "modulus": int(solution[1]), "meaning": f"x ≡ {solution[0]} (mod {solution[1]})"}
|
||||
case "factorial":
|
||||
if not 0 <= n[0] <= _MAX_FACTORIAL:
|
||||
raise MathError(f"n must be between 0 and {_MAX_FACTORIAL}")
|
||||
result = sp.factorial(n[0])
|
||||
case "binomial":
|
||||
result = sp.binomial(n[0], n[1])
|
||||
case "permutations":
|
||||
if n[1] > n[0] or n[1] < 0:
|
||||
result = 0
|
||||
else:
|
||||
result = sp.ff(n[0], n[1])
|
||||
case "fibonacci":
|
||||
if not 0 <= n[0] <= 100_000:
|
||||
raise MathError("n must be between 0 and 100000")
|
||||
result = sp.fibonacci(n[0])
|
||||
case _:
|
||||
raise MathError(f"unknown operation {operation!r}")
|
||||
|
||||
return _int_out(result)
|
||||
|
||||
|
||||
def _int_out(result: Any) -> dict[str, Any]:
|
||||
if isinstance(result, bool):
|
||||
return {"result": result}
|
||||
value = int(result)
|
||||
text = str(value)
|
||||
out: dict[str, Any] = {"result": clip(text) if len(text) > 15 else value}
|
||||
if len(text) > 15:
|
||||
out["digits"] = len(text.lstrip("-"))
|
||||
out["note"] = "integer given as string to keep every digit"
|
||||
return out
|
||||
|
||||
|
||||
def _int(value: IntLike) -> int:
|
||||
if isinstance(value, bool):
|
||||
raise MathError("booleans are not integers here")
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
if isinstance(value, float):
|
||||
if not value.is_integer():
|
||||
raise MathError(f"{value} is not an integer")
|
||||
return int(value)
|
||||
parsed = parse(str(value))
|
||||
if not parsed.is_Integer:
|
||||
raise MathError(f"{value!r} is not an integer")
|
||||
return int(parsed)
|
||||
|
||||
|
||||
def _check_magnitude(n: int, bound: int, operation: str) -> None:
|
||||
if abs(n) > bound:
|
||||
raise MathError(f"'{operation}' is limited to |n| <= 10^{len(str(bound)) - 1}")
|
||||
|
||||
|
||||
def _continued_fraction(x: sp.Expr, max_terms: int) -> list[int]:
|
||||
terms = []
|
||||
for term in sp.continued_fraction_iterator(x):
|
||||
terms.append(int(term))
|
||||
if len(terms) >= max_terms:
|
||||
break
|
||||
return terms
|
||||
|
||||
|
||||
def _base_convert(value: str, from_base: int, to_base: int) -> dict[str, Any]:
|
||||
if not (2 <= from_base <= 36 and 2 <= to_base <= 36):
|
||||
raise MathError("bases must be between 2 and 36")
|
||||
text = value.strip().lower().replace("_", "")
|
||||
for prefix, base in (("0x", 16), ("0b", 2), ("0o", 8)):
|
||||
if text.lstrip("-").startswith(prefix) and from_base == base:
|
||||
text = text.replace(prefix, "", 1)
|
||||
if len(text) > 1000:
|
||||
raise MathError("value too long")
|
||||
try:
|
||||
n = int(text, from_base)
|
||||
except ValueError:
|
||||
raise MathError(f"{value!r} is not a valid base-{from_base} number") from None
|
||||
if n == 0:
|
||||
digits = "0"
|
||||
else:
|
||||
digits, m = "", abs(n)
|
||||
while m:
|
||||
m, r = divmod(m, to_base)
|
||||
digits = _DIGITS[r] + digits
|
||||
digits = ("-" if n < 0 else "") + digits
|
||||
return {"result": digits, "decimal": to_str(n), "from_base": from_base, "to_base": to_base}
|
||||
241
src/mcp_calculator/ops/parsing.py
Normal file
241
src/mcp_calculator/ops/parsing.py
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
"""Safe parsing of math expressions.
|
||||
|
||||
SymPy's ``parse_expr`` ultimately calls ``eval``. It is made safe here by
|
||||
|
||||
1. validating the raw text (character whitelist, no ``__``, no attribute access,
|
||||
no string literals, no Python keywords),
|
||||
2. evaluating with a namespace that contains *only* whitelisted SymPy objects and
|
||||
an empty ``__builtins__``, and
|
||||
3. running every computation in a sandboxed worker process with a hard timeout
|
||||
(see ``mcp_calculator.executor``).
|
||||
|
||||
Accepted syntax (see also ``spec.SYNTAX_HELP``):
|
||||
``2x`` (implicit multiplication), ``x^2`` or ``x**2``, ``5!``, ``e``, ``pi``, ``I``,
|
||||
``oo``, ``sin(30*deg)``, ``ln(x)``, ``log(x, 2)``, equations ``x^2 = 4``,
|
||||
inequalities ``x^2 < 4``. Decimal numbers are converted to exact fractions
|
||||
(``0.1 + 0.2`` is exactly ``3/10``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import keyword
|
||||
import re
|
||||
from collections.abc import Iterable
|
||||
|
||||
import sympy as sp
|
||||
from sympy.parsing.sympy_parser import (
|
||||
convert_xor,
|
||||
implicit_multiplication,
|
||||
parse_expr,
|
||||
rationalize,
|
||||
standard_transformations,
|
||||
)
|
||||
|
||||
from mcp_calculator.ops.common import MathError, limits
|
||||
from mcp_calculator.ops.spec import Assumption
|
||||
|
||||
_TRANSFORMATIONS = standard_transformations + (implicit_multiplication, convert_xor, rationalize)
|
||||
_TRANSFORMATIONS_FLOAT = standard_transformations + (implicit_multiplication, convert_xor)
|
||||
|
||||
_UNICODE = {
|
||||
"π": "pi",
|
||||
"×": "*",
|
||||
"·": "*",
|
||||
"⋅": "*",
|
||||
"÷": "/",
|
||||
"−": "-",
|
||||
"–": "-",
|
||||
"²": "**2",
|
||||
"³": "**3",
|
||||
"∞": "oo",
|
||||
"≤": "<=",
|
||||
"≥": ">=",
|
||||
"≠": "!=",
|
||||
"\u00a0": " ", # non-breaking space
|
||||
}
|
||||
_SQRT = re.compile(r"√\s*(\d+(?:\.\d+)?|[^\W\d]\w*)")
|
||||
_ALLOWED_CHARS = re.compile(r"^[\w\s.,+\-*/^()<>=!%]*$")
|
||||
_ATTRIBUTE_ACCESS = re.compile(r"\.\s*[^\W\d]")
|
||||
_IDENTIFIER = re.compile(r"[^\W\d]\w*")
|
||||
_ALLOWED_KEYWORDS = {"True", "False"}
|
||||
_MAX_IDENTIFIER_LENGTH = 40
|
||||
|
||||
|
||||
def _log10(x: sp.Expr) -> sp.Expr:
|
||||
return sp.log(x, 10)
|
||||
|
||||
|
||||
def _log2(x: sp.Expr) -> sp.Expr:
|
||||
return sp.log(x, 2)
|
||||
|
||||
|
||||
def _build_namespace() -> dict[str, object]:
|
||||
names = """
|
||||
sqrt cbrt root real_root exp log
|
||||
sin cos tan cot sec csc asin acos atan atan2 acot asec acsc
|
||||
sinh cosh tanh coth sech csch asinh acosh atanh acoth
|
||||
Abs sign floor ceiling frac
|
||||
factorial factorial2 binomial gamma beta erf erfc erfinv zeta
|
||||
fibonacci harmonic LambertW
|
||||
Min Max Mod gcd lcm re im conjugate arg
|
||||
diff Derivative Integral Sum Product Piecewise Eq Ne Lt Le Gt Ge
|
||||
Interval Union FiniteSet CRootOf RootOf Heaviside DiracDelta
|
||||
E I pi oo zoo nan EulerGamma GoldenRatio
|
||||
Symbol Function Integer Float Rational Lambda
|
||||
""".split()
|
||||
ns: dict[str, object] = {name: getattr(sp, name) for name in names}
|
||||
ns.update(
|
||||
{
|
||||
"e": sp.E,
|
||||
"inf": sp.oo,
|
||||
"infinity": sp.oo,
|
||||
"deg": sp.pi / 180,
|
||||
"ln": sp.log,
|
||||
"log10": _log10,
|
||||
"log2": _log2,
|
||||
"abs": sp.Abs,
|
||||
"ceil": sp.ceiling,
|
||||
"min": sp.Min,
|
||||
"max": sp.Max,
|
||||
"mod": sp.Mod,
|
||||
"arcsin": sp.asin,
|
||||
"arccos": sp.acos,
|
||||
"arctan": sp.atan,
|
||||
"arsinh": sp.asinh,
|
||||
"arcosh": sp.acosh,
|
||||
"artanh": sp.atanh,
|
||||
"__builtins__": {},
|
||||
}
|
||||
)
|
||||
return ns
|
||||
|
||||
|
||||
_NAMESPACE = _build_namespace()
|
||||
|
||||
|
||||
def normalize(text: str) -> str:
|
||||
"""Map common Unicode math notation to ASCII syntax."""
|
||||
text = _SQRT.sub(r"sqrt(\1)", text)
|
||||
text = text.replace("√", "sqrt")
|
||||
for src, dst in _UNICODE.items():
|
||||
text = text.replace(src, dst)
|
||||
return text.strip()
|
||||
|
||||
|
||||
def validate(text: str) -> str:
|
||||
"""Reject anything that is not plain math before it reaches ``eval``."""
|
||||
if not isinstance(text, str):
|
||||
raise MathError("expression must be a string")
|
||||
text = normalize(text)
|
||||
if not text:
|
||||
raise MathError("expression is empty")
|
||||
if len(text) > limits().max_input_length:
|
||||
raise MathError(f"expression is longer than {limits().max_input_length} characters")
|
||||
if not _ALLOWED_CHARS.match(text):
|
||||
bad = sorted({c for c in text if not _ALLOWED_CHARS.match(c)})
|
||||
raise MathError(f"unsupported character(s): {' '.join(bad)}")
|
||||
if "__" in text:
|
||||
raise MathError("'__' is not allowed")
|
||||
if _ATTRIBUTE_ACCESS.search(text):
|
||||
raise MathError("attribute access ('.name') is not allowed; use function syntax, e.g. diff(f, x)")
|
||||
for name in _IDENTIFIER.findall(text):
|
||||
if name.startswith("_"):
|
||||
raise MathError(f"names must not start with '_': {name}")
|
||||
if keyword.iskeyword(name) and name not in _ALLOWED_KEYWORDS:
|
||||
raise MathError(f"'{name}' is a reserved word and cannot be used as a name")
|
||||
if len(name) > _MAX_IDENTIFIER_LENGTH:
|
||||
raise MathError(f"name too long: {name[:20]}...")
|
||||
return text
|
||||
|
||||
|
||||
def parse(text: str, *, exact: bool = True, local: dict[str, object] | None = None) -> sp.Expr:
|
||||
"""Parse an expression, equation (``a = b``) or inequality into a SymPy object.
|
||||
|
||||
``exact=True`` turns decimal literals into rationals.
|
||||
"""
|
||||
text = validate(text)
|
||||
# '=' and '!=' are split off here: in Python code they would compare structurally.
|
||||
lhs, op, rhs = _split_relation(text)
|
||||
if op is None:
|
||||
return _parse(text, exact, local)
|
||||
relation = sp.Eq if op == "=" else sp.Ne
|
||||
return relation(_parse(lhs, exact, local), _parse(rhs, exact, local))
|
||||
|
||||
|
||||
def parse_symbol(name: str) -> sp.Symbol:
|
||||
name = validate(name)
|
||||
if not re.fullmatch(r"[^\W\d]\w*", name) or name in _NAMESPACE:
|
||||
raise MathError(f"'{name}' is not a valid variable name")
|
||||
return sp.Symbol(name)
|
||||
|
||||
|
||||
def parse_symbols(names: Iterable[str] | str | None) -> list[sp.Symbol]:
|
||||
if names is None:
|
||||
return []
|
||||
if isinstance(names, str):
|
||||
names = [n for n in re.split(r"[,\s]+", names) if n]
|
||||
return [parse_symbol(n) for n in names]
|
||||
|
||||
|
||||
def as_equation(expr: sp.Basic) -> sp.Basic:
|
||||
"""Treat a bare expression ``f`` as the equation ``f = 0``."""
|
||||
if isinstance(expr, (sp.Equality, sp.Rel)):
|
||||
return expr
|
||||
return sp.Eq(expr, 0)
|
||||
|
||||
|
||||
def apply_assumptions(expr: sp.Basic, assume: Assumption) -> tuple[sp.Basic, dict[sp.Symbol, sp.Symbol]]:
|
||||
"""Replace free symbols by symbols carrying assumptions (real, positive, ...).
|
||||
|
||||
Returns the new expression and the mapping old -> new symbol.
|
||||
"""
|
||||
if assume == "none":
|
||||
return expr, {}
|
||||
flags = {"real": {"real": True}, "positive": {"positive": True}, "integer": {"integer": True}}[assume]
|
||||
mapping = {s: sp.Symbol(s.name, **flags) for s in expr.free_symbols if isinstance(s, sp.Symbol)}
|
||||
return expr.xreplace(mapping), mapping
|
||||
|
||||
|
||||
def _split_relation(text: str) -> tuple[str, str | None, str]:
|
||||
"""Split at a top-level '=' / '==' or '!=' (but not '<=' / '>=')."""
|
||||
found: list[tuple[int, int, str]] = [] # (start, length, operator)
|
||||
depth = 0
|
||||
i = 0
|
||||
while i < len(text):
|
||||
ch = text[i]
|
||||
if ch == "(":
|
||||
depth += 1
|
||||
elif ch == ")":
|
||||
depth -= 1
|
||||
elif depth == 0 and ch == "!" and text[i + 1 : i + 2] == "=":
|
||||
found.append((i, 2, "!="))
|
||||
i += 2
|
||||
continue
|
||||
elif depth == 0 and ch == "=" and (i == 0 or text[i - 1] not in "<>"):
|
||||
length = 2 if text[i + 1 : i + 2] == "=" else 1
|
||||
found.append((i, length, "="))
|
||||
i += length
|
||||
continue
|
||||
i += 1
|
||||
if not found:
|
||||
return text, None, ""
|
||||
if len(found) > 1:
|
||||
raise MathError("an equation may contain only one '='; pass several equations as a list")
|
||||
start, length, op = found[0]
|
||||
lhs, rhs = text[:start].strip(), text[start + length :].strip()
|
||||
if not lhs or not rhs:
|
||||
raise MathError(f"both sides of '{op}' must be non-empty")
|
||||
return lhs, op, rhs
|
||||
|
||||
|
||||
def _parse(text: str, exact: bool, local: dict[str, object] | None) -> sp.Expr:
|
||||
transformations = _TRANSFORMATIONS if exact else _TRANSFORMATIONS_FLOAT
|
||||
try:
|
||||
result = parse_expr(text, local_dict=dict(local or {}), global_dict=dict(_NAMESPACE), transformations=transformations)
|
||||
except SyntaxError as exc:
|
||||
raise MathError(f"syntax error in {text!r}: {exc.msg}") from None
|
||||
except (TypeError, ValueError, NameError, AttributeError) as exc:
|
||||
raise MathError(f"could not parse {text!r}: {exc}") from None
|
||||
if not isinstance(result, (sp.Basic, int, float, bool)):
|
||||
raise MathError(f"{text!r} is not a math expression")
|
||||
return sp.sympify(result)
|
||||
160
src/mcp_calculator/ops/spec.py
Normal file
160
src/mcp_calculator/ops/spec.py
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
"""API vocabulary shared by the MCP layer (server process) and the math operations (workers).
|
||||
|
||||
This module must stay import-light: no SymPy, NumPy, SciPy or Pint. The server
|
||||
process imports it for tool schemas, but never loads the math libraries itself;
|
||||
they only live in the worker processes (see docs/memory.md).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
Assumption = Literal["none", "real", "positive", "integer"]
|
||||
Domain = Literal["complex", "real", "positive", "integer"]
|
||||
Direction = Literal["+-", "+", "-"]
|
||||
Alternative = Literal["two-sided", "less", "greater"]
|
||||
|
||||
TransformOperation = Literal[
|
||||
"simplify",
|
||||
"expand",
|
||||
"factor",
|
||||
"collect",
|
||||
"cancel",
|
||||
"apart",
|
||||
"together",
|
||||
"trigsimp",
|
||||
"expand_trig",
|
||||
"powsimp",
|
||||
"expand_log",
|
||||
"logcombine",
|
||||
"radsimp",
|
||||
"rationalize_denominator",
|
||||
"nsimplify",
|
||||
"substitute",
|
||||
]
|
||||
|
||||
CalculusOperation = Literal[
|
||||
"derivative",
|
||||
"integral",
|
||||
"limit",
|
||||
"series",
|
||||
"sum",
|
||||
"product",
|
||||
"gradient",
|
||||
"hessian",
|
||||
"jacobian",
|
||||
]
|
||||
|
||||
MatrixOperation = Literal[
|
||||
"determinant",
|
||||
"inverse",
|
||||
"transpose",
|
||||
"rank",
|
||||
"trace",
|
||||
"rref",
|
||||
"eigenvalues",
|
||||
"eigenvectors",
|
||||
"characteristic_polynomial",
|
||||
"diagonalize",
|
||||
"nullspace",
|
||||
"columnspace",
|
||||
"multiply",
|
||||
"add",
|
||||
"subtract",
|
||||
"power",
|
||||
"solve",
|
||||
"dot",
|
||||
"cross",
|
||||
"norm",
|
||||
"svd",
|
||||
"pseudo_inverse",
|
||||
"condition_number",
|
||||
]
|
||||
|
||||
RegressionModel = Literal["linear", "polynomial", "exponential", "logarithmic", "power"]
|
||||
|
||||
Distribution = Literal[
|
||||
"normal",
|
||||
"t",
|
||||
"chi2",
|
||||
"f",
|
||||
"exponential",
|
||||
"uniform",
|
||||
"lognormal",
|
||||
"gamma",
|
||||
"beta",
|
||||
"binomial",
|
||||
"poisson",
|
||||
"geometric",
|
||||
"hypergeometric",
|
||||
"negative_binomial",
|
||||
]
|
||||
|
||||
ProbabilityOperation = Literal["pdf", "cdf", "sf", "between", "quantile", "interval", "summary"]
|
||||
|
||||
HypothesisTest = Literal[
|
||||
"one_sample_t",
|
||||
"two_sample_t",
|
||||
"paired_t",
|
||||
"one_way_anova",
|
||||
"mann_whitney",
|
||||
"wilcoxon",
|
||||
"chi2_goodness_of_fit",
|
||||
"chi2_independence",
|
||||
"shapiro_wilk",
|
||||
"binomial",
|
||||
"pearson_correlation",
|
||||
]
|
||||
|
||||
NumberTheoryOperation = Literal[
|
||||
"gcd",
|
||||
"lcm",
|
||||
"factorize",
|
||||
"is_prime",
|
||||
"next_prime",
|
||||
"previous_prime",
|
||||
"nth_prime",
|
||||
"primes_in_range",
|
||||
"divisors",
|
||||
"totient",
|
||||
"mod",
|
||||
"mod_pow",
|
||||
"mod_inverse",
|
||||
"crt",
|
||||
"factorial",
|
||||
"binomial",
|
||||
"permutations",
|
||||
"fibonacci",
|
||||
"continued_fraction",
|
||||
"base_convert",
|
||||
]
|
||||
|
||||
# Parameter names of each distribution, in signature order.
|
||||
DISTRIBUTION_PARAMETERS: dict[str, list[str]] = {
|
||||
"normal": ["mean", "std"],
|
||||
"t": ["df"],
|
||||
"chi2": ["df"],
|
||||
"f": ["dfn", "dfd"],
|
||||
"exponential": ["rate"],
|
||||
"uniform": ["low", "high"],
|
||||
"lognormal": ["mu", "sigma"],
|
||||
"gamma": ["shape", "scale"],
|
||||
"beta": ["a", "b"],
|
||||
"binomial": ["n", "p"],
|
||||
"poisson": ["lambda"],
|
||||
"geometric": ["p"],
|
||||
"hypergeometric": ["population", "successes", "draws"],
|
||||
"negative_binomial": ["successes", "p"],
|
||||
}
|
||||
|
||||
DISTRIBUTION_HELP = "; ".join(f"{name}({', '.join(params)})" for name, params in DISTRIBUTION_PARAMETERS.items())
|
||||
|
||||
SYNTAX_HELP = (
|
||||
"Expression syntax: + - * / ^ (or **), implicit multiplication (2x, 3(x+1)), factorial n!, "
|
||||
"modulo Mod(a, b) or a % b. Constants: pi, e, I (imaginary unit), oo (infinity), deg (pi/180, "
|
||||
"so sin(30*deg)). Functions: sqrt, cbrt, root(x, n), exp, ln/log (log(x, b) for base b), log10, log2, "
|
||||
"sin cos tan cot sec csc, asin acos atan atan2, sinh cosh tanh, abs, sign, floor, ceiling, "
|
||||
"factorial, binomial, gamma, erf, min, max, gcd, lcm, re, im, conjugate. "
|
||||
"Equations use a single '=', inequalities <, <=, >, >=. Use '.' as decimal separator; "
|
||||
"decimals are treated as exact fractions."
|
||||
)
|
||||
391
src/mcp_calculator/ops/stats.py
Normal file
391
src/mcp_calculator/ops/stats.py
Normal file
|
|
@ -0,0 +1,391 @@
|
|||
"""Descriptive statistics, regression, probability distributions and hypothesis tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from scipy import stats as st
|
||||
|
||||
from mcp_calculator.ops.common import MathError, check_list, limits
|
||||
from mcp_calculator.ops.spec import DISTRIBUTION_HELP, DISTRIBUTION_PARAMETERS, Alternative, Distribution, HypothesisTest, ProbabilityOperation, RegressionModel
|
||||
|
||||
|
||||
def _clean(value: Any, digits: int = 12) -> Any:
|
||||
"""Round floats for readability and make values JSON-safe."""
|
||||
if isinstance(value, dict):
|
||||
return {k: _clean(v, digits) for k, v in value.items()}
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [_clean(v, digits) for v in value]
|
||||
if isinstance(value, np.ndarray):
|
||||
return _clean(value.tolist(), digits)
|
||||
if isinstance(value, (np.integer,)):
|
||||
return int(value)
|
||||
if isinstance(value, (float, np.floating)):
|
||||
value = float(value)
|
||||
if math.isnan(value):
|
||||
return None
|
||||
if math.isinf(value):
|
||||
return "inf" if value > 0 else "-inf"
|
||||
return float(f"{value:.{digits}g}")
|
||||
return value
|
||||
|
||||
|
||||
def _data(values: list[float], name: str = "data", min_items: int = 1) -> np.ndarray:
|
||||
check_list(values, name, min_items=min_items, max_items=limits().max_data_points)
|
||||
arr = np.asarray(values, dtype=float)
|
||||
if not np.all(np.isfinite(arr)):
|
||||
raise MathError(f"'{name}' contains non-finite values")
|
||||
return arr
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- descriptive
|
||||
|
||||
|
||||
def statistics(data: list[float], sample: bool = True, percentiles: list[float] | None = None, y: list[float] | None = None) -> dict[str, Any]:
|
||||
x = _data(data)
|
||||
n = x.size
|
||||
ddof = 1 if sample and n > 1 else 0
|
||||
values, counts = np.unique(x, return_counts=True)
|
||||
modes = values[counts == counts.max()] if counts.max() > 1 else np.array([])
|
||||
q1, median, q3 = np.percentile(x, [25, 50, 75])
|
||||
std = float(np.std(x, ddof=ddof))
|
||||
|
||||
out: dict[str, Any] = {
|
||||
"count": n,
|
||||
"sum": math.fsum(x),
|
||||
"mean": math.fsum(x) / n,
|
||||
"median": median,
|
||||
"mode": modes.tolist(),
|
||||
"min": x.min(),
|
||||
"max": x.max(),
|
||||
"range": x.max() - x.min(),
|
||||
"variance": float(np.var(x, ddof=ddof)),
|
||||
"std_dev": std,
|
||||
"kind": "sample (n-1)" if ddof else "population (n)",
|
||||
"q1": q1,
|
||||
"q3": q3,
|
||||
"iqr": q3 - q1,
|
||||
}
|
||||
if n > 1:
|
||||
out["standard_error"] = std / math.sqrt(n)
|
||||
out["coefficient_of_variation"] = std / out["mean"] if out["mean"] else None
|
||||
if n > 2 and np.ptp(x) > 0:
|
||||
out["skewness"] = float(st.skew(x, bias=not sample))
|
||||
out["kurtosis_excess"] = float(st.kurtosis(x, bias=not sample)) if n > 3 else None
|
||||
if np.all(x > 0):
|
||||
out["geometric_mean"] = float(st.gmean(x))
|
||||
out["harmonic_mean"] = float(st.hmean(x))
|
||||
if percentiles:
|
||||
check_list(percentiles, "percentiles", max_items=100)
|
||||
if any(not 0 <= p <= 100 for p in percentiles):
|
||||
raise MathError("percentiles must be between 0 and 100")
|
||||
out["percentiles"] = {str(p): float(np.percentile(x, p)) for p in percentiles}
|
||||
if y is not None:
|
||||
yy = _data(y, "y")
|
||||
if yy.size != n:
|
||||
raise MathError("'data' and 'y' must have the same length")
|
||||
if n > 2 and np.ptp(x) > 0 and np.ptp(yy) > 0:
|
||||
pearson = st.pearsonr(x, yy)
|
||||
spearman = st.spearmanr(x, yy)
|
||||
out["paired"] = {
|
||||
"covariance": float(np.cov(x, yy, ddof=ddof)[0, 1]),
|
||||
"pearson_r": float(pearson.statistic),
|
||||
"pearson_p_value": float(pearson.pvalue),
|
||||
"spearman_rho": float(spearman.statistic),
|
||||
"spearman_p_value": float(spearman.pvalue),
|
||||
}
|
||||
return _clean(out)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- regression
|
||||
|
||||
|
||||
def regression(
|
||||
x: list[float],
|
||||
y: list[float],
|
||||
model: RegressionModel = "linear",
|
||||
degree: int = 2,
|
||||
predict: list[float] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
xs, ys = _data(x, "x", 2), _data(y, "y", 2)
|
||||
if xs.size != ys.size:
|
||||
raise MathError("'x' and 'y' must have the same length")
|
||||
|
||||
match model:
|
||||
case "linear":
|
||||
fit = st.linregress(xs, ys)
|
||||
a, b = float(fit.intercept), float(fit.slope)
|
||||
predict_fn = lambda v: a + b * v # noqa: E731
|
||||
out: dict[str, Any] = {
|
||||
"equation": f"y = {_g(a)} + {_g(b)}*x",
|
||||
"coefficients": {"intercept": a, "slope": b},
|
||||
"pearson_r": float(fit.rvalue),
|
||||
"p_value": float(fit.pvalue),
|
||||
"slope_std_error": float(fit.stderr),
|
||||
"intercept_std_error": float(fit.intercept_stderr),
|
||||
}
|
||||
case "polynomial":
|
||||
if not 1 <= degree <= 10:
|
||||
raise MathError("degree must be between 1 and 10")
|
||||
if xs.size <= degree:
|
||||
raise MathError(f"a degree-{degree} polynomial needs at least {degree + 1} points")
|
||||
coeffs = np.polyfit(xs, ys, degree)
|
||||
predict_fn = lambda v: float(np.polyval(coeffs, v)) # noqa: E731
|
||||
terms = [f"{_g(c)}*x^{degree - i}" if degree - i > 1 else (f"{_g(c)}*x" if degree - i == 1 else _g(c)) for i, c in enumerate(coeffs)]
|
||||
out = {
|
||||
"equation": "y = " + " + ".join(terms),
|
||||
"coefficients": {f"x^{degree - i}": float(c) for i, c in enumerate(coeffs)},
|
||||
}
|
||||
case "exponential":
|
||||
if np.any(ys <= 0):
|
||||
raise MathError("exponential model y = a*e^(b*x) needs y > 0")
|
||||
fit = st.linregress(xs, np.log(ys))
|
||||
a, b = math.exp(fit.intercept), float(fit.slope)
|
||||
predict_fn = lambda v: a * math.exp(b * v) # noqa: E731
|
||||
out = {"equation": f"y = {_g(a)}*e^({_g(b)}*x)", "coefficients": {"a": a, "b": b}, "fitted_on": "ln(y) (log-linear least squares)"}
|
||||
case "logarithmic":
|
||||
if np.any(xs <= 0):
|
||||
raise MathError("logarithmic model y = a + b*ln(x) needs x > 0")
|
||||
fit = st.linregress(np.log(xs), ys)
|
||||
a, b = float(fit.intercept), float(fit.slope)
|
||||
predict_fn = lambda v: a + b * math.log(v) # noqa: E731
|
||||
out = {"equation": f"y = {_g(a)} + {_g(b)}*ln(x)", "coefficients": {"a": a, "b": b}}
|
||||
case "power":
|
||||
if np.any(xs <= 0) or np.any(ys <= 0):
|
||||
raise MathError("power model y = a*x^b needs x > 0 and y > 0")
|
||||
fit = st.linregress(np.log(xs), np.log(ys))
|
||||
a, b = math.exp(fit.intercept), float(fit.slope)
|
||||
predict_fn = lambda v: a * v**b # noqa: E731
|
||||
out = {"equation": f"y = {_g(a)}*x^{_g(b)}", "coefficients": {"a": a, "b": b}, "fitted_on": "ln(x), ln(y) (log-log least squares)"}
|
||||
case _:
|
||||
raise MathError(f"unknown model {model!r}")
|
||||
|
||||
fitted = np.array([predict_fn(v) for v in xs])
|
||||
residuals = ys - fitted
|
||||
ss_res = float(np.sum(residuals**2))
|
||||
ss_tot = float(np.sum((ys - ys.mean()) ** 2))
|
||||
out["model"] = model
|
||||
out["r_squared"] = 1 - ss_res / ss_tot if ss_tot > 0 else None
|
||||
out["rmse"] = math.sqrt(ss_res / xs.size)
|
||||
out["n"] = int(xs.size)
|
||||
if predict:
|
||||
check_list(predict, "predict", max_items=1000)
|
||||
try:
|
||||
out["predictions"] = [{"x": v, "y": predict_fn(v)} for v in predict]
|
||||
except (ValueError, OverflowError) as exc:
|
||||
raise MathError(f"prediction failed: {exc}") from None
|
||||
return _clean(out)
|
||||
|
||||
|
||||
def _g(v: float) -> str:
|
||||
return f"{v:.10g}"
|
||||
|
||||
|
||||
def _n(v: float) -> float | int:
|
||||
"""Show integral floats as ints (20.0 -> 20)."""
|
||||
return int(v) if isinstance(v, float) and v.is_integer() else v
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- distributions
|
||||
|
||||
# name: (defaults, factory(params) -> frozen scipy distribution); parameter names live in spec.DISTRIBUTION_PARAMETERS
|
||||
_DISTRIBUTIONS: dict[str, tuple[dict[str, float], Any]] = {
|
||||
"normal": ({"mean": 0.0, "std": 1.0}, lambda p: st.norm(p["mean"], p["std"])),
|
||||
"t": ({}, lambda p: st.t(p["df"])),
|
||||
"chi2": ({}, lambda p: st.chi2(p["df"])),
|
||||
"f": ({}, lambda p: st.f(p["dfn"], p["dfd"])),
|
||||
"exponential": ({"rate": 1.0}, lambda p: st.expon(scale=1 / p["rate"])),
|
||||
"uniform": ({"low": 0.0, "high": 1.0}, lambda p: st.uniform(p["low"], p["high"] - p["low"])),
|
||||
"lognormal": ({}, lambda p: st.lognorm(s=p["sigma"], scale=math.exp(p["mu"]))),
|
||||
"gamma": ({"scale": 1.0}, lambda p: st.gamma(p["shape"], scale=p["scale"])),
|
||||
"beta": ({}, lambda p: st.beta(p["a"], p["b"])),
|
||||
"binomial": ({}, lambda p: st.binom(int(p["n"]), p["p"])),
|
||||
"poisson": ({}, lambda p: st.poisson(p["lambda"])),
|
||||
"geometric": ({}, lambda p: st.geom(p["p"])),
|
||||
"hypergeometric": ({}, lambda p: st.hypergeom(int(p["population"]), int(p["successes"]), int(p["draws"]))),
|
||||
"negative_binomial": ({}, lambda p: st.nbinom(p["successes"], p["p"])),
|
||||
}
|
||||
_DISCRETE = {"binomial", "poisson", "geometric", "hypergeometric", "negative_binomial"}
|
||||
|
||||
|
||||
def probability(
|
||||
distribution: Distribution,
|
||||
operation: ProbabilityOperation,
|
||||
parameters: dict[str, float] | None = None,
|
||||
x: float | None = None,
|
||||
lower: float | None = None,
|
||||
upper: float | None = None,
|
||||
q: float | None = None,
|
||||
confidence: float = 0.95,
|
||||
) -> dict[str, Any]:
|
||||
if distribution not in _DISTRIBUTIONS:
|
||||
raise MathError(f"unknown distribution {distribution!r}. Available: {DISTRIBUTION_HELP}")
|
||||
defaults, factory = _DISTRIBUTIONS[distribution]
|
||||
required = DISTRIBUTION_PARAMETERS[distribution]
|
||||
params = {**defaults, **(parameters or {})}
|
||||
missing = [r for r in required if r not in params]
|
||||
if missing:
|
||||
raise MathError(f"{distribution} needs parameter(s) {missing}. Signature: {distribution}({', '.join(required)})")
|
||||
unknown = set(params) - set(required)
|
||||
if unknown:
|
||||
raise MathError(f"unknown parameter(s) {sorted(unknown)} for {distribution}({', '.join(required)})")
|
||||
try:
|
||||
dist = factory(params)
|
||||
_ = dist.mean()
|
||||
except (ValueError, ZeroDivisionError, TypeError) as exc:
|
||||
raise MathError(f"invalid parameters for {distribution}: {exc}") from None
|
||||
|
||||
discrete = distribution in _DISCRETE
|
||||
shown = {k: _n(v) for k, v in params.items()}
|
||||
out: dict[str, Any] = {"distribution": distribution, "parameters": shown, "operation": operation, "discrete": discrete}
|
||||
|
||||
def need(value: float | None, name: str) -> float:
|
||||
if value is None:
|
||||
raise MathError(f"'{operation}' needs '{name}'")
|
||||
return value
|
||||
|
||||
match operation:
|
||||
case "pdf":
|
||||
v = need(x, "x")
|
||||
out["result"] = float(dist.pmf(v) if discrete else dist.pdf(v))
|
||||
out["meaning"] = f"P(X = {_n(v)})" if discrete else f"density f({_n(v)})"
|
||||
case "cdf":
|
||||
v = need(x, "x")
|
||||
out["result"] = float(dist.cdf(v))
|
||||
out["meaning"] = f"P(X <= {_n(v)})"
|
||||
case "sf":
|
||||
v = need(x, "x")
|
||||
out["result"] = float(dist.sf(v))
|
||||
out["meaning"] = f"P(X > {_n(v)})" + (f" = P(X >= {math.floor(v) + 1})" if discrete else "")
|
||||
case "between":
|
||||
a, b = need(lower, "lower"), need(upper, "upper")
|
||||
out["result"] = float(dist.cdf(b) - dist.cdf(a))
|
||||
out["meaning"] = f"P({_n(a)} < X <= {_n(b)})" + (f" = P({math.floor(a) + 1} <= X <= {math.floor(b)})" if discrete else "")
|
||||
case "quantile":
|
||||
p = need(q, "q")
|
||||
if not 0 < p < 1:
|
||||
raise MathError("q must be between 0 and 1 (exclusive)")
|
||||
out["result"] = float(dist.ppf(p))
|
||||
out["meaning"] = f"smallest x with P(X <= x) >= {_n(p)}"
|
||||
case "interval":
|
||||
if not 0 < confidence < 1:
|
||||
raise MathError("confidence must be between 0 and 1")
|
||||
lo, hi = dist.interval(confidence)
|
||||
out["result"] = [float(lo), float(hi)]
|
||||
out["meaning"] = f"central interval containing {confidence:.0%} of the probability mass"
|
||||
case "summary":
|
||||
out["result"] = {
|
||||
"mean": float(dist.mean()),
|
||||
"variance": float(dist.var()),
|
||||
"std_dev": float(dist.std()),
|
||||
"median": float(dist.median()),
|
||||
"skewness": float(dist.stats(moments="s")),
|
||||
"kurtosis_excess": float(dist.stats(moments="k")),
|
||||
}
|
||||
case _:
|
||||
raise MathError(f"unknown operation {operation!r}")
|
||||
return _clean(out)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- hypothesis tests
|
||||
|
||||
|
||||
def hypothesis_test(
|
||||
test: HypothesisTest,
|
||||
sample: list[float] | None = None,
|
||||
sample2: list[float] | None = None,
|
||||
groups: list[list[float]] | None = None,
|
||||
table: list[list[float]] | None = None,
|
||||
expected: list[float] | None = None,
|
||||
mu: float = 0.0,
|
||||
p: float = 0.5,
|
||||
alternative: Alternative = "two-sided",
|
||||
equal_variance: bool = False,
|
||||
alpha: float = 0.05,
|
||||
) -> dict[str, Any]:
|
||||
if not 0 < alpha < 1:
|
||||
raise MathError("alpha must be between 0 and 1")
|
||||
|
||||
def need(values: list[float] | None, name: str, min_items: int = 2) -> np.ndarray:
|
||||
if values is None:
|
||||
raise MathError(f"'{test}' needs '{name}'")
|
||||
return _data(values, name, min_items)
|
||||
|
||||
out: dict[str, Any] = {"test": test, "alternative": alternative, "alpha": alpha}
|
||||
match test:
|
||||
case "one_sample_t":
|
||||
r = st.ttest_1samp(need(sample, "sample"), mu, alternative=alternative)
|
||||
out.update(statistic=r.statistic, p_value=r.pvalue, df=r.df, null_hypothesis=f"mean = {mu}")
|
||||
ci = r.confidence_interval(1 - alpha)
|
||||
out["confidence_interval"] = [ci.low, ci.high]
|
||||
case "two_sample_t":
|
||||
r = st.ttest_ind(need(sample, "sample"), need(sample2, "sample2"), equal_var=equal_variance, alternative=alternative)
|
||||
out.update(statistic=r.statistic, p_value=r.pvalue, df=r.df, variant="Student" if equal_variance else "Welch")
|
||||
out["null_hypothesis"] = "mean(sample) = mean(sample2)"
|
||||
case "paired_t":
|
||||
a, b = need(sample, "sample"), need(sample2, "sample2")
|
||||
if a.size != b.size:
|
||||
raise MathError("paired samples must have the same length")
|
||||
r = st.ttest_rel(a, b, alternative=alternative)
|
||||
out.update(statistic=r.statistic, p_value=r.pvalue, df=r.df, null_hypothesis="mean difference = 0")
|
||||
case "one_way_anova":
|
||||
if not groups or len(groups) < 2:
|
||||
raise MathError("'one_way_anova' needs 'groups' with at least two groups")
|
||||
r = st.f_oneway(*[_data(g, "groups[i]", 2) for g in groups])
|
||||
out.update(statistic=r.statistic, p_value=r.pvalue, null_hypothesis="all group means are equal")
|
||||
out["alternative"] = "at least one mean differs"
|
||||
case "mann_whitney":
|
||||
r = st.mannwhitneyu(need(sample, "sample"), need(sample2, "sample2"), alternative=alternative)
|
||||
out.update(statistic=r.statistic, p_value=r.pvalue, null_hypothesis="both samples come from the same distribution")
|
||||
case "wilcoxon":
|
||||
a = need(sample, "sample")
|
||||
r = st.wilcoxon(a, need(sample2, "sample2"), alternative=alternative) if sample2 is not None else st.wilcoxon(a - mu, alternative=alternative)
|
||||
out.update(statistic=r.statistic, p_value=r.pvalue, null_hypothesis="median difference = 0" if sample2 is not None else f"median = {mu}")
|
||||
case "chi2_goodness_of_fit":
|
||||
observed = need(sample, "sample (observed counts)")
|
||||
exp = _data(expected, "expected", 2) if expected is not None else None
|
||||
if exp is not None:
|
||||
if exp.size != observed.size:
|
||||
raise MathError("'expected' must have the same length as the observed counts")
|
||||
exp = exp * observed.sum() / exp.sum() # accept probabilities or counts
|
||||
r = st.chisquare(observed, exp)
|
||||
out.update(statistic=r.statistic, p_value=r.pvalue, df=observed.size - 1, null_hypothesis="observed counts follow the expected distribution")
|
||||
out["alternative"] = "two-sided"
|
||||
case "chi2_independence":
|
||||
if not table:
|
||||
raise MathError("'chi2_independence' needs 'table' (contingency table of counts)")
|
||||
arr = np.asarray(table, dtype=float)
|
||||
r = st.chi2_contingency(arr)
|
||||
out.update(statistic=r.statistic, p_value=r.pvalue, df=r.dof, expected_frequencies=r.expected_freq, null_hypothesis="rows and columns are independent")
|
||||
n = arr.sum()
|
||||
out["cramers_v"] = math.sqrt(r.statistic / (n * (min(arr.shape) - 1))) if min(arr.shape) > 1 else None
|
||||
out["alternative"] = "two-sided"
|
||||
case "shapiro_wilk":
|
||||
r = st.shapiro(need(sample, "sample", 3))
|
||||
out.update(statistic=r.statistic, p_value=r.pvalue, null_hypothesis="data is normally distributed")
|
||||
out["alternative"] = "two-sided"
|
||||
case "binomial":
|
||||
values = need(sample, "sample ([successes, trials])", 2)
|
||||
if values.size != 2:
|
||||
raise MathError("for 'binomial' pass sample = [successes, trials]")
|
||||
r = st.binomtest(int(values[0]), int(values[1]), p, alternative=alternative)
|
||||
ci = r.proportion_ci(1 - alpha)
|
||||
out.update(statistic=r.statistic, p_value=r.pvalue, null_hypothesis=f"success probability = {p}", confidence_interval=[ci.low, ci.high])
|
||||
case "pearson_correlation":
|
||||
a, b = need(sample, "sample", 3), need(sample2, "sample2", 3)
|
||||
r = st.pearsonr(a, b, alternative=alternative)
|
||||
ci = r.confidence_interval(1 - alpha)
|
||||
out.update(statistic=r.statistic, p_value=r.pvalue, null_hypothesis="correlation = 0", confidence_interval=[ci.low, ci.high])
|
||||
case _:
|
||||
raise MathError(f"unknown test {test!r}")
|
||||
|
||||
p_value = float(out["p_value"])
|
||||
out["reject_null"] = p_value < alpha
|
||||
out["conclusion"] = (
|
||||
f"p = {p_value:.4g} < {alpha}: reject the null hypothesis ({out['null_hypothesis']})."
|
||||
if p_value < alpha
|
||||
else f"p = {p_value:.4g} >= {alpha}: the null hypothesis ({out['null_hypothesis']}) cannot be rejected."
|
||||
)
|
||||
return _clean(out)
|
||||
63
src/mcp_calculator/ops/units.py
Normal file
63
src/mcp_calculator/ops/units.py
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
"""Unit conversion and dimensional arithmetic via Pint.
|
||||
|
||||
Pint parses quantity strings with its own tokenizer (no ``eval``), so user input
|
||||
never reaches Python evaluation here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from typing import Any
|
||||
|
||||
import pint
|
||||
|
||||
from mcp_calculator.ops.common import MathError, limits
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _registry() -> pint.UnitRegistry:
|
||||
return pint.UnitRegistry(autoconvert_offset_to_baseunit=True)
|
||||
|
||||
|
||||
def convert_units(quantity: str, to_unit: str | None = None, precision: int = 12) -> dict[str, Any]:
|
||||
if len(quantity) > limits().max_input_length or (to_unit and len(to_unit) > 200):
|
||||
raise MathError("input too long")
|
||||
if not 1 <= precision <= 30:
|
||||
raise MathError("precision must be between 1 and 30")
|
||||
ureg = _registry()
|
||||
try:
|
||||
q = ureg.Quantity(quantity)
|
||||
except (pint.errors.UndefinedUnitError, pint.errors.DefinitionSyntaxError) as exc:
|
||||
raise MathError(f"unknown unit in {quantity!r}: {exc}") from None
|
||||
except (pint.errors.OffsetUnitCalculusError, ValueError, TypeError, AttributeError, SyntaxError) as exc:
|
||||
raise MathError(f"could not interpret {quantity!r}: {exc}") from None
|
||||
|
||||
if not isinstance(q, pint.Quantity):
|
||||
q = ureg.Quantity(q)
|
||||
|
||||
try:
|
||||
if to_unit:
|
||||
result = q.to(to_unit)
|
||||
else:
|
||||
result = q.to_base_units()
|
||||
except pint.errors.DimensionalityError as exc:
|
||||
raise MathError(f"incompatible dimensions: cannot convert {exc.dim1} to {exc.dim2}") from None
|
||||
except pint.errors.UndefinedUnitError as exc:
|
||||
raise MathError(f"unknown target unit {to_unit!r}: {exc}") from None
|
||||
except pint.errors.OffsetUnitCalculusError as exc:
|
||||
raise MathError(f"offset units (degC, degF) cannot be combined this way: {exc}") from None
|
||||
|
||||
magnitude = result.magnitude
|
||||
out: dict[str, Any] = {
|
||||
"input": quantity,
|
||||
"result": f"{magnitude:.{precision}g} {result.units:~P}".strip(),
|
||||
"magnitude": float(f"{magnitude:.{precision}g}"),
|
||||
"unit": f"{result.units:~P}",
|
||||
"unit_long": f"{result.units:P}",
|
||||
"dimensionality": str(result.dimensionality),
|
||||
}
|
||||
if not to_unit:
|
||||
out["note"] = "No target unit given; converted to SI base units."
|
||||
compact = q.to_compact() if q.dimensionless is False else q
|
||||
out["compact"] = f"{compact.magnitude:.{precision}g} {compact.units:~P}"
|
||||
return out
|
||||
54
src/mcp_calculator/ratelimit.py
Normal file
54
src/mcp_calculator/ratelimit.py
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
"""Per-client concurrency limit (overload protection).
|
||||
|
||||
A single client (e.g. an agent stuck in a loop) must not occupy the whole capacity
|
||||
of the pod. Each client may run at most ``max_concurrent`` computations at once
|
||||
(per client setting, otherwise ``MCP_CALC_CLIENT_MAX_CONCURRENT``; 0 = unlimited).
|
||||
Requests above the limit are rejected immediately as "busy".
|
||||
|
||||
Counts are per process/replica. That matches the goal (every pod protects itself)
|
||||
and needs no shared state.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from mcp_calculator import metrics
|
||||
from mcp_calculator.auth import Identity
|
||||
from mcp_calculator.executor import ComputationError
|
||||
|
||||
|
||||
class ClientConcurrencyLimiter:
|
||||
def __init__(self, default_limit: int) -> None:
|
||||
self.default_limit = default_limit
|
||||
self._active: defaultdict[str, int] = defaultdict(int) # only touched from the event loop
|
||||
|
||||
def limit_for(self, identity: Identity) -> int:
|
||||
if not identity.limited:
|
||||
return 0 # stdio: a single local user, no limit
|
||||
return self.default_limit if identity.max_concurrent is None else identity.max_concurrent
|
||||
|
||||
def active(self, name: str) -> int:
|
||||
return self._active.get(name, 0)
|
||||
|
||||
@asynccontextmanager
|
||||
async def slot(self, identity: Identity) -> AsyncIterator[None]:
|
||||
limit = self.limit_for(identity)
|
||||
name = identity.name
|
||||
if limit and self._active[name] >= limit:
|
||||
raise ComputationError(
|
||||
f"client busy: {name!r} already has {self._active[name]} computations running (limit {limit}). "
|
||||
"Wait for them to finish or retry in a few seconds.",
|
||||
kind="busy",
|
||||
)
|
||||
self._active[name] += 1
|
||||
metrics.CLIENT_INFLIGHT.labels(name).set(self._active[name])
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self._active[name] -= 1
|
||||
metrics.CLIENT_INFLIGHT.labels(name).set(self._active[name])
|
||||
if not self._active[name]:
|
||||
del self._active[name]
|
||||
383
src/mcp_calculator/server.py
Normal file
383
src/mcp_calculator/server.py
Normal file
|
|
@ -0,0 +1,383 @@
|
|||
"""MCP server definition: tool schemas and descriptions.
|
||||
|
||||
Tool functions here only validate/normalise arguments and hand the work to the
|
||||
executor; the math lives in ``mcp_calculator.ops`` and runs in worker processes.
|
||||
This module deliberately imports only ``ops.spec`` (no SymPy/SciPy), which keeps
|
||||
the server process small (see docs/memory.md).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import Annotated, Any
|
||||
|
||||
from mcp.server.mcpserver import MCPServer
|
||||
from mcp.server.mcpserver.exceptions import ToolError
|
||||
from mcp.types import ToolAnnotations
|
||||
from pydantic import Field
|
||||
|
||||
from mcp_calculator import __version__, metrics
|
||||
from mcp_calculator.auth import current_identity
|
||||
from mcp_calculator.executor import ComputationError, Executor
|
||||
from mcp_calculator.ops import spec
|
||||
from mcp_calculator.ops.spec import DISTRIBUTION_HELP, SYNTAX_HELP
|
||||
from mcp_calculator.ratelimit import ClientConcurrencyLimiter
|
||||
|
||||
logger = logging.getLogger("mcp_calculator.tools")
|
||||
|
||||
INSTRUCTIONS = f"""\
|
||||
Exact, deterministic math tools. Use them instead of mental arithmetic whenever a
|
||||
result must be correct: any arithmetic beyond trivial, algebra, calculus, linear
|
||||
algebra, statistics, probabilities, number theory and unit conversions.
|
||||
|
||||
Which tool:
|
||||
- evaluate: compute a value (exact + decimal, arbitrary precision)
|
||||
- transform: simplify / expand / factor / substitute ... an expression
|
||||
- solve: equations, systems, inequalities (symbolic or numeric)
|
||||
- calculus: derivative, integral, limit, series, sum, product, gradient, hessian, jacobian
|
||||
- analyze_function: complete curve sketching of f(x) (zeros, extrema, inflection points, asymptotes)
|
||||
- solve_ode: ordinary differential equations, optionally with initial conditions
|
||||
- matrix: determinants, inverses, eigenvalues, linear systems, decompositions
|
||||
- statistics / regression / probability / hypothesis_test: data analysis and distributions
|
||||
- number_theory: primes, gcd/lcm, modular arithmetic, combinatorics, base conversion
|
||||
- convert_units: physical units and quantity arithmetic
|
||||
|
||||
{SYNTAX_HELP}
|
||||
|
||||
Every symbolic result field 'result' uses this same syntax, so it can be passed to
|
||||
the next tool call unchanged. Report results to the user from the tool output; do
|
||||
not recompute them by hand. If a tool times out, simplify the problem or use a
|
||||
numeric option.
|
||||
"""
|
||||
|
||||
READ_ONLY = ToolAnnotations(readOnlyHint=True, idempotentHint=True, destructiveHint=False, openWorldHint=False)
|
||||
|
||||
Expression = Annotated[str, Field(description="Math expression, e.g. 'x^2 + 2x + 1' or 'sqrt(2)*pi'", max_length=20_000)]
|
||||
Precision = Annotated[int, Field(description="Significant digits of decimal results", ge=1, le=1000)]
|
||||
Number = str | float | int
|
||||
|
||||
|
||||
def create_server(executor: Executor, limiter: ClientConcurrencyLimiter | None = None) -> MCPServer:
|
||||
limiter = limiter or ClientConcurrencyLimiter(executor.settings.client_max_concurrent)
|
||||
mcp = MCPServer(
|
||||
name="calculator",
|
||||
title="Calculator",
|
||||
description="Exact math for LLMs: symbolic algebra, calculus, linear algebra, statistics, units.",
|
||||
instructions=INSTRUCTIONS,
|
||||
version=__version__,
|
||||
)
|
||||
|
||||
async def run(tool: str, task: str, **kwargs: Any) -> dict[str, Any]:
|
||||
identity = current_identity.get()
|
||||
started = time.perf_counter()
|
||||
status = "ok"
|
||||
try:
|
||||
async with limiter.slot(identity):
|
||||
return await executor.run(task, **kwargs)
|
||||
except ComputationError as exc:
|
||||
status = exc.kind
|
||||
logger.debug("tool=%s client=%s error=%r", tool, identity.name, str(exc))
|
||||
raise ToolError(str(exc)) from None
|
||||
except asyncio.CancelledError:
|
||||
status = "cancelled"
|
||||
raise
|
||||
except Exception:
|
||||
status = "crashed"
|
||||
raise
|
||||
finally:
|
||||
elapsed = time.perf_counter() - started
|
||||
metrics.TOOL_CALLS.labels(tool, identity.name, status).inc()
|
||||
metrics.TOOL_DURATION.labels(tool).observe(elapsed)
|
||||
logger.info("tool=%s client=%s status=%s duration_ms=%.0f", tool, identity.name, status, elapsed * 1000)
|
||||
|
||||
# ------------------------------------------------------------------ algebra
|
||||
|
||||
@mcp.tool(annotations=READ_ONLY)
|
||||
async def evaluate(
|
||||
expression: Expression,
|
||||
precision: Precision = 15,
|
||||
variables: Annotated[
|
||||
dict[str, Number] | None,
|
||||
Field(description="Values to substitute before evaluating, e.g. {\"x\": 2, \"r\": \"1/3\"}"),
|
||||
] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Evaluate an expression exactly and as a decimal number with arbitrary precision.
|
||||
|
||||
Use for any arithmetic, powers, roots, logarithms, trig values, factorials, big integers.
|
||||
Decimals are exact fractions, so 0.1 + 0.2 is exactly 3/10.
|
||||
Examples: "2^64", "sqrt(8) + 1/3", "sin(30*deg)", "100!", "e^(I*pi)", "(1 + 0.05)^10 * 1000".
|
||||
Returns 'exact' (symbolic) and 'numeric' (decimal string with the requested precision).
|
||||
"""
|
||||
return await run("evaluate", "algebra.evaluate", expression=expression, precision=precision, variables=variables)
|
||||
|
||||
@mcp.tool(annotations=READ_ONLY)
|
||||
async def transform(
|
||||
expression: Expression,
|
||||
operation: Annotated[
|
||||
spec.TransformOperation,
|
||||
Field(description="simplify | expand | factor | collect (needs variable) | cancel | apart (partial fractions) | together | trigsimp | expand_trig | powsimp | expand_log | logcombine | radsimp / rationalize_denominator | nsimplify (decimal -> closed form) | substitute"),
|
||||
] = "simplify",
|
||||
variable: Annotated[str | None, Field(description="Main variable for collect / apart / factor")] = None,
|
||||
substitutions: Annotated[dict[str, Number] | None, Field(description="For 'substitute': {\"x\": \"2*t\", \"y\": 3}")] = None,
|
||||
assume: Annotated[
|
||||
spec.Assumption,
|
||||
Field(description="Assumption for all variables; 'positive' allows e.g. sqrt(x^2) -> x, log(a*b) -> log(a)+log(b)"),
|
||||
] = "none",
|
||||
) -> dict[str, Any]:
|
||||
"""Rewrite an expression symbolically: simplify, expand, factor, partial fractions, substitute, ...
|
||||
|
||||
Examples: factor "x^2 - 5x + 6" -> (x-3)(x-2); simplify "sin(x)^2 + cos(x)^2" -> 1;
|
||||
apart "1/(x^2-1)"; nsimplify "1.4142135623731" -> sqrt(2).
|
||||
'equivalent_to_input' confirms the rewrite is mathematically identical.
|
||||
"""
|
||||
return await run("transform", "algebra.transform", expression=expression, operation=operation, variable=variable, substitutions=substitutions, assume=assume)
|
||||
|
||||
@mcp.tool(annotations=READ_ONLY)
|
||||
async def solve(
|
||||
equations: Annotated[
|
||||
list[str] | str,
|
||||
Field(description="One equation or a list forming a system. 'x^2 = 4', '2x + y = 1'. A bare expression means '= 0'. Inequalities: 'x^2 - 4 < 0'."),
|
||||
],
|
||||
variables: Annotated[list[str] | None, Field(description="Variables to solve for (default: all free variables)")] = None,
|
||||
domain: Annotated[
|
||||
spec.Domain,
|
||||
Field(description="Domain of the variables. Use 'real' for most real-world problems."),
|
||||
] = "complex",
|
||||
numeric_only: Annotated[
|
||||
bool,
|
||||
Field(description="Solve numerically (Newton / polynomial roots). Use for transcendental equations without closed form, e.g. cos(x) = x."),
|
||||
] = False,
|
||||
initial_guess: Annotated[list[float] | None, Field(description="Start values for numeric solving, one per variable")] = None,
|
||||
precision: Precision = 15,
|
||||
) -> dict[str, Any]:
|
||||
"""Solve equations, systems of equations and inequalities exactly (or numerically).
|
||||
|
||||
Examples: "x^2 - 5x + 6 = 0"; ["x + y = 10", "x - y = 2"]; "sin(x) = 1/2" (domain real) returns
|
||||
all solutions as a set plus principal values; "x^2 < 4" returns the interval (-2, 2).
|
||||
Returns 'solutions' (list of {variable: value}) and 'numeric' approximations.
|
||||
"""
|
||||
eqs = [equations] if isinstance(equations, str) else equations
|
||||
return await run("solve", "algebra.solve", equations=eqs, variables=variables, domain=domain, numeric_only=numeric_only, initial_guess=initial_guess, precision=precision)
|
||||
|
||||
# ------------------------------------------------------------------ calculus
|
||||
|
||||
# Python name differs from the tool name to avoid clashing with the imported module.
|
||||
@mcp.tool(name="calculus", annotations=READ_ONLY)
|
||||
async def calculus_tool(
|
||||
operation: Annotated[
|
||||
spec.CalculusOperation,
|
||||
Field(description="derivative | integral | limit | series | sum | product | gradient | hessian | jacobian"),
|
||||
],
|
||||
expression: Annotated[str, Field(description="Expression; for 'jacobian' a comma-separated list of component functions", max_length=20_000)],
|
||||
variable: Annotated[
|
||||
str,
|
||||
Field(description="Variable, e.g. 'x'. Several comma-separated for mixed partial derivatives, gradient, hessian, jacobian: 'x, y'"),
|
||||
] = "x",
|
||||
order: Annotated[int, Field(description="Derivative order", ge=1, le=50)] = 1,
|
||||
lower: Annotated[str | None, Field(description="Lower bound (integral, sum, product); may be '-oo'")] = None,
|
||||
upper: Annotated[str | None, Field(description="Upper bound (integral, sum, product); may be 'oo'")] = None,
|
||||
point: Annotated[str | None, Field(description="Limit point or series expansion point, e.g. '0', 'oo', 'pi/2'")] = None,
|
||||
direction: Annotated[spec.Direction, Field(description="Limit direction: two-sided, from the right (+) or left (-)")] = "+-",
|
||||
terms: Annotated[int, Field(description="Series: order of the truncation term O(x^terms)", ge=1, le=50)] = 6,
|
||||
assume: Annotated[spec.Assumption, Field(description="Assumption for all variables")] = "none",
|
||||
precision: Precision = 15,
|
||||
) -> dict[str, Any]:
|
||||
"""Derivatives, integrals (indefinite, definite, numeric fallback), limits, Taylor series, sums and products, gradient/Hessian/Jacobian.
|
||||
|
||||
Examples: derivative of "x^3*sin(x)"; integral of "e^(-x^2)" from "-oo" to "oo" -> sqrt(pi);
|
||||
limit of "sin(x)/x" at "0"; sum of "1/n^2" over variable "n" from "1" to "oo" -> pi^2/6;
|
||||
series of "exp(x)" at "0"; hessian of "x^2*y + y^3" with variable "x, y".
|
||||
"""
|
||||
return await run(
|
||||
"calculus", "calculus.calculus",
|
||||
operation=operation, expression=expression, variable=variable, order=order, lower=lower, upper=upper,
|
||||
point=point, direction=direction, terms=terms, assume=assume, precision=precision,
|
||||
) # fmt: skip
|
||||
|
||||
@mcp.tool(annotations=READ_ONLY)
|
||||
async def analyze_function(
|
||||
expression: Annotated[str, Field(description="Real function of one variable, e.g. 'x^3 - 3x' or '(x^2 - 1)/(x - 2)'", max_length=5_000)],
|
||||
variable: Annotated[str, Field(description="The function's variable")] = "x",
|
||||
precision: Annotated[int, Field(description="Significant digits of decimal values", ge=1, le=50)] = 10,
|
||||
) -> dict[str, Any]:
|
||||
"""Complete curve sketching of a real function f(x) in one call.
|
||||
|
||||
Returns domain, derivatives f', f'', f''', zeros, y-intercept, extrema (with type), inflection points,
|
||||
symmetry, limits at +-infinity, vertical asymptotes, monotonicity and convexity intervals.
|
||||
"""
|
||||
return await run("analyze_function", "calculus.analyze_function", expression=expression, variable=variable, precision=precision)
|
||||
|
||||
@mcp.tool(annotations=READ_ONLY)
|
||||
async def solve_ode(
|
||||
equation: Annotated[
|
||||
str,
|
||||
Field(description="ODE using prime notation, e.g. \"y'' + 2y' + y = sin(x)\" or \"y' = k*y\"", max_length=5_000),
|
||||
],
|
||||
function: Annotated[str, Field(description="Name of the unknown function")] = "y",
|
||||
variable: Annotated[str, Field(description="Independent variable")] = "x",
|
||||
initial_conditions: Annotated[
|
||||
dict[str, Number] | None,
|
||||
Field(description="E.g. {\"y(0)\": 1, \"y'(0)\": 0}"),
|
||||
] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Solve an ordinary differential equation symbolically, optionally with initial conditions.
|
||||
|
||||
Without initial conditions the general solution with constants C1, C2, ... is returned.
|
||||
"""
|
||||
return await run("solve_ode", "calculus.solve_ode", equation=equation, function=function, variable=variable, initial_conditions=initial_conditions)
|
||||
|
||||
# ------------------------------------------------------------------ linear algebra
|
||||
|
||||
@mcp.tool(annotations=READ_ONLY)
|
||||
async def matrix(
|
||||
operation: Annotated[
|
||||
spec.MatrixOperation,
|
||||
Field(description="determinant | inverse | transpose | rank | trace | rref | eigenvalues | eigenvectors | characteristic_polynomial | diagonalize | nullspace | columnspace | multiply | add | subtract | power | solve (A*x = other) | dot | cross | norm | svd | pseudo_inverse | condition_number"),
|
||||
],
|
||||
matrix: Annotated[
|
||||
list[list[Number]] | list[Number],
|
||||
Field(description="Matrix as list of rows, e.g. [[1, 2], [3, 4]]. Entries may be numbers or expressions ('1/3', 'a', 'sqrt(2)'). A flat list is a column vector."),
|
||||
],
|
||||
other: Annotated[
|
||||
list[list[Number]] | list[Number] | None,
|
||||
Field(description="Second operand for multiply / add / subtract / dot / cross, or right-hand side b for solve"),
|
||||
] = None,
|
||||
exponent: Annotated[int | None, Field(description="Exponent for 'power' (negative = power of the inverse)")] = None,
|
||||
numeric_mode: Annotated[
|
||||
bool,
|
||||
Field(description="Use fast floating point (NumPy) instead of exact arithmetic; for large or ill-conditioned numeric matrices"),
|
||||
] = False,
|
||||
precision: Precision = 15,
|
||||
) -> dict[str, Any]:
|
||||
"""Linear algebra with exact arithmetic (fractions, roots, symbols) or fast floating point.
|
||||
|
||||
Examples: determinant [[1,2],[3,4]] -> -2; solve [[2,1],[1,3]] with other [3,5];
|
||||
eigenvalues [[2,1],[1,2]]; determinant [["a","b"],["c","d"]] -> a*d - b*c.
|
||||
"""
|
||||
return await run("matrix", "linalg.matrix", operation=operation, matrix=matrix, other=other, exponent=exponent, numeric_mode=numeric_mode, precision=precision)
|
||||
|
||||
# ------------------------------------------------------------------ statistics
|
||||
|
||||
@mcp.tool(annotations=READ_ONLY)
|
||||
async def statistics(
|
||||
data: Annotated[list[float], Field(description="Numeric data values")],
|
||||
sample: Annotated[bool, Field(description="true: sample statistics (n-1); false: population (n)")] = True,
|
||||
percentiles: Annotated[list[float] | None, Field(description="Extra percentiles to compute, e.g. [5, 95]")] = None,
|
||||
y: Annotated[list[float] | None, Field(description="Optional paired second variable: adds covariance, Pearson and Spearman correlation")] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Descriptive statistics of a data set in one call.
|
||||
|
||||
Returns count, sum, mean, median, mode, min/max, range, variance, standard deviation, quartiles, IQR,
|
||||
standard error, skewness, kurtosis, geometric/harmonic mean; with 'y' also correlations.
|
||||
"""
|
||||
return await run("statistics", "stats.statistics", data=data, sample=sample, percentiles=percentiles, y=y)
|
||||
|
||||
@mcp.tool(annotations=READ_ONLY)
|
||||
async def regression(
|
||||
x: Annotated[list[float], Field(description="Independent values")],
|
||||
y: Annotated[list[float], Field(description="Dependent values (same length as x)")],
|
||||
model: Annotated[
|
||||
spec.RegressionModel,
|
||||
Field(description="linear (y=a+bx) | polynomial (needs degree) | exponential (y=a*e^(bx)) | logarithmic (y=a+b*ln x) | power (y=a*x^b)"),
|
||||
] = "linear",
|
||||
degree: Annotated[int, Field(description="Polynomial degree", ge=1, le=10)] = 2,
|
||||
predict: Annotated[list[float] | None, Field(description="x values to predict y for")] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Least-squares curve fitting with coefficients, fitted equation, R², RMSE and optional predictions.
|
||||
|
||||
The linear model also returns Pearson r, p-value and standard errors.
|
||||
"""
|
||||
return await run("regression", "stats.regression", x=x, y=y, model=model, degree=degree, predict=predict)
|
||||
|
||||
@mcp.tool(annotations=READ_ONLY)
|
||||
async def probability(
|
||||
distribution: Annotated[spec.Distribution, Field(description=f"Distribution and its parameters: {DISTRIBUTION_HELP}")],
|
||||
operation: Annotated[
|
||||
spec.ProbabilityOperation,
|
||||
Field(description="pdf (density, or P(X=x) for discrete) | cdf P(X<=x) | sf P(X>x) | between P(lower<X<=upper) | quantile (inverse cdf at q) | interval (central interval with 'confidence') | summary (mean, variance, ...)"),
|
||||
],
|
||||
parameters: Annotated[dict[str, float] | None, Field(description="Distribution parameters, e.g. {\"mean\": 100, \"std\": 15} or {\"n\": 10, \"p\": 0.3}")] = None,
|
||||
x: Annotated[float | None, Field(description="Point for pdf / cdf / sf")] = None,
|
||||
lower: Annotated[float | None, Field(description="Lower bound for 'between'")] = None,
|
||||
upper: Annotated[float | None, Field(description="Upper bound for 'between'")] = None,
|
||||
q: Annotated[float | None, Field(description="Probability for 'quantile', 0 < q < 1")] = None,
|
||||
confidence: Annotated[float, Field(description="Coverage for 'interval'", gt=0, lt=1)] = 0.95,
|
||||
) -> dict[str, Any]:
|
||||
"""Probabilities, densities, quantiles and moments of common distributions.
|
||||
|
||||
Discrete distributions: P(X >= k) = sf at x = k-1; the 'meaning' field states exactly what was computed.
|
||||
Examples: normal {mean 100, std 15} between 85 and 115; binomial {n 10, p 0.5} cdf at 3; t {df 9} quantile 0.975.
|
||||
"""
|
||||
return await run("probability", "stats.probability", distribution=distribution, operation=operation, parameters=parameters, x=x, lower=lower, upper=upper, q=q, confidence=confidence)
|
||||
|
||||
@mcp.tool(annotations=READ_ONLY)
|
||||
async def hypothesis_test(
|
||||
test: Annotated[
|
||||
spec.HypothesisTest,
|
||||
Field(description="one_sample_t (sample, mu) | two_sample_t (sample, sample2, equal_variance) | paired_t (sample, sample2) | one_way_anova (groups) | mann_whitney (sample, sample2) | wilcoxon (sample, sample2 or mu) | chi2_goodness_of_fit (sample = observed counts, expected) | chi2_independence (table) | shapiro_wilk (sample) | binomial (sample = [successes, trials], p) | pearson_correlation (sample, sample2)"),
|
||||
],
|
||||
sample: Annotated[list[float] | None, Field(description="First sample")] = None,
|
||||
sample2: Annotated[list[float] | None, Field(description="Second sample")] = None,
|
||||
groups: Annotated[list[list[float]] | None, Field(description="Groups for ANOVA")] = None,
|
||||
table: Annotated[list[list[float]] | None, Field(description="Contingency table of counts for chi2_independence")] = None,
|
||||
expected: Annotated[list[float] | None, Field(description="Expected counts or probabilities for chi2_goodness_of_fit (default: uniform)")] = None,
|
||||
mu: Annotated[float, Field(description="Hypothesised mean/median for one-sample tests")] = 0.0,
|
||||
p: Annotated[float, Field(description="Hypothesised success probability for the binomial test", ge=0, le=1)] = 0.5,
|
||||
alternative: Annotated[spec.Alternative, Field(description="Alternative hypothesis")] = "two-sided",
|
||||
equal_variance: Annotated[bool, Field(description="two_sample_t: Student (true) or Welch (false, recommended)")] = False,
|
||||
alpha: Annotated[float, Field(description="Significance level", gt=0, lt=1)] = 0.05,
|
||||
) -> dict[str, Any]:
|
||||
"""Statistical hypothesis tests with test statistic, p-value, decision and a plain-language conclusion."""
|
||||
return await run(
|
||||
"hypothesis_test", "stats.hypothesis_test",
|
||||
test=test, sample=sample, sample2=sample2, groups=groups, table=table, expected=expected,
|
||||
mu=mu, p=p, alternative=alternative, equal_variance=equal_variance, alpha=alpha,
|
||||
) # fmt: skip
|
||||
|
||||
# ------------------------------------------------------------------ number theory & units
|
||||
|
||||
@mcp.tool(name="number_theory", annotations=READ_ONLY)
|
||||
async def number_theory_tool(
|
||||
operation: Annotated[
|
||||
spec.NumberTheoryOperation,
|
||||
Field(description="gcd / lcm [a, b, ...] | factorize, is_prime, next_prime, previous_prime, divisors, totient, factorial, fibonacci [n] | nth_prime [k] | primes_in_range [low, high] | mod [a, m] | mod_pow [base, exp, mod] | mod_inverse [a, m] | crt [residues] + moduli | binomial, permutations [n, k] | continued_fraction [x] | base_convert [value] + from_base, to_base"),
|
||||
],
|
||||
values: Annotated[
|
||||
list[int | str],
|
||||
Field(description="Operands as listed for the operation. Pass integers above 2^53 as strings to keep every digit."),
|
||||
],
|
||||
moduli: Annotated[list[int | str] | None, Field(description="Moduli for 'crt'")] = None,
|
||||
from_base: Annotated[int, Field(description="Source base for base_convert", ge=2, le=36)] = 10,
|
||||
to_base: Annotated[int, Field(description="Target base for base_convert", ge=2, le=36)] = 10,
|
||||
) -> dict[str, Any]:
|
||||
"""Exact integer math: primes, factorization, gcd/lcm, modular arithmetic, Chinese remainder theorem, combinatorics, base conversion.
|
||||
|
||||
Examples: factorize [360] -> 2^3 * 3^2 * 5; mod_pow [2, 100, 97]; binomial [49, 6]; base_convert ["ff"] from 16 to 2.
|
||||
"""
|
||||
return await run("number_theory", "number_theory.number_theory", operation=operation, values=values, moduli=moduli, from_base=from_base, to_base=to_base)
|
||||
|
||||
@mcp.tool(annotations=READ_ONLY)
|
||||
async def convert_units(
|
||||
quantity: Annotated[
|
||||
str,
|
||||
Field(description="Quantity with unit, optionally with arithmetic: '100 km/h', '25 degC', '3 kWh', '5 km/h * 2 h', '1.5 kg * 9.81 m/s^2'", max_length=2_000),
|
||||
],
|
||||
to_unit: Annotated[str | None, Field(description="Target unit, e.g. 'm/s', 'degF', 'J', 'mi'. Omit for SI base units.")] = None,
|
||||
precision: Annotated[int, Field(description="Significant digits", ge=1, le=30)] = 12,
|
||||
) -> dict[str, Any]:
|
||||
"""Convert physical quantities between units and compute with units (dimension-checked).
|
||||
|
||||
Supports SI and imperial units, prefixes, temperatures (degC, degF, K), data sizes (B, KiB, MB), energy, pressure, etc.
|
||||
"""
|
||||
return await run("convert_units", "units.convert_units", quantity=quantity, to_unit=to_unit, precision=precision)
|
||||
|
||||
# ------------------------------------------------------------------ reference resource
|
||||
|
||||
@mcp.resource("calculator://syntax", name="syntax", title="Expression syntax", mime_type="text/plain")
|
||||
def syntax() -> str:
|
||||
"""Syntax accepted by all expression parameters."""
|
||||
return f"{SYNTAX_HELP}\n\nDistributions: {DISTRIBUTION_HELP}\n"
|
||||
|
||||
return mcp
|
||||
5
tests/__init__.py
Normal file
5
tests/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import logging
|
||||
|
||||
# Configure logging before any MCPServer is created; otherwise the SDK installs its own
|
||||
# verbose handler and every test prints INFO lines.
|
||||
logging.basicConfig(level=logging.WARNING)
|
||||
102
tests/support.py
Normal file
102
tests/support.py
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
"""Shared test helpers (standard library ``unittest`` only).
|
||||
|
||||
Run all tests: python -m unittest
|
||||
Skip subprocess tests: MCP_CALC_SKIP_SLOW=1 python -m unittest
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import unittest
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
|
||||
from mcp import Client
|
||||
|
||||
from mcp_calculator.config import Limits, load_settings
|
||||
from mcp_calculator.executor import Executor
|
||||
from mcp_calculator.ops.context import set_limits
|
||||
from mcp_calculator.server import create_server
|
||||
|
||||
# Tests that start subprocesses, worker pools or servers (a few seconds in total).
|
||||
slow = unittest.skipIf(os.environ.get("MCP_CALC_SKIP_SLOW") == "1", "slow test skipped (MCP_CALC_SKIP_SLOW=1)")
|
||||
|
||||
|
||||
class MathTestCase(unittest.TestCase):
|
||||
"""Resets the global limits around every test."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
set_limits(Limits())
|
||||
self.addCleanup(set_limits, Limits())
|
||||
|
||||
def assertApprox(self, actual: float, expected: float, rel: float = 1e-6) -> None:
|
||||
self.assertTrue(math.isclose(actual, expected, rel_tol=rel, abs_tol=1e-12), f"{actual!r} != approx {expected!r}")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def in_process_client(**env: str) -> AsyncIterator[Client]:
|
||||
"""MCP client connected in-process to a server with the inline executor (fast, no subprocesses).
|
||||
|
||||
Opened inside each test: anyio cancel scopes must be entered and exited in the same task,
|
||||
which rules out asyncSetUp/asyncTearDown.
|
||||
"""
|
||||
settings = load_settings([], env={"MCP_CALC_EXECUTOR": "inline", **env})
|
||||
async with Client(create_server(Executor(settings))) as client:
|
||||
yield client
|
||||
|
||||
|
||||
async def asgi_request(app, method: str, path: str, *, headers: dict[str, str] | None = None, body: Any = None) -> tuple[int, Any]:
|
||||
"""Call an ASGI app directly (no HTTP server, no extra dependency) and return (status, JSON body)."""
|
||||
payload = json.dumps(body).encode() if body is not None else b""
|
||||
scope = {
|
||||
"type": "http",
|
||||
"asgi": {"version": "3.0"},
|
||||
"http_version": "1.1",
|
||||
"method": method,
|
||||
"scheme": "http",
|
||||
"path": path,
|
||||
"raw_path": path.encode(),
|
||||
"query_string": b"",
|
||||
"root_path": "",
|
||||
"headers": [(b"content-type", b"application/json")] + [(k.lower().encode(), v.encode()) for k, v in (headers or {}).items()],
|
||||
"client": ("127.0.0.1", 50000),
|
||||
"server": ("testserver", 80),
|
||||
}
|
||||
delivered = False
|
||||
|
||||
async def receive():
|
||||
nonlocal delivered
|
||||
if not delivered:
|
||||
delivered = True
|
||||
return {"type": "http.request", "body": payload, "more_body": False}
|
||||
await asyncio.Event().wait()
|
||||
|
||||
messages = []
|
||||
|
||||
async def send(message):
|
||||
messages.append(message)
|
||||
|
||||
await app(scope, receive, send)
|
||||
status = next(m["status"] for m in messages if m["type"] == "http.response.start")
|
||||
raw = b"".join(m.get("body", b"") for m in messages if m["type"] == "http.response.body")
|
||||
return status, json.loads(raw) if raw else None
|
||||
|
||||
|
||||
class ClientTestCase(unittest.IsolatedAsyncioTestCase):
|
||||
async def call(self, tool: str, **arguments: Any) -> dict[str, Any]:
|
||||
async with in_process_client() as client:
|
||||
result = await client.call_tool(tool, arguments)
|
||||
self.assertFalse(result.is_error, result.content[0].text)
|
||||
self.assertIsNotNone(result.structured_content)
|
||||
self.assertEqual(json.loads(result.content[0].text), result.structured_content)
|
||||
return result.structured_content
|
||||
|
||||
async def call_error(self, tool: str, **arguments: Any) -> str:
|
||||
async with in_process_client() as client:
|
||||
result = await client.call_tool(tool, arguments)
|
||||
self.assertTrue(result.is_error, result.structured_content)
|
||||
return result.content[0].text
|
||||
171
tests/test_admin.py
Normal file
171
tests/test_admin.py
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
"""Client administration: service layer, audit log, admin API and CLI."""
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from mcp_calculator import admin_cli
|
||||
from mcp_calculator.admin import AuditLog, ClientAdmin, Conflict, NotFound, build_admin_app
|
||||
from mcp_calculator.clients import ClientConfigError, ClientFile, hash_token
|
||||
from tests.support import asgi_request
|
||||
|
||||
ADMIN_TOKEN = "admin-token-0123456789"
|
||||
|
||||
|
||||
class AdminTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.dir = Path(self.tmp.name)
|
||||
self.file = ClientFile(self.dir / "clients.json")
|
||||
self.audit_path = self.dir / "audit.log"
|
||||
self.changes = 0
|
||||
self.admin = ClientAdmin(self.file, AuditLog(self.audit_path), on_change=self._on_change)
|
||||
|
||||
def _on_change(self):
|
||||
self.changes += 1
|
||||
|
||||
def audit_entries(self) -> list[dict]:
|
||||
return [json.loads(line) for line in self.audit_path.read_text().splitlines()]
|
||||
|
||||
|
||||
class ClientAdminTest(AdminTestCase):
|
||||
def test_create_returns_token_once_and_stores_hash(self):
|
||||
client, token = self.admin.create("team-a", actor="test", max_concurrent=4, note="Team A")
|
||||
self.assertTrue(token.startswith("mcpc_"))
|
||||
self.assertEqual(client["token_hint"], token[:9])
|
||||
stored = self.file.read()["team-a"]
|
||||
self.assertEqual(stored.token_sha256, hash_token(token))
|
||||
self.assertNotIn(token, (self.dir / "clients.json").read_text())
|
||||
self.assertEqual(self.changes, 1)
|
||||
|
||||
def test_network_client_has_no_token(self):
|
||||
client, token = self.admin.create("batch", actor="test", auth="network", allowed_networks=["10.0.0.1"])
|
||||
self.assertIsNone(token)
|
||||
self.assertEqual(client["auth"], "network")
|
||||
|
||||
def test_lifecycle(self):
|
||||
_, first = self.admin.create("team-a", actor="test")
|
||||
self.assertEqual(self.admin.update("team-a", {"enabled": False}, actor="test")["status"], "disabled")
|
||||
_, second = self.admin.rotate("team-a", actor="test")
|
||||
self.assertNotEqual(first, second)
|
||||
self.assertEqual(self.file.read()["team-a"].token_sha256, hash_token(second))
|
||||
self.assertFalse(self.file.read()["team-a"].enabled, "rotate keeps the other settings")
|
||||
self.admin.delete("team-a", actor="test")
|
||||
self.assertEqual(self.admin.list(), [])
|
||||
|
||||
def test_errors(self):
|
||||
self.admin.create("team-a", actor="test")
|
||||
with self.assertRaises(Conflict):
|
||||
self.admin.create("team-a", actor="test")
|
||||
with self.assertRaises(NotFound):
|
||||
self.admin.update("nobody", {"enabled": False}, actor="test")
|
||||
with self.assertRaises(ClientConfigError):
|
||||
self.admin.create("Bad Name", actor="test")
|
||||
with self.assertRaises(ClientConfigError):
|
||||
self.admin.update("team-a", {"token_sha256": "0" * 64}, actor="test")
|
||||
|
||||
def test_audit_log_has_no_secrets(self):
|
||||
_, token = self.admin.create("team-a", actor="admin:ops", max_concurrent=2)
|
||||
self.admin.update("team-a", {"enabled": False}, actor="admin:ops")
|
||||
self.admin.rotate("team-a", actor="admin:ops")
|
||||
entries = self.audit_entries()
|
||||
self.assertEqual([e["action"] for e in entries], ["create", "update", "rotate"])
|
||||
self.assertTrue(all(e["actor"] == "admin:ops" and e["client"] == "team-a" for e in entries))
|
||||
text = self.audit_path.read_text()
|
||||
self.assertNotIn(token, text)
|
||||
self.assertNotIn(hash_token(token), text)
|
||||
|
||||
|
||||
class AdminApiTest(AdminTestCase, unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.app = build_admin_app(self.admin, [f"ops:{ADMIN_TOKEN}"])
|
||||
self.auth = {"Authorization": f"Bearer {ADMIN_TOKEN}"}
|
||||
|
||||
async def test_requires_admin_token(self):
|
||||
self.assertEqual((await asgi_request(self.app, "GET", "/admin/clients"))[0], 401)
|
||||
self.assertEqual((await asgi_request(self.app, "GET", "/admin/clients", headers={"Authorization": "Bearer wrong"}))[0], 401)
|
||||
self.assertEqual((await asgi_request(self.app, "GET", "/healthz"))[0], 200)
|
||||
|
||||
async def test_crud(self):
|
||||
status, body = await asgi_request(self.app, "POST", "/admin/clients", headers=self.auth, body={"name": "team-a", "max_concurrent": 3})
|
||||
self.assertEqual(status, 201)
|
||||
self.assertTrue(body["token"].startswith("mcpc_"))
|
||||
|
||||
status, body = await asgi_request(self.app, "GET", "/admin/clients", headers=self.auth)
|
||||
self.assertEqual([c["name"] for c in body["clients"]], ["team-a"])
|
||||
self.assertNotIn("token_sha256", body["clients"][0])
|
||||
|
||||
status, body = await asgi_request(self.app, "PATCH", "/admin/clients/team-a", headers=self.auth, body={"enabled": False})
|
||||
self.assertEqual((status, body["status"]), (200, "disabled"))
|
||||
|
||||
status, body = await asgi_request(self.app, "POST", "/admin/clients/team-a/rotate", headers=self.auth)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertIn("token", body)
|
||||
|
||||
self.assertEqual((await asgi_request(self.app, "DELETE", "/admin/clients/team-a", headers=self.auth))[0], 200)
|
||||
self.assertEqual((await asgi_request(self.app, "GET", "/admin/clients/team-a", headers=self.auth))[0], 404)
|
||||
self.assertEqual(self.audit_entries()[0]["actor"], "admin:ops")
|
||||
|
||||
async def test_validation_errors(self):
|
||||
cases = [
|
||||
("POST", "/admin/clients", {"max_concurrent": 1}, 400), # no name
|
||||
("POST", "/admin/clients", {"name": "a", "role": "root"}, 400), # unknown field
|
||||
("POST", "/admin/clients", {"name": "a", "expires": "someday"}, 400),
|
||||
("PATCH", "/admin/clients/missing", {"enabled": False}, 404),
|
||||
]
|
||||
for method, path, body, expected in cases:
|
||||
with self.subTest(body=body):
|
||||
self.assertEqual((await asgi_request(self.app, method, path, headers=self.auth, body=body))[0], expected)
|
||||
await asgi_request(self.app, "POST", "/admin/clients", headers=self.auth, body={"name": "dup"})
|
||||
self.assertEqual((await asgi_request(self.app, "POST", "/admin/clients", headers=self.auth, body={"name": "dup"}))[0], 409)
|
||||
|
||||
|
||||
class AdminCliTest(AdminTestCase):
|
||||
def cli(self, *args: str) -> tuple[int, str]:
|
||||
out = io.StringIO()
|
||||
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(out):
|
||||
code = admin_cli.main(["--file", str(self.file.path), *args])
|
||||
return code, out.getvalue()
|
||||
|
||||
def test_create_list_and_manage(self):
|
||||
code, out = self.cli("create", "team-a", "--max-concurrent", "4", "--expires", "2030-12-31")
|
||||
self.assertEqual(code, 0)
|
||||
self.assertIn("TOKEN (shown only once", out)
|
||||
code, out = self.cli("list")
|
||||
self.assertIn("team-a", out)
|
||||
self.assertIn("active", out)
|
||||
|
||||
self.assertEqual(self.cli("disable", "team-a")[0], 0)
|
||||
self.assertFalse(self.file.read()["team-a"].enabled)
|
||||
self.assertEqual(self.cli("enable", "team-a")[0], 0)
|
||||
self.assertTrue(self.file.read()["team-a"].enabled)
|
||||
|
||||
self.assertEqual(self.cli("set", "team-a", "--max-concurrent", "default", "--networks", "10.0.0.0/8", "--expires", "never")[0], 0)
|
||||
client = self.file.read()["team-a"]
|
||||
self.assertIsNone(client.max_concurrent)
|
||||
self.assertIsNone(client.expires)
|
||||
self.assertEqual([str(n) for n in client.allowed_networks], ["10.0.0.0/8"])
|
||||
|
||||
code, out = self.cli("--json", "rotate", "team-a")
|
||||
self.assertTrue(json.loads(out)["token"].startswith("mcpc_"))
|
||||
self.assertEqual(self.cli("delete", "team-a", "--yes")[0], 0)
|
||||
self.assertEqual(self.file.read(), {})
|
||||
self.assertTrue(all(e["actor"].startswith("cli:") for e in self.audit_entries()))
|
||||
|
||||
def test_network_only_client(self):
|
||||
self.assertEqual(self.cli("create", "batch", "--network-only", "--networks", "10.131.2.15")[0], 0)
|
||||
self.assertEqual(self.file.read()["batch"].auth, "network")
|
||||
self.assertEqual(self.cli("create", "nonet", "--network-only")[0], 1)
|
||||
|
||||
def test_errors_exit_non_zero(self):
|
||||
self.assertEqual(self.cli("disable", "missing")[0], 1)
|
||||
self.assertEqual(self.cli("set", "missing", "--note", "x")[0], 1)
|
||||
self.cli("create", "team-a")
|
||||
code, out = self.cli("set", "team-a")
|
||||
self.assertEqual(code, 1)
|
||||
self.assertIn("nothing to change", out)
|
||||
120
tests/test_algebra.py
Normal file
120
tests/test_algebra.py
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
from mcp_calculator.ops.algebra import evaluate, solve, transform
|
||||
from mcp_calculator.ops.context import MathError
|
||||
from tests.support import MathTestCase
|
||||
|
||||
|
||||
class EvaluateTest(MathTestCase):
|
||||
def test_exact_and_numeric(self):
|
||||
r = evaluate("sqrt(8) + 1/3", precision=20)
|
||||
self.assertEqual(r["exact"], "1/3 + 2*sqrt(2)")
|
||||
self.assertTrue(r["numeric"].startswith("3.161760458"))
|
||||
self.assertEqual(len(r["numeric"].replace(".", "")), 20)
|
||||
|
||||
def test_decimal_arithmetic_is_exact(self):
|
||||
self.assertEqual(evaluate("0.1 + 0.2")["exact"], "3/10")
|
||||
|
||||
def test_big_integer(self):
|
||||
r = evaluate("2^200")
|
||||
self.assertEqual(r["exact"], str(2**200))
|
||||
self.assertEqual(r["digits"], 61)
|
||||
|
||||
def test_variables(self):
|
||||
self.assertEqual(evaluate("a*x^2 + b", variables={"a": 2, "x": "3", "b": 0.5})["exact"], "37/2")
|
||||
|
||||
def test_free_symbols_are_reported(self):
|
||||
r = evaluate("x + pi")
|
||||
self.assertEqual(r["free_symbols"], ["x"])
|
||||
self.assertIn("note", r)
|
||||
|
||||
def test_compound_interest(self):
|
||||
self.assertEqual(evaluate("1000 * (1 + 0.05)^10", precision=12)["numeric"], "1628.89462678")
|
||||
|
||||
def test_precision_limit(self):
|
||||
with self.assertRaises(MathError):
|
||||
evaluate("pi", precision=5000)
|
||||
|
||||
def test_complex(self):
|
||||
self.assertEqual(evaluate("sqrt(-4)")["exact"], "2*I")
|
||||
|
||||
|
||||
class TransformTest(MathTestCase):
|
||||
def test_operations(self):
|
||||
cases = [
|
||||
("sin(x)^2 + cos(x)^2", "simplify", "1"),
|
||||
("(x + 1)^2", "expand", "x**2 + 2*x + 1"),
|
||||
("x^2 - 5x + 6", "factor", "(x - 3)*(x - 2)"),
|
||||
("(x^2 - 1)/(x - 1)", "cancel", "x + 1"),
|
||||
("1/(x^2 - 1)", "apart", "-1/(2*(x + 1)) + 1/(2*(x - 1))"),
|
||||
("1/sqrt(2)", "rationalize_denominator", "sqrt(2)/2"),
|
||||
("1.4142135623730951", "nsimplify", "sqrt(2)"),
|
||||
]
|
||||
for expression, operation, expected in cases:
|
||||
with self.subTest(operation=operation):
|
||||
self.assertEqual(transform(expression, operation)["result"], expected)
|
||||
|
||||
def test_collect(self):
|
||||
self.assertEqual(transform("a*x + b*x + c", "collect", variable="x")["result"], "c + x*(a + b)")
|
||||
|
||||
def test_substitute(self):
|
||||
self.assertEqual(transform("x^2 + y", "substitute", substitutions={"x": "2t", "y": 1})["result"], "4*t**2 + 1")
|
||||
|
||||
def test_assumption_positive(self):
|
||||
self.assertEqual(transform("sqrt(x^2)", assume="positive")["result"], "x")
|
||||
self.assertEqual(transform("sqrt(x^2)")["result"], "sqrt(x**2)")
|
||||
|
||||
def test_equivalence_flag(self):
|
||||
self.assertIs(transform("(x+1)^2", "expand")["equivalent_to_input"], True)
|
||||
|
||||
def test_collect_needs_variable(self):
|
||||
with self.assertRaisesRegex(MathError, "variable"):
|
||||
transform("x + x*y", "collect")
|
||||
|
||||
|
||||
class SolveTest(MathTestCase):
|
||||
def test_quadratic(self):
|
||||
self.assertEqual(solve(["x^2 - 5x + 6 = 0"])["solutions"], [{"x": "2"}, {"x": "3"}])
|
||||
|
||||
def test_bare_expression_means_zero(self):
|
||||
self.assertEqual(solve(["x^2 - 4"])["count"], 2)
|
||||
|
||||
def test_complex_vs_real(self):
|
||||
self.assertEqual(solve(["x^2 = -1"])["solutions"], [{"x": "-I"}, {"x": "I"}])
|
||||
self.assertEqual(solve(["x^2 = -1"], domain="real")["count"], 0)
|
||||
|
||||
def test_linear_system(self):
|
||||
self.assertEqual(solve(["x + y = 10", "x - y = 2"])["solutions"], [{"x": "6", "y": "4"}])
|
||||
|
||||
def test_underdetermined_system(self):
|
||||
self.assertIn("Infinitely many", solve(["x + y = 10"], variables=["x", "y"])["note"])
|
||||
|
||||
def test_nonlinear_system(self):
|
||||
r = solve(["x^2 + y^2 = 25", "x - y = 1"])
|
||||
self.assertEqual({tuple(s.items()) for s in r["solutions"]}, {(("x", "-3"), ("y", "-4")), (("x", "4"), ("y", "3"))})
|
||||
|
||||
def test_periodic_solutions(self):
|
||||
r = solve(["sin(x) = 1/2"], domain="real")
|
||||
self.assertIn("ImageSet", r["solution_set"])
|
||||
self.assertIn({"x": "pi/6"}, r["principal_solutions"])
|
||||
|
||||
def test_inequality(self):
|
||||
self.assertEqual(solve(["x^2 < 4"])["solution_set"], "Interval.open(-2, 2)")
|
||||
|
||||
def test_inequality_system(self):
|
||||
self.assertEqual(solve(["x > 1", "x <= 5"])["solution_set"], "Interval.Lopen(1, 5)")
|
||||
|
||||
def test_numeric_transcendental(self):
|
||||
self.assertTrue(solve(["cos(x) = x"], numeric_only=True)["solutions"][0]["x"].startswith("0.7390851332"))
|
||||
|
||||
def test_numeric_polynomial_all_roots(self):
|
||||
self.assertEqual(solve(["x^5 - x + 1"], numeric_only=True)["count"], 5)
|
||||
|
||||
def test_numeric_system_with_guess(self):
|
||||
r = solve(["x^2 + y^2 = 4", "x = y"], numeric_only=True, initial_guess=[1, 1])
|
||||
self.assertTrue(r["solutions"][0]["x"].startswith("1.414213562"))
|
||||
|
||||
def test_positive_domain(self):
|
||||
self.assertEqual(solve(["x^2 = 4"], domain="positive")["solutions"], [{"x": "2"}])
|
||||
|
||||
def test_no_variable(self):
|
||||
with self.assertRaises(MathError):
|
||||
solve(["1 = 1"])
|
||||
117
tests/test_auth.py
Normal file
117
tests/test_auth.py
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
"""Authentication decisions and the per-client concurrency limit."""
|
||||
|
||||
import asyncio
|
||||
import unittest
|
||||
|
||||
from mcp_calculator.auth import LOCAL, Failure, Identity, authenticate, extract_token
|
||||
from mcp_calculator.clients import Client, Snapshot, hash_token
|
||||
from mcp_calculator.executor import ComputationError
|
||||
from mcp_calculator.ratelimit import ClientConcurrencyLimiter
|
||||
|
||||
TOKEN_A = "mcpc_team-a-token-000000000000000000000000"
|
||||
TOKEN_SVC = "mcpc_service-token-00000000000000000000000"
|
||||
TOKEN_OFF = "mcpc_disabled-token-0000000000000000000000"
|
||||
TOKEN_OLD = "mcpc_expired-token-00000000000000000000000"
|
||||
|
||||
|
||||
def snapshot() -> Snapshot:
|
||||
clients = {
|
||||
"team-a": Client.from_dict("team-a", {"token_sha256": hash_token(TOKEN_A), "max_concurrent": 3}),
|
||||
"reporting": Client.from_dict("reporting", {"token_sha256": hash_token(TOKEN_SVC), "allowed_networks": ["10.130.4.0/24"]}),
|
||||
"off": Client.from_dict("off", {"token_sha256": hash_token(TOKEN_OFF), "enabled": False}),
|
||||
"old": Client.from_dict("old", {"token_sha256": hash_token(TOKEN_OLD), "expires": "2000-01-01"}),
|
||||
"batch-net": Client.from_dict("batch-net", {"auth": "network", "allowed_networks": ["10.131.0.0/16"]}),
|
||||
"batch-host": Client.from_dict("batch-host", {"auth": "network", "allowed_networks": ["10.131.2.15/32"], "max_concurrent": 1}),
|
||||
}
|
||||
return Snapshot.build(clients)
|
||||
|
||||
|
||||
class AuthenticateTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.snapshot = snapshot()
|
||||
|
||||
def test_valid_token(self):
|
||||
self.assertEqual(authenticate(self.snapshot, TOKEN_A, "203.0.113.1"), Identity("team-a", "token", 3))
|
||||
|
||||
def test_failures(self):
|
||||
cases = [
|
||||
(None, "203.0.113.1", "missing_credentials"),
|
||||
("mcpc_unknown", "203.0.113.1", "invalid_token"),
|
||||
(TOKEN_OFF, "203.0.113.1", "disabled"),
|
||||
(TOKEN_OLD, "203.0.113.1", "expired"),
|
||||
(TOKEN_SVC, "10.130.5.1", "network_denied"),
|
||||
(TOKEN_SVC, None, "network_denied"),
|
||||
]
|
||||
for token, ip, reason in cases:
|
||||
with self.subTest(reason=reason):
|
||||
result = authenticate(self.snapshot, token, ip)
|
||||
self.assertIsInstance(result, Failure)
|
||||
self.assertEqual(result.reason, reason)
|
||||
|
||||
def test_http_status_codes(self):
|
||||
self.assertEqual(Failure("missing_credentials").status, 401)
|
||||
self.assertEqual(Failure("invalid_token").status, 401)
|
||||
self.assertEqual(Failure("disabled").status, 403)
|
||||
self.assertEqual(Failure("network_denied").status, 403)
|
||||
|
||||
def test_token_restricted_to_network(self):
|
||||
self.assertEqual(authenticate(self.snapshot, TOKEN_SVC, "10.130.4.77").name, "reporting")
|
||||
|
||||
def test_network_client_without_token(self):
|
||||
self.assertEqual(authenticate(self.snapshot, None, "10.131.9.9"), Identity("batch-net", "network", None))
|
||||
|
||||
def test_most_specific_network_wins(self):
|
||||
self.assertEqual(authenticate(self.snapshot, None, "10.131.2.15"), Identity("batch-host", "network", 1))
|
||||
|
||||
def test_invalid_token_is_rejected_even_from_trusted_network(self):
|
||||
self.assertEqual(authenticate(self.snapshot, "mcpc_wrong", "10.131.2.15").reason, "invalid_token")
|
||||
|
||||
|
||||
class ExtractTokenTest(unittest.TestCase):
|
||||
def test_sources(self):
|
||||
self.assertEqual(extract_token([(b"authorization", b"Bearer abc")]), "abc")
|
||||
self.assertEqual(extract_token([(b"authorization", b"bearer abc ")]), "abc")
|
||||
self.assertEqual(extract_token([(b"x-api-key", b"xyz")]), "xyz")
|
||||
self.assertIsNone(extract_token([(b"authorization", b"Basic dXNlcjpwYXNz")]))
|
||||
self.assertIsNone(extract_token([]))
|
||||
|
||||
|
||||
class ClientConcurrencyTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_limit_per_client(self):
|
||||
limiter = ClientConcurrencyLimiter(default_limit=2)
|
||||
client = Identity("team-a", "token", None)
|
||||
async with limiter.slot(client), limiter.slot(client):
|
||||
self.assertEqual(limiter.active("team-a"), 2)
|
||||
with self.assertRaisesRegex(ComputationError, "client busy") as ctx:
|
||||
async with limiter.slot(client):
|
||||
pass
|
||||
self.assertEqual(ctx.exception.kind, "busy")
|
||||
# other clients are not affected
|
||||
async with limiter.slot(Identity("team-b", "token", None)):
|
||||
pass
|
||||
self.assertEqual(limiter.active("team-a"), 0)
|
||||
|
||||
async def test_client_specific_limit_and_unlimited(self):
|
||||
limiter = ClientConcurrencyLimiter(default_limit=1)
|
||||
self.assertEqual(limiter.limit_for(Identity("x", "token", 5)), 5)
|
||||
self.assertEqual(limiter.limit_for(Identity("x", "token", 0)), 0)
|
||||
self.assertEqual(limiter.limit_for(LOCAL), 0, "stdio is never limited")
|
||||
async with limiter.slot(Identity("x", "token", 0)), limiter.slot(Identity("x", "token", 0)):
|
||||
pass
|
||||
|
||||
async def test_slot_is_released_on_error_and_cancel(self):
|
||||
limiter = ClientConcurrencyLimiter(default_limit=1)
|
||||
client = Identity("team-a", "token", None)
|
||||
with self.assertRaises(ValueError):
|
||||
async with limiter.slot(client):
|
||||
raise ValueError
|
||||
task = asyncio.create_task(self._hold(limiter, client))
|
||||
await asyncio.sleep(0.01)
|
||||
task.cancel()
|
||||
with self.assertRaises(asyncio.CancelledError):
|
||||
await task
|
||||
self.assertEqual(limiter.active("team-a"), 0)
|
||||
|
||||
async def _hold(self, limiter, client):
|
||||
async with limiter.slot(client):
|
||||
await asyncio.sleep(10)
|
||||
104
tests/test_calculus.py
Normal file
104
tests/test_calculus.py
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
from mcp_calculator.ops.calculus import analyze_function, calculus, solve_ode
|
||||
from mcp_calculator.ops.context import MathError
|
||||
from tests.support import MathTestCase
|
||||
|
||||
|
||||
class CalculusTest(MathTestCase):
|
||||
def test_derivative(self):
|
||||
self.assertEqual(calculus("derivative", "x^3")["result"], "3*x**2")
|
||||
|
||||
def test_higher_derivative(self):
|
||||
self.assertEqual(calculus("derivative", "x^5", order=3)["result"], "60*x**2")
|
||||
|
||||
def test_mixed_partial(self):
|
||||
self.assertEqual(calculus("derivative", "x^2*y^3", variable="x, y")["result"], "6*x*y**2")
|
||||
|
||||
def test_indefinite_integral(self):
|
||||
r = calculus("integral", "x*e^x")
|
||||
self.assertEqual(r["result"], "(x - 1)*exp(x)")
|
||||
self.assertIn("constant", r["note"])
|
||||
|
||||
def test_definite_integral(self):
|
||||
self.assertEqual(calculus("integral", "e^(-x^2)", lower="-oo", upper="oo")["result"], "sqrt(pi)")
|
||||
|
||||
def test_numeric_integral_fallback(self):
|
||||
r = calculus("integral", "sin(sin(x))", lower="0", upper="1")
|
||||
self.assertTrue(r["method"].startswith("numeric"))
|
||||
self.assertTrue(r["numeric"].startswith("0.4306061031"))
|
||||
|
||||
def test_integral_needs_both_bounds(self):
|
||||
with self.assertRaises(MathError):
|
||||
calculus("integral", "x", lower="0")
|
||||
|
||||
def test_limit(self):
|
||||
self.assertEqual(calculus("limit", "sin(x)/x", point="0")["result"], "1")
|
||||
self.assertEqual(calculus("limit", "(1 + 1/n)^n", variable="n", point="oo")["result"], "E")
|
||||
|
||||
def test_one_sided_limit(self):
|
||||
self.assertEqual(calculus("limit", "1/x", point="0", direction="+")["result"], "oo")
|
||||
self.assertEqual(calculus("limit", "1/x", point="0", direction="-")["result"], "-oo")
|
||||
|
||||
def test_series(self):
|
||||
self.assertEqual(calculus("series", "exp(x)", terms=4)["polynomial"], "x**3/6 + x**2/2 + x + 1")
|
||||
|
||||
def test_infinite_sum(self):
|
||||
r = calculus("sum", "1/n^2", variable="n", lower="1", upper="oo")
|
||||
self.assertEqual(r["result"], "pi**2/6")
|
||||
self.assertIs(r["converges"], True)
|
||||
|
||||
def test_finite_sum_and_product(self):
|
||||
self.assertEqual(calculus("sum", "k", variable="k", lower="1", upper="100")["result"], "5050")
|
||||
self.assertEqual(calculus("product", "k", variable="k", lower="1", upper="5")["result"], "120")
|
||||
|
||||
def test_gradient_hessian_jacobian(self):
|
||||
self.assertEqual(calculus("gradient", "x^2*y", variable="x,y")["result"], ["2*x*y", "x**2"])
|
||||
self.assertEqual(calculus("hessian", "x^2*y", variable="x,y")["result"], [["2*y", "2*x"], ["2*x", "0"]])
|
||||
self.assertEqual(calculus("jacobian", "x*y, x + y", variable="x,y")["result"], [["y", "x"], ["1", "1"]])
|
||||
|
||||
|
||||
class AnalyzeFunctionTest(MathTestCase):
|
||||
def test_cubic(self):
|
||||
r = analyze_function("x^3 - 3x")
|
||||
self.assertEqual({z["x"] for z in r["zeros"]}, {"0", "-sqrt(3)", "sqrt(3)"})
|
||||
self.assertEqual({(e["x"], e["type"]) for e in r["extrema"]}, {("-1", "maximum"), ("1", "minimum")})
|
||||
self.assertEqual(r["inflection_points"][0]["x"], "0")
|
||||
self.assertTrue(r["symmetry"].startswith("odd"))
|
||||
self.assertEqual(r["limit_at_plus_infinity"], "oo")
|
||||
|
||||
def test_rational_function(self):
|
||||
r = analyze_function("1/(x^2 - 1)")
|
||||
self.assertEqual(r["vertical_asymptotes"], ["-1", "1"])
|
||||
self.assertTrue(r["symmetry"].startswith("even"))
|
||||
self.assertEqual(r["limit_at_plus_infinity"], "0")
|
||||
|
||||
def test_saddle_point(self):
|
||||
self.assertTrue(analyze_function("x^3")["extrema"][0]["type"].startswith("saddle"))
|
||||
|
||||
def test_rejects_parameters(self):
|
||||
with self.assertRaisesRegex(MathError, "parameter"):
|
||||
analyze_function("a*x^2")
|
||||
|
||||
|
||||
class OdeTest(MathTestCase):
|
||||
def test_general_solution(self):
|
||||
r = solve_ode("y'' + y = 0")
|
||||
self.assertEqual(r["solutions"], ["C1*sin(x) + C2*cos(x)"])
|
||||
self.assertIn("C1", r["note"])
|
||||
|
||||
def test_initial_value_problem(self):
|
||||
self.assertEqual(solve_ode("y' = 2y", initial_conditions={"y(0)": 3})["solutions"], ["3*exp(2*x)"])
|
||||
|
||||
def test_second_order_ivp(self):
|
||||
self.assertEqual(solve_ode("y'' + y = 0", initial_conditions={"y(0)": 0, "y'(0)": 1})["solutions"], ["sin(x)"])
|
||||
|
||||
def test_custom_names(self):
|
||||
r = solve_ode("N' = -k*N", function="N", variable="t", initial_conditions={"N(0)": "N0"})
|
||||
self.assertEqual(r["solutions"], ["N0*exp(-k*t)"])
|
||||
|
||||
def test_requires_derivative(self):
|
||||
with self.assertRaisesRegex(MathError, "no derivative"):
|
||||
solve_ode("y = x")
|
||||
|
||||
def test_bad_initial_condition_key(self):
|
||||
with self.assertRaisesRegex(MathError, "initial condition"):
|
||||
solve_ode("y' = y", initial_conditions={"z(0)": 1})
|
||||
192
tests/test_clients.py
Normal file
192
tests/test_clients.py
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
"""Client model, clients file and hot-reloading registry."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from mcp_calculator.clients import (
|
||||
Client,
|
||||
ClientConfigError,
|
||||
ClientFile,
|
||||
ClientRegistry,
|
||||
clients_from_env,
|
||||
hash_token,
|
||||
new_token,
|
||||
)
|
||||
|
||||
HASH = "a" * 64
|
||||
|
||||
|
||||
class ClientModelTest(unittest.TestCase):
|
||||
def test_token_client(self):
|
||||
c = Client.from_dict("team-a", {"token_sha256": HASH, "max_concurrent": 3, "allowed_networks": ["10.0.0.0/8"]})
|
||||
self.assertEqual(c.status(), "active")
|
||||
self.assertEqual(c.max_concurrent, 3)
|
||||
self.assertTrue(c.allows_ip("10.1.2.3"))
|
||||
self.assertFalse(c.allows_ip("192.168.1.1"))
|
||||
self.assertFalse(c.allows_ip(None))
|
||||
|
||||
def test_without_networks_every_ip_is_allowed(self):
|
||||
self.assertTrue(Client.from_dict("a", {"token_sha256": HASH}).allows_ip("203.0.113.9"))
|
||||
|
||||
def test_status(self):
|
||||
today = date(2027, 1, 10)
|
||||
self.assertEqual(Client.from_dict("a", {"token_sha256": HASH, "enabled": False}).status(today), "disabled")
|
||||
self.assertEqual(Client.from_dict("a", {"token_sha256": HASH, "expires": "2027-01-09"}).status(today), "expired")
|
||||
self.assertEqual(Client.from_dict("a", {"token_sha256": HASH, "expires": "2027-01-10"}).status(today), "active")
|
||||
|
||||
def test_network_client(self):
|
||||
c = Client.from_dict("batch", {"auth": "network", "allowed_networks": ["10.131.2.15/32"]})
|
||||
self.assertEqual(c.auth, "network")
|
||||
self.assertTrue(c.allows_ip("10.131.2.15"))
|
||||
|
||||
def test_invalid_entries(self):
|
||||
cases = {
|
||||
"bad name": ("Team A", {"token_sha256": HASH}),
|
||||
"missing hash": ("a", {}),
|
||||
"short hash": ("a", {"token_sha256": "abc"}),
|
||||
"unknown field": ("a", {"token_sha256": HASH, "admin": True}),
|
||||
"bad auth": ("a", {"auth": "magic", "token_sha256": HASH}),
|
||||
"network without networks": ("a", {"auth": "network"}),
|
||||
"network with token": ("a", {"auth": "network", "token_sha256": HASH, "allowed_networks": ["10.0.0.1"]}),
|
||||
"bad network": ("a", {"token_sha256": HASH, "allowed_networks": ["10.0.0.300/8"]}),
|
||||
"bad date": ("a", {"token_sha256": HASH, "expires": "tomorrow"}),
|
||||
"negative limit": ("a", {"token_sha256": HASH, "max_concurrent": -1}),
|
||||
"enabled as string": ("a", {"token_sha256": HASH, "enabled": "yes"}),
|
||||
}
|
||||
for label, (name, data) in cases.items():
|
||||
with self.subTest(label), self.assertRaises(ClientConfigError):
|
||||
Client.from_dict(name, data)
|
||||
|
||||
def test_with_changes(self):
|
||||
c = Client.from_dict("a", {"token_sha256": HASH})
|
||||
changed = c.with_changes({"enabled": False, "max_concurrent": 5, "expires": "2030-01-01", "allowed_networks": ["10.0.0.0/8"]})
|
||||
self.assertFalse(changed.enabled)
|
||||
self.assertEqual(changed.max_concurrent, 5)
|
||||
self.assertEqual(changed.expires, date(2030, 1, 1))
|
||||
self.assertIsNotNone(changed.updated)
|
||||
with self.assertRaisesRegex(ClientConfigError, "cannot change"):
|
||||
c.with_changes({"token_sha256": "b" * 64})
|
||||
|
||||
def test_public_view_hides_hash(self):
|
||||
view = Client.from_dict("a", {"token_sha256": HASH, "token_hint": "mcpc_abcd"}).public_view()
|
||||
self.assertNotIn("token_sha256", view)
|
||||
self.assertEqual(view["token_hint"], "mcpc_abcd")
|
||||
self.assertEqual(view["status"], "active")
|
||||
|
||||
def test_tokens(self):
|
||||
token = new_token()
|
||||
self.assertTrue(token.startswith("mcpc_"))
|
||||
self.assertGreaterEqual(len(token), 40)
|
||||
self.assertNotEqual(token, new_token())
|
||||
self.assertEqual(len(hash_token(token)), 64)
|
||||
|
||||
def test_clients_from_env(self):
|
||||
clients = clients_from_env(["svc:0123456789abcdef"])
|
||||
self.assertEqual(clients["svc"].token_sha256, hash_token("0123456789abcdef"))
|
||||
self.assertEqual(clients["svc"].source, "env")
|
||||
|
||||
|
||||
class ClientFileTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.path = Path(self.tmp.name) / "data" / "clients.json"
|
||||
self.file = ClientFile(self.path)
|
||||
|
||||
def test_missing_file_is_empty(self):
|
||||
self.assertEqual(self.file.read(), {})
|
||||
|
||||
def test_update_roundtrip(self):
|
||||
with self.file.update() as clients:
|
||||
clients["a"] = Client.from_dict("a", {"token_sha256": HASH, "note": "Team A"})
|
||||
raw = json.loads(self.path.read_text())
|
||||
self.assertEqual(raw["version"], 1)
|
||||
self.assertEqual(raw["clients"]["a"]["note"], "Team A")
|
||||
self.assertEqual(self.file.read()["a"].token_sha256, HASH)
|
||||
self.assertEqual(stat.S_IMODE(os.stat(self.path).st_mode), 0o640)
|
||||
self.assertEqual([p.name for p in self.path.parent.iterdir() if p.suffix == ".tmp"], [])
|
||||
|
||||
def test_failed_update_leaves_file_untouched(self):
|
||||
with self.file.update() as clients:
|
||||
clients["a"] = Client.from_dict("a", {"token_sha256": HASH})
|
||||
before = self.path.read_text()
|
||||
with self.assertRaises(RuntimeError), self.file.update() as clients:
|
||||
clients.clear()
|
||||
raise RuntimeError("abort")
|
||||
self.assertEqual(self.path.read_text(), before)
|
||||
|
||||
def test_invalid_json(self):
|
||||
self.path.parent.mkdir(parents=True)
|
||||
self.path.write_text("{not json")
|
||||
with self.assertRaisesRegex(ClientConfigError, "invalid JSON"):
|
||||
self.file.read()
|
||||
|
||||
def test_duplicate_tokens_rejected(self):
|
||||
self.path.parent.mkdir(parents=True)
|
||||
self.path.write_text(json.dumps({"clients": {"a": {"token_sha256": HASH}, "b": {"token_sha256": HASH}}}))
|
||||
with self.assertRaisesRegex(ClientConfigError, "same token"):
|
||||
self.file.read()
|
||||
|
||||
|
||||
class RegistryTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.file = ClientFile(Path(self.tmp.name) / "clients.json")
|
||||
|
||||
def _write(self, clients: dict) -> None:
|
||||
with self.file.update() as current:
|
||||
current.clear()
|
||||
current.update({n: Client.from_dict(n, d) for n, d in clients.items()})
|
||||
# make sure the fingerprint changes even on file systems with coarse timestamps
|
||||
future = time.time() + 1 + len(clients)
|
||||
os.utime(self.file.path, (future, future))
|
||||
|
||||
def test_hot_reload(self):
|
||||
self._write({"a": {"token_sha256": HASH}})
|
||||
registry = ClientRegistry(self.file, {}, reload_seconds=0.01)
|
||||
self.assertEqual(set(registry.snapshot().clients), {"a"})
|
||||
self._write({"a": {"token_sha256": HASH}, "b": {"token_sha256": "b" * 64}})
|
||||
time.sleep(0.02)
|
||||
self.assertEqual(set(registry.snapshot().clients), {"a", "b"})
|
||||
self.assertIn("b" * 64, registry.snapshot().by_hash)
|
||||
|
||||
def test_invalid_change_keeps_previous_state(self):
|
||||
self._write({"a": {"token_sha256": HASH}})
|
||||
registry = ClientRegistry(self.file, {}, reload_seconds=0.01)
|
||||
self.file.path.write_text("{broken")
|
||||
os.utime(self.file.path, (time.time() + 5, time.time() + 5))
|
||||
time.sleep(0.02)
|
||||
with self.assertLogs("mcp_calculator.clients", "ERROR"):
|
||||
snapshot = registry.snapshot()
|
||||
self.assertEqual(set(snapshot.clients), {"a"})
|
||||
|
||||
def test_invalid_file_at_startup_fails(self):
|
||||
self.file.path.write_text("{broken")
|
||||
with self.assertRaises(ClientConfigError):
|
||||
ClientRegistry(self.file, {})
|
||||
|
||||
def test_static_and_file_clients_are_merged(self):
|
||||
self._write({"a": {"token_sha256": HASH}})
|
||||
registry = ClientRegistry(self.file, clients_from_env(["svc:0123456789abcdef"]))
|
||||
self.assertEqual(set(registry.snapshot().clients), {"a", "svc"})
|
||||
|
||||
def test_name_clash_between_sources_fails(self):
|
||||
self._write({"svc": {"token_sha256": HASH}})
|
||||
with self.assertRaisesRegex(ClientConfigError, "both"):
|
||||
ClientRegistry(self.file, clients_from_env(["svc:0123456789abcdef"]))
|
||||
|
||||
def test_disabled_without_sources(self):
|
||||
self.assertFalse(ClientRegistry(None, {}).enabled)
|
||||
self.assertTrue(ClientRegistry(self.file, {}).enabled)
|
||||
|
||||
def test_expiry_is_evaluated_at_request_time(self):
|
||||
yesterday = (date.today() - timedelta(days=1)).isoformat()
|
||||
self._write({"old": {"token_sha256": HASH, "expires": yesterday}})
|
||||
self.assertEqual(ClientRegistry(self.file, {}).snapshot().clients["old"].status(), "expired")
|
||||
61
tests/test_config.py
Normal file
61
tests/test_config.py
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import unittest
|
||||
|
||||
from mcp_calculator.config import load_settings
|
||||
|
||||
TOKEN = "0123456789abcdef"
|
||||
|
||||
|
||||
class SettingsTest(unittest.TestCase):
|
||||
def test_defaults(self):
|
||||
s = load_settings([], env={})
|
||||
self.assertEqual(s.transport, "stdio")
|
||||
self.assertEqual(s.executor, "process")
|
||||
self.assertEqual(s.auth_tokens, [])
|
||||
self.assertEqual(s.clients_file, "")
|
||||
self.assertEqual(s.admin_tokens, [])
|
||||
self.assertTrue(s.metrics)
|
||||
self.assertEqual(s.max_queue, 20)
|
||||
self.assertEqual(s.max_queue_wait_seconds, 30)
|
||||
self.assertEqual(s.client_max_concurrent, 10)
|
||||
self.assertEqual(s.trusted_proxies, [])
|
||||
|
||||
def test_env_and_cli_precedence(self):
|
||||
env = {"MCP_CALC_TRANSPORT": "http", "MCP_CALC_PORT": "9000", "MCP_CALC_AUTH_TOKENS": f"a:{TOKEN}, b:{TOKEN}x"}
|
||||
s = load_settings(["--port", "9100"], env=env)
|
||||
self.assertEqual(s.transport, "http")
|
||||
self.assertEqual(s.port, 9100)
|
||||
self.assertEqual(s.auth_tokens, [f"a:{TOKEN}", f"b:{TOKEN}x"])
|
||||
|
||||
def test_limits_from_env(self):
|
||||
s = load_settings([], env={"MCP_CALC_MAX_MATRIX_SIZE": "5", "MCP_CALC_TIMEOUT_SECONDS": "2.5"})
|
||||
self.assertEqual(s.limits.max_matrix_size, 5)
|
||||
self.assertEqual(s.timeout_seconds, 2.5)
|
||||
|
||||
def test_booleans(self):
|
||||
self.assertFalse(load_settings([], env={"MCP_CALC_METRICS": "false"}).metrics)
|
||||
self.assertTrue(load_settings([], env={"MCP_CALC_METRICS": "On"}).metrics)
|
||||
|
||||
def test_admin_settings(self):
|
||||
s = load_settings([], env={"MCP_CALC_CLIENTS_FILE": "/data/clients.json", "MCP_CALC_ADMIN_TOKENS": f"ops:{TOKEN}"})
|
||||
self.assertEqual(s.admin_tokens, [f"ops:{TOKEN}"])
|
||||
self.assertEqual(s.admin_port, 9090)
|
||||
self.assertEqual(s.admin_host, "127.0.0.1")
|
||||
|
||||
def test_invalid_settings(self):
|
||||
for env in (
|
||||
{"MCP_CALC_TRANSPORT": "websocket"},
|
||||
{"MCP_CALC_PORT": "abc"},
|
||||
{"MCP_CALC_WORKERS": "0"},
|
||||
{"MCP_CALC_MAX_QUEUE": "-1"},
|
||||
{"MCP_CALC_MAX_QUEUE_WAIT_SECONDS": "0"},
|
||||
{"MCP_CALC_CLIENT_MAX_CONCURRENT": "-1"},
|
||||
{"MCP_CALC_MAX_CONNECTIONS": "0"},
|
||||
{"MCP_CALC_METRICS": "maybe"},
|
||||
{"MCP_CALC_PATH": "mcp"},
|
||||
{"MCP_CALC_AUTH_TOKENS": "no-name-token-0123456789"},
|
||||
{"MCP_CALC_AUTH_TOKENS": "name:short"},
|
||||
{"MCP_CALC_ADMIN_TOKENS": f"ops:{TOKEN}"}, # admin API without clients file
|
||||
{"MCP_CALC_CLIENTS_FILE": "/x.json", "MCP_CALC_ADMIN_TOKENS": f"ops:{TOKEN}", "MCP_CALC_ADMIN_PORT": "8080"},
|
||||
):
|
||||
with self.subTest(env=env), self.assertRaises(SystemExit):
|
||||
load_settings([], env=env)
|
||||
113
tests/test_footprint.py
Normal file
113
tests/test_footprint.py
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
"""Guards for the memory layout described in docs/memory.md.
|
||||
|
||||
* The server process must not import the math libraries (they live in the workers).
|
||||
* Workers must not import the MCP/HTTP stack (spawn re-imports the main module).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import builtins
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import textwrap
|
||||
import typing
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from mcp_calculator import executor as executor_module
|
||||
from mcp_calculator.config import load_settings
|
||||
from mcp_calculator.executor import ComputationError, Executor
|
||||
from mcp_calculator.ops import spec, stats
|
||||
from tests.support import slow
|
||||
|
||||
MATH_LIBS = ("sympy", "numpy", "scipy", "pint", "mpmath")
|
||||
SERVER_LIBS = ("mcp", "uvicorn", "starlette", "pydantic", "anyio")
|
||||
|
||||
|
||||
def _run_python(code: str, modules: tuple[str, ...]) -> dict:
|
||||
"""Run ``code`` as a script (MODULES is replaced by the module names); return its last JSON line."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
script = Path(tmp) / "probe.py"
|
||||
script.write_text(textwrap.dedent(code).replace("MODULES", repr(modules)))
|
||||
out = subprocess.run([sys.executable, str(script)], capture_output=True, text=True, timeout=120, check=True)
|
||||
return json.loads(out.stdout.strip().splitlines()[-1])
|
||||
|
||||
|
||||
@slow
|
||||
class ImportFootprintTest(unittest.TestCase):
|
||||
def test_server_process_does_not_import_math_libraries(self):
|
||||
loaded = _run_python(
|
||||
"""
|
||||
import json, sys
|
||||
import mcp_calculator.__main__, mcp_calculator.server, mcp_calculator.http_app, mcp_calculator.executor
|
||||
print(json.dumps({m: m in sys.modules for m in MODULES}))
|
||||
""",
|
||||
MATH_LIBS,
|
||||
)
|
||||
self.assertFalse(any(loaded.values()), loaded)
|
||||
|
||||
def test_workers_do_not_import_server_stack(self):
|
||||
# Mirrors the console script: the main module imports mcp_calculator.__main__.
|
||||
loaded = _run_python(
|
||||
"""
|
||||
import json, sys
|
||||
from mcp_calculator.__main__ import main # noqa: F401
|
||||
|
||||
def probe():
|
||||
import os
|
||||
loaded = {m: m in sys.modules for m in MODULES}
|
||||
loaded["single_threaded_blas"] = os.environ.get("OPENBLAS_NUM_THREADS") == "1"
|
||||
return loaded
|
||||
|
||||
if __name__ == "__main__":
|
||||
from mcp_calculator.config import load_settings
|
||||
from mcp_calculator.executor import Executor
|
||||
|
||||
executor = Executor(load_settings([], env={"MCP_CALC_WORKERS": "1"}))
|
||||
executor.start()
|
||||
result = executor._pool.schedule(probe, timeout=60).result()
|
||||
executor.stop()
|
||||
print(json.dumps(result))
|
||||
""",
|
||||
MATH_LIBS + SERVER_LIBS,
|
||||
)
|
||||
self.assertTrue(loaded["sympy"] and loaded["scipy"], "workers should preload the math libraries")
|
||||
self.assertFalse(any(loaded[m] for m in ("mcp", "uvicorn", "starlette")), loaded)
|
||||
self.assertTrue(loaded["single_threaded_blas"])
|
||||
|
||||
|
||||
class MemoryLimitTest(unittest.TestCase):
|
||||
def test_limit_is_relative_to_loaded_size(self):
|
||||
"""RLIMIT_AS = current address space + configured extra (Linux path, simulated)."""
|
||||
with (
|
||||
mock.patch.object(builtins, "open", lambda *a, **k: io.StringIO("100000 5000 0 0 0 0 0")),
|
||||
mock.patch.object(executor_module.resource, "setrlimit") as setrlimit,
|
||||
):
|
||||
executor_module._limit_address_space(512)
|
||||
expected = 100000 * os.sysconf("SC_PAGE_SIZE") + 512 * 1024 * 1024
|
||||
setrlimit.assert_called_once_with(executor_module.resource.RLIMIT_AS, (expected, expected))
|
||||
|
||||
def test_failure_is_not_fatal(self):
|
||||
with mock.patch.object(builtins, "open", side_effect=OSError("no /proc")), self.assertLogs("mcp_calculator.executor", "WARNING") as logs:
|
||||
executor_module._limit_address_space(512)
|
||||
self.assertIn("could not set memory limit", logs.output[0])
|
||||
|
||||
|
||||
class TaskDispatchTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_only_public_ops_functions_can_be_called(self):
|
||||
executor = Executor(load_settings([], env={"MCP_CALC_EXECUTOR": "inline"}))
|
||||
for task in ("os.system", "algebra._equivalent", "algebra", "parsing.parse", "algebra.__class__"):
|
||||
with self.subTest(task=task), self.assertRaisesRegex(ComputationError, "unknown task"):
|
||||
await executor.run(task)
|
||||
|
||||
|
||||
class SpecConsistencyTest(unittest.TestCase):
|
||||
def test_distribution_tables_are_consistent(self):
|
||||
names = set(typing.get_args(spec.Distribution))
|
||||
self.assertEqual(names, set(spec.DISTRIBUTION_PARAMETERS))
|
||||
self.assertEqual(names, set(stats._DISTRIBUTIONS))
|
||||
159
tests/test_http_security.py
Normal file
159
tests/test_http_security.py
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
"""End-to-end: authentication with a clients file, hot reload, admin API, per-client limit,
|
||||
cancel on disconnect and the connection limit, against a real server process."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import http.client
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import socket
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from mcp_calculator import admin_cli
|
||||
from mcp_calculator.admin import AuditLog, ClientAdmin
|
||||
from mcp_calculator.clients import ClientFile
|
||||
from tests.support import slow
|
||||
from tests.test_transports import _free_port, _http, _start_server
|
||||
|
||||
ADMIN_TOKEN = "admin-token-0123456789"
|
||||
|
||||
|
||||
def _call(port: int, token: str | None, expression: str = "6*7", *, header: str = "Authorization") -> tuple[int, dict | str]:
|
||||
body = {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "evaluate", "arguments": {"expression": expression}}}
|
||||
if header == "Authorization" or token is None:
|
||||
status, text = _http("POST", f"http://127.0.0.1:{port}/mcp", body, token=token)
|
||||
else: # X-API-Key
|
||||
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=30)
|
||||
conn.request("POST", "/mcp", json.dumps(body), {"Content-Type": "application/json", "Accept": "application/json, text/event-stream", header: token})
|
||||
response = conn.getresponse()
|
||||
status, text = response.status, response.read().decode()
|
||||
conn.close()
|
||||
try:
|
||||
return status, json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
return status, text
|
||||
|
||||
|
||||
def _metric(port: int, pattern: str) -> float:
|
||||
text = _http("GET", f"http://127.0.0.1:{port}/metrics")[1]
|
||||
match = re.search(pattern + r" ([0-9.e+]+)", text)
|
||||
return float(match.group(1)) if match else 0.0
|
||||
|
||||
|
||||
def _wait_for(predicate, timeout: float = 5.0) -> bool:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
if predicate():
|
||||
return True
|
||||
time.sleep(0.1)
|
||||
return False
|
||||
|
||||
|
||||
@slow
|
||||
class ClientsFileAuthTest(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.tmp = tempfile.TemporaryDirectory()
|
||||
cls.clients_path = Path(cls.tmp.name) / "clients.json"
|
||||
admin = ClientAdmin(ClientFile(cls.clients_path), AuditLog(Path(cls.tmp.name) / "audit.log"))
|
||||
_, cls.token = admin.create("team-a", actor="test")
|
||||
_, cls.single = admin.create("single", actor="test", max_concurrent=1)
|
||||
cls.port, cls.admin_port = _free_port(), _free_port()
|
||||
cls.proc = _start_server(
|
||||
cls.port,
|
||||
MCP_CALC_CLIENTS_FILE=str(cls.clients_path),
|
||||
MCP_CALC_CLIENTS_RELOAD_SECONDS="0.2",
|
||||
MCP_CALC_ADMIN_TOKENS=f"ops:{ADMIN_TOKEN}",
|
||||
MCP_CALC_ADMIN_PORT=str(cls.admin_port),
|
||||
MCP_CALC_TIMEOUT_SECONDS="20",
|
||||
MCP_CALC_LOG_LEVEL="WARNING",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls) -> None:
|
||||
cls.proc.terminate()
|
||||
cls.proc.communicate(timeout=60)
|
||||
cls.tmp.cleanup()
|
||||
|
||||
def cli(self, *args: str) -> None:
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
self.assertEqual(admin_cli.main(["--file", str(self.clients_path), *args]), 0)
|
||||
|
||||
def admin(self, method: str, path: str, body: dict | None = None, token: str = ADMIN_TOKEN) -> tuple[int, dict]:
|
||||
status, text = _http(method, f"http://127.0.0.1:{self.admin_port}{path}", body, token=token)
|
||||
return status, json.loads(text) if text else {}
|
||||
|
||||
def test_token_header_variants(self):
|
||||
status, body = _call(self.port, self.token)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertIn("42", body["result"]["content"][0]["text"])
|
||||
self.assertEqual(_call(self.port, self.token, header="X-API-Key")[0], 200)
|
||||
self.assertEqual(_call(self.port, None)[0], 401)
|
||||
self.assertEqual(_call(self.port, "mcpc_wrong")[0], 401)
|
||||
|
||||
def test_disable_via_cli_takes_effect_without_restart(self):
|
||||
_, token = ClientAdmin(ClientFile(self.clients_path), AuditLog(None)).create("temp-cli", actor="test")
|
||||
self.assertTrue(_wait_for(lambda: _call(self.port, token)[0] == 200))
|
||||
self.cli("disable", "temp-cli")
|
||||
self.assertTrue(_wait_for(lambda: _call(self.port, token)[0] == 403), "disabled client must be rejected after reload")
|
||||
self.cli("enable", "temp-cli")
|
||||
self.assertTrue(_wait_for(lambda: _call(self.port, token)[0] == 200))
|
||||
|
||||
def test_admin_api_over_http(self):
|
||||
self.assertEqual(self.admin("GET", "/admin/clients", token="wrong")[0], 401)
|
||||
status, body = self.admin("POST", "/admin/clients", {"name": "temp-api", "max_concurrent": 2})
|
||||
self.assertEqual(status, 201)
|
||||
token = body["token"]
|
||||
self.assertEqual(_call(self.port, token)[0], 200, "the replica serving the admin call applies it immediately")
|
||||
status, body = self.admin("POST", "/admin/clients/temp-api/rotate")
|
||||
self.assertEqual(_call(self.port, token)[0], 401, "old token is invalid after rotation")
|
||||
self.assertEqual(_call(self.port, body["token"])[0], 200)
|
||||
self.assertEqual(self.admin("DELETE", "/admin/clients/temp-api")[0], 200)
|
||||
self.assertEqual(_call(self.port, body["token"])[0], 401)
|
||||
|
||||
def test_per_client_concurrency_limit(self):
|
||||
blocker = threading.Thread(target=_call, args=(self.port, self.single, "9^9^9^9"), daemon=True)
|
||||
blocker.start()
|
||||
self.assertTrue(_wait_for(lambda: _metric(self.port, r'mcp_calc_client_inflight_computations\{client="single"\}') == 1))
|
||||
status, body = _call(self.port, self.single)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertTrue(body["result"]["isError"])
|
||||
self.assertIn("client busy", body["result"]["content"][0]["text"])
|
||||
self.assertEqual(_call(self.port, self.token)[0], 200, "other clients are not affected")
|
||||
|
||||
def test_disconnect_cancels_computation(self):
|
||||
before = _metric(self.port, r'mcp_calc_tool_calls_total\{client="team-a",status="cancelled",tool="evaluate"\}')
|
||||
body = {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "evaluate", "arguments": {"expression": "9^9^9^9"}}}
|
||||
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=0.5)
|
||||
conn.request("POST", "/mcp", json.dumps(body), {"Content-Type": "application/json", "Accept": "application/json, text/event-stream", "Authorization": f"Bearer {self.token}"})
|
||||
with self.assertRaises((TimeoutError, socket.timeout)):
|
||||
conn.getresponse()
|
||||
conn.close()
|
||||
pattern = r'mcp_calc_tool_calls_total\{client="team-a",status="cancelled",tool="evaluate"\}'
|
||||
self.assertTrue(_wait_for(lambda: _metric(self.port, pattern) == before + 1), "computation must be cancelled well before its 20 s timeout")
|
||||
|
||||
|
||||
@slow
|
||||
class ConnectionLimitTest(unittest.TestCase):
|
||||
def test_connections_beyond_the_limit_get_503(self):
|
||||
port = _free_port()
|
||||
# uvicorn counts the incoming connection itself: with a limit of 2, one long request
|
||||
# is served and the next concurrent connection is rejected.
|
||||
proc = _start_server(port, MCP_CALC_MAX_CONNECTIONS="2", MCP_CALC_TIMEOUT_SECONDS="3", MCP_CALC_LOG_LEVEL="WARNING")
|
||||
try:
|
||||
blocker = threading.Thread(target=_call, args=(port, None, "9^9^9^9"), daemon=True)
|
||||
blocker.start()
|
||||
time.sleep(0.5)
|
||||
self.assertEqual(_call(port, None)[0], 503)
|
||||
blocker.join(timeout=10)
|
||||
self.assertEqual(_call(port, None)[0], 200)
|
||||
finally:
|
||||
proc.terminate()
|
||||
proc.communicate(timeout=30)
|
||||
|
||||
74
tests/test_linalg.py
Normal file
74
tests/test_linalg.py
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
from mcp_calculator.config import Limits
|
||||
from mcp_calculator.ops.context import MathError, set_limits
|
||||
from mcp_calculator.ops.linalg import matrix
|
||||
from tests.support import MathTestCase
|
||||
|
||||
A = [[1, 2], [3, 4]]
|
||||
|
||||
|
||||
class ExactMatrixTest(MathTestCase):
|
||||
def test_determinant(self):
|
||||
self.assertEqual(matrix("determinant", A)["result"], "-2")
|
||||
|
||||
def test_symbolic_determinant(self):
|
||||
self.assertEqual(matrix("determinant", [["a", "b"], ["c", "d"]])["result"], "a*d - b*c")
|
||||
|
||||
def test_inverse_is_exact(self):
|
||||
self.assertEqual(matrix("inverse", A)["result"], [["-2", "1"], ["3/2", "-1/2"]])
|
||||
|
||||
def test_singular_inverse(self):
|
||||
with self.assertRaisesRegex(MathError, "singular"):
|
||||
matrix("inverse", [[1, 2], [2, 4]])
|
||||
|
||||
def test_eigenvalues(self):
|
||||
values = {e["value"] for e in matrix("eigenvalues", [[2, 1], [1, 2]])["result"]}
|
||||
self.assertEqual(values, {"1", "3"})
|
||||
|
||||
def test_eigenvectors(self):
|
||||
r = matrix("eigenvectors", [[2, 0], [0, 3]])["result"]
|
||||
self.assertEqual({e["value"]: e["vectors"] for e in r}, {"2": [["1", "0"]], "3": [["0", "1"]]})
|
||||
|
||||
def test_solve_linear_system(self):
|
||||
self.assertEqual(matrix("solve", [[1, 1], [1, -1]], [10, 2])["result"], [["6"], ["4"]])
|
||||
|
||||
def test_solve_inconsistent(self):
|
||||
self.assertIsNone(matrix("solve", [[1, 1], [1, 1]], [1, 2])["result"])
|
||||
|
||||
def test_multiply_and_shapes(self):
|
||||
self.assertEqual(matrix("multiply", A, [[1], [1]])["result"], [["3"], ["7"]])
|
||||
with self.assertRaisesRegex(MathError, "not compatible"):
|
||||
matrix("multiply", A, [[1, 2, 3]])
|
||||
|
||||
def test_power_rank_trace_transpose(self):
|
||||
self.assertEqual(matrix("power", A, exponent=2)["result"], [["7", "10"], ["15", "22"]])
|
||||
self.assertEqual(matrix("rank", [[1, 2], [2, 4]])["result"], 1)
|
||||
self.assertEqual(matrix("trace", A)["result"], "5")
|
||||
self.assertEqual(matrix("transpose", A)["result"], [["1", "3"], ["2", "4"]])
|
||||
|
||||
def test_vectors(self):
|
||||
self.assertEqual(matrix("dot", [1, 2, 3], [4, 5, 6])["result"], "32")
|
||||
self.assertEqual(matrix("cross", [1, 0, 0], [0, 1, 0])["result"], [["0"], ["0"], ["1"]])
|
||||
|
||||
def test_characteristic_polynomial(self):
|
||||
self.assertEqual(matrix("characteristic_polynomial", A)["result"], "lambda**2 - 5*lambda - 2")
|
||||
|
||||
|
||||
class NumericMatrixTest(MathTestCase):
|
||||
def test_numeric_mode(self):
|
||||
r = matrix("determinant", A, numeric_mode=True)
|
||||
self.assertTrue(r["mode"].startswith("numeric"))
|
||||
self.assertApprox(r["result"], -2)
|
||||
|
||||
def test_svd_is_numeric(self):
|
||||
self.assertEqual(len(matrix("svd", A)["singular_values"]), 2)
|
||||
|
||||
|
||||
class MatrixValidationTest(MathTestCase):
|
||||
def test_size_limit(self):
|
||||
set_limits(Limits(max_matrix_size=2))
|
||||
with self.assertRaisesRegex(MathError, "larger than"):
|
||||
matrix("determinant", [[1, 2, 3], [4, 5, 6], [7, 8, 9]])
|
||||
|
||||
def test_ragged_rows(self):
|
||||
with self.assertRaisesRegex(MathError, "same length"):
|
||||
matrix("determinant", [[1, 2], [3]])
|
||||
94
tests/test_number_theory_units.py
Normal file
94
tests/test_number_theory_units.py
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
from mcp_calculator.ops.context import MathError
|
||||
from mcp_calculator.ops.number_theory import number_theory
|
||||
from mcp_calculator.ops.units import convert_units
|
||||
from tests.support import MathTestCase
|
||||
|
||||
|
||||
class NumberTheoryTest(MathTestCase):
|
||||
def test_operations(self):
|
||||
cases = [
|
||||
("gcd", [48, 180], 12),
|
||||
("lcm", [4, 6, 10], 60),
|
||||
("is_prime", [97], True),
|
||||
("is_prime", ["2305843009213693951"], True),
|
||||
("next_prime", [100], 101),
|
||||
("previous_prime", [100], 97),
|
||||
("nth_prime", [10], 29),
|
||||
("totient", [36], 12),
|
||||
("mod", [-7, 3], 2),
|
||||
("mod_pow", [2, 100, 97], 16),
|
||||
("mod_inverse", [3, 11], 4),
|
||||
("binomial", [49, 6], 13983816),
|
||||
("permutations", [5, 2], 20),
|
||||
("fibonacci", [50], 12586269025),
|
||||
("factorial", [10], 3628800),
|
||||
]
|
||||
for operation, values, expected in cases:
|
||||
with self.subTest(operation=operation, values=values):
|
||||
self.assertEqual(number_theory(operation, values)["result"], expected)
|
||||
|
||||
def test_big_results_are_strings(self):
|
||||
r = number_theory("factorial", [30])
|
||||
self.assertEqual(r["result"], "265252859812191058636308480000000")
|
||||
self.assertEqual(r["digits"], 33)
|
||||
|
||||
def test_factorize(self):
|
||||
r = number_theory("factorize", [360])
|
||||
self.assertEqual(r["result"], {"2": 3, "3": 2, "5": 1})
|
||||
self.assertEqual(r["product_form"], "2^3 * 3^2 * 5")
|
||||
|
||||
def test_crt(self):
|
||||
self.assertEqual(number_theory("crt", [2, 3, 2], moduli=[3, 5, 7])["result"], 23)
|
||||
|
||||
def test_base_convert(self):
|
||||
self.assertEqual(number_theory("base_convert", ["ff"], from_base=16, to_base=2)["result"], "11111111")
|
||||
self.assertEqual(number_theory("base_convert", ["0x1F"], from_base=16, to_base=10)["result"], "31")
|
||||
self.assertEqual(number_theory("base_convert", ["-10"], to_base=2)["result"], "-1010")
|
||||
|
||||
def test_continued_fraction(self):
|
||||
self.assertEqual(number_theory("continued_fraction", ["415/93"])["result"], [4, 2, 6, 7])
|
||||
|
||||
def test_primes_in_range(self):
|
||||
self.assertEqual(number_theory("primes_in_range", [10, 30])["result"], [11, 13, 17, 19, 23, 29])
|
||||
|
||||
def test_arity_errors(self):
|
||||
with self.assertRaisesRegex(MathError, "values"):
|
||||
number_theory("mod_pow", [2, 3])
|
||||
|
||||
def test_no_inverse(self):
|
||||
with self.assertRaisesRegex(MathError, "no inverse"):
|
||||
number_theory("mod_inverse", [2, 4])
|
||||
|
||||
def test_non_integer(self):
|
||||
with self.assertRaisesRegex(MathError, "not an integer"):
|
||||
number_theory("gcd", ["1.5", 3])
|
||||
|
||||
|
||||
class UnitsTest(MathTestCase):
|
||||
def test_speed(self):
|
||||
self.assertApprox(convert_units("100 km/h", "m/s")["magnitude"], 27.7777777778)
|
||||
|
||||
def test_temperature(self):
|
||||
self.assertApprox(convert_units("25 degC", "degF")["magnitude"], 77)
|
||||
|
||||
def test_quantity_arithmetic(self):
|
||||
self.assertApprox(convert_units("5 km/h * 2 h", "m")["magnitude"], 10000)
|
||||
|
||||
def test_force(self):
|
||||
self.assertApprox(convert_units("1.5 kg * 9.81 m/s^2", "N")["magnitude"], 14.715)
|
||||
|
||||
def test_base_units(self):
|
||||
r = convert_units("3 kWh")
|
||||
self.assertApprox(r["magnitude"], 10_800_000)
|
||||
self.assertIn("note", r)
|
||||
|
||||
def test_data_units(self):
|
||||
self.assertApprox(convert_units("1 GiB", "MB")["magnitude"], 1073.741824)
|
||||
|
||||
def test_incompatible(self):
|
||||
with self.assertRaisesRegex(MathError, "incompatible"):
|
||||
convert_units("5 kg", "m")
|
||||
|
||||
def test_unknown_unit(self):
|
||||
with self.assertRaisesRegex(MathError, "unknown"):
|
||||
convert_units("5 flurbs", "m")
|
||||
72
tests/test_overload.py
Normal file
72
tests/test_overload.py
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
"""Overload protection in the executor: queue wait limit and cancellation (real worker processes)."""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
import unittest
|
||||
|
||||
from mcp_calculator.config import load_settings
|
||||
from mcp_calculator.executor import ComputationError, Executor
|
||||
from tests.support import slow
|
||||
|
||||
|
||||
async def _warm(executor: Executor) -> None:
|
||||
deadline = time.time() + 30
|
||||
while not executor.healthy and time.time() < deadline:
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
|
||||
@slow
|
||||
class QueueWaitTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_waiting_longer_than_the_limit_is_rejected(self):
|
||||
env = {"MCP_CALC_WORKERS": "1", "MCP_CALC_TIMEOUT_SECONDS": "5", "MCP_CALC_MAX_QUEUE_WAIT_SECONDS": "0.5"}
|
||||
executor = Executor(load_settings([], env=env))
|
||||
executor.start()
|
||||
self.addCleanup(executor.stop)
|
||||
await _warm(executor)
|
||||
|
||||
blocker = asyncio.create_task(executor.run("algebra.evaluate", expression="9^9^9^9"))
|
||||
await asyncio.sleep(0.2)
|
||||
started = time.perf_counter()
|
||||
with self.assertRaisesRegex(ComputationError, "no worker became free") as ctx:
|
||||
await executor.run("algebra.evaluate", expression="1+1")
|
||||
waited = time.perf_counter() - started
|
||||
self.assertEqual(ctx.exception.kind, "busy")
|
||||
self.assertGreaterEqual(waited, 0.45)
|
||||
self.assertLess(waited, 2)
|
||||
blocker.cancel()
|
||||
with self.assertRaises(asyncio.CancelledError):
|
||||
await blocker
|
||||
|
||||
|
||||
@slow
|
||||
class CancellationTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def asyncSetUp(self):
|
||||
self.executor = Executor(load_settings([], env={"MCP_CALC_WORKERS": "1", "MCP_CALC_TIMEOUT_SECONDS": "30"}))
|
||||
self.executor.start()
|
||||
self.addCleanup(self.executor.stop)
|
||||
await _warm(self.executor)
|
||||
|
||||
async def test_cancelling_a_running_computation_frees_the_worker(self):
|
||||
running = asyncio.create_task(self.executor.run("algebra.evaluate", expression="9^9^9^9"))
|
||||
await asyncio.sleep(0.5)
|
||||
running.cancel()
|
||||
with self.assertRaises(asyncio.CancelledError):
|
||||
await running
|
||||
self.assertEqual(self.executor.inflight, 0)
|
||||
started = time.perf_counter()
|
||||
self.assertEqual((await self.executor.run("algebra.evaluate", expression="1+1"))["exact"], "2")
|
||||
self.assertLess(time.perf_counter() - started, 10, "the worker must be terminated, not left computing for 30 s")
|
||||
|
||||
async def test_cancelling_a_queued_computation_never_starts_it(self):
|
||||
running = asyncio.create_task(self.executor.run("algebra.evaluate", expression="9^9^9^9"))
|
||||
await asyncio.sleep(0.2)
|
||||
queued = asyncio.create_task(self.executor.run("algebra.evaluate", expression="2+2"))
|
||||
await asyncio.sleep(0.2)
|
||||
self.assertEqual(self.executor.inflight, 2)
|
||||
queued.cancel()
|
||||
with self.assertRaises(asyncio.CancelledError):
|
||||
await queued
|
||||
self.assertEqual(self.executor.inflight, 1)
|
||||
running.cancel()
|
||||
with self.assertRaises(asyncio.CancelledError):
|
||||
await running
|
||||
117
tests/test_parsing.py
Normal file
117
tests/test_parsing.py
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import sympy as sp
|
||||
|
||||
from mcp_calculator.config import Limits
|
||||
from mcp_calculator.ops.context import MathError, set_limits
|
||||
from mcp_calculator.ops.parsing import apply_assumptions, parse, parse_symbols
|
||||
from tests.support import MathTestCase
|
||||
|
||||
x, y = sp.symbols("x y")
|
||||
|
||||
VALID = [
|
||||
("2x + 3(x+1)", 5 * x + 3),
|
||||
("x^2", x**2),
|
||||
("x**2", x**2),
|
||||
("0.1 + 0.2", sp.Rational(3, 10)),
|
||||
("5!", 120),
|
||||
("sin(30*deg)", sp.Rational(1, 2)),
|
||||
("e^(I*pi)", -1),
|
||||
("ln(e)", 1),
|
||||
("log(8, 2)", 3),
|
||||
("log10(1000)", 3),
|
||||
("abs(-3)", 3),
|
||||
("max(3, 7)", 7),
|
||||
("10 % 3", 1),
|
||||
("1e5", 100000),
|
||||
("2.5e-3", sp.Rational(1, 400)),
|
||||
("√8", 2 * sp.sqrt(2)),
|
||||
("3×4÷2", 6),
|
||||
("2π", 2 * sp.pi),
|
||||
("x²", x**2),
|
||||
("arcsin(1)", sp.pi / 2),
|
||||
("inf", sp.oo),
|
||||
]
|
||||
|
||||
MALICIOUS = [
|
||||
"__import__('os').system('ls')",
|
||||
"().__class__.__bases__",
|
||||
"x.subs(x, 1)",
|
||||
"lambda x: x",
|
||||
"open('/etc/passwd')",
|
||||
"import os",
|
||||
"getattr(x, 'y')",
|
||||
"[1, 2, 3]",
|
||||
"Symbol('x')",
|
||||
"_x + 1",
|
||||
"a; b",
|
||||
"exec\n('1')",
|
||||
"{1: 2}",
|
||||
"x if x else y",
|
||||
"`ls`",
|
||||
"\\x41",
|
||||
]
|
||||
|
||||
|
||||
class ParseTest(MathTestCase):
|
||||
def test_valid_expressions(self):
|
||||
for text, expected in VALID:
|
||||
with self.subTest(text=text):
|
||||
result = parse(text)
|
||||
self.assertTrue(result == expected or sp.simplify(result - expected) == 0, f"{text!r} -> {result}")
|
||||
|
||||
def test_relations(self):
|
||||
self.assertEqual(parse("x^2 = 4"), sp.Eq(x**2, 4))
|
||||
self.assertEqual(parse("x^2 == 4"), sp.Eq(x**2, 4))
|
||||
self.assertEqual(parse("x != 3"), sp.Ne(x, 3))
|
||||
self.assertEqual(parse("x <= 3"), sp.Le(x, 3))
|
||||
self.assertEqual(parse("x^2 > 4"), sp.Gt(x**2, 4))
|
||||
|
||||
def test_only_one_equals_sign(self):
|
||||
self.assertEqual(parse("x = 1"), sp.Eq(x, 1))
|
||||
with self.assertRaisesRegex(MathError, "only one '='"):
|
||||
parse("x = 1 = 2")
|
||||
|
||||
def test_multi_letter_names_are_single_symbols(self):
|
||||
rate, time = sp.symbols("rate time")
|
||||
self.assertEqual(parse("rate*time"), rate * time)
|
||||
|
||||
def test_exact_false_keeps_floats(self):
|
||||
self.assertIsInstance(parse("0.5", exact=False), sp.Float)
|
||||
|
||||
def test_empty_expression(self):
|
||||
with self.assertRaisesRegex(MathError, "empty"):
|
||||
parse(" ")
|
||||
|
||||
def test_syntax_error_message(self):
|
||||
with self.assertRaisesRegex(MathError, "syntax error|could not parse"):
|
||||
parse("2 +* 3")
|
||||
|
||||
def test_length_limit(self):
|
||||
set_limits(Limits(max_input_length=10))
|
||||
with self.assertRaisesRegex(MathError, "longer than 10"):
|
||||
parse("1+1+1+1+1+1+1")
|
||||
|
||||
|
||||
class SecurityTest(MathTestCase):
|
||||
def test_code_is_rejected(self):
|
||||
for text in MALICIOUS:
|
||||
with self.subTest(text=text), self.assertRaises(MathError):
|
||||
parse(text)
|
||||
|
||||
def test_python_builtins_are_not_reachable(self):
|
||||
# Unknown names become plain symbols; they never resolve to Python builtins.
|
||||
self.assertEqual(parse("eval"), sp.Symbol("eval"))
|
||||
self.assertEqual(parse("exec"), sp.Symbol("exec"))
|
||||
with self.assertRaises(MathError):
|
||||
parse("globals()")
|
||||
|
||||
|
||||
class SymbolTest(MathTestCase):
|
||||
def test_parse_symbols(self):
|
||||
self.assertEqual(parse_symbols("x, y"), [x, y])
|
||||
self.assertEqual(parse_symbols(["x"]), [x])
|
||||
with self.assertRaises(MathError):
|
||||
parse_symbols(["sin"])
|
||||
|
||||
def test_apply_assumptions(self):
|
||||
expr, mapping = apply_assumptions(sp.sqrt(x**2), "positive")
|
||||
self.assertEqual(sp.simplify(expr), mapping[x])
|
||||
108
tests/test_server.py
Normal file
108
tests/test_server.py
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
"""Tool layer tests through a real MCP client (in-process, inline executor)."""
|
||||
|
||||
from mcp_calculator.metrics import REGISTRY
|
||||
from tests.support import ClientTestCase, in_process_client
|
||||
|
||||
EXPECTED_TOOLS = {
|
||||
"evaluate",
|
||||
"transform",
|
||||
"solve",
|
||||
"calculus",
|
||||
"analyze_function",
|
||||
"solve_ode",
|
||||
"matrix",
|
||||
"statistics",
|
||||
"regression",
|
||||
"probability",
|
||||
"hypothesis_test",
|
||||
"number_theory",
|
||||
"convert_units",
|
||||
}
|
||||
|
||||
|
||||
def tool_calls(tool: str, status: str, client: str = "local") -> float:
|
||||
return REGISTRY.get_sample_value("mcp_calc_tool_calls_total", {"tool": tool, "client": client, "status": status}) or 0.0
|
||||
|
||||
|
||||
class DiscoveryTest(ClientTestCase):
|
||||
async def test_tool_list(self):
|
||||
async with in_process_client() as client:
|
||||
tools = {t.name: t for t in (await client.list_tools()).tools}
|
||||
self.assertEqual(set(tools), EXPECTED_TOOLS)
|
||||
for tool in tools.values():
|
||||
with self.subTest(tool=tool.name):
|
||||
self.assertGreater(len(tool.description or ""), 40)
|
||||
self.assertIs(tool.annotations.read_only_hint, True)
|
||||
self.assertIs(tool.annotations.open_world_hint, False)
|
||||
|
||||
async def test_schema_has_descriptions_and_enums(self):
|
||||
async with in_process_client() as client:
|
||||
tools = {t.name: t for t in (await client.list_tools()).tools}
|
||||
props = tools["calculus"].input_schema["properties"]
|
||||
self.assertIn("derivative", props["operation"]["enum"])
|
||||
self.assertTrue(props["variable"]["description"])
|
||||
|
||||
async def test_instructions_are_sent(self):
|
||||
async with in_process_client() as client:
|
||||
self.assertIn("Which tool", client.instructions or "")
|
||||
self.assertEqual(client.server_info.name, "calculator")
|
||||
|
||||
async def test_syntax_resource(self):
|
||||
async with in_process_client() as client:
|
||||
resources = (await client.list_resources()).resources
|
||||
self.assertTrue(any(str(r.uri) == "calculator://syntax" for r in resources))
|
||||
|
||||
|
||||
class ToolCallTest(ClientTestCase):
|
||||
async def test_evaluate(self):
|
||||
self.assertEqual((await self.call("evaluate", expression="2^64"))["exact"], "18446744073709551616")
|
||||
|
||||
async def test_solve_accepts_single_string(self):
|
||||
r = await self.call("solve", equations="x^2 = 9", domain="real")
|
||||
self.assertEqual(r["solutions"], [{"x": "-3"}, {"x": "3"}])
|
||||
|
||||
async def test_calculus_and_chaining(self):
|
||||
derivative = await self.call("calculus", operation="derivative", expression="x^3 - 6x^2 + 9x")
|
||||
# 'result' is valid input syntax for the next call
|
||||
roots = await self.call("solve", equations=derivative["result"])
|
||||
self.assertEqual(roots["solutions"], [{"x": "1"}, {"x": "3"}])
|
||||
|
||||
async def test_matrix(self):
|
||||
self.assertEqual((await self.call("matrix", operation="determinant", matrix=[[1, 2], [3, 4]]))["result"], "-2")
|
||||
|
||||
async def test_statistics(self):
|
||||
self.assertEqual((await self.call("statistics", data=[1, 2, 3, 4]))["mean"], 2.5)
|
||||
|
||||
async def test_number_theory_big_ints_as_strings(self):
|
||||
r = await self.call("number_theory", operation="is_prime", values=["170141183460469231731687303715884105727"])
|
||||
self.assertIs(r["result"], True)
|
||||
|
||||
async def test_convert_units(self):
|
||||
r = await self.call("convert_units", quantity="1 mi", to_unit="km")
|
||||
self.assertAlmostEqual(r["magnitude"], 1.609344, places=9)
|
||||
|
||||
|
||||
class ToolErrorTest(ClientTestCase):
|
||||
async def test_math_error_is_tool_error(self):
|
||||
self.assertIn("singular", await self.call_error("matrix", operation="inverse", matrix=[[1, 2], [2, 4]]))
|
||||
|
||||
async def test_injection_is_rejected(self):
|
||||
text = await self.call_error("evaluate", expression="__import__('os').system('id')")
|
||||
self.assertRegex(text, "unsupported character|not allowed")
|
||||
|
||||
async def test_schema_validation_error(self):
|
||||
self.assertIn("operation", await self.call_error("matrix", operation="explode", matrix=[[1]]))
|
||||
|
||||
|
||||
class ToolMetricsTest(ClientTestCase):
|
||||
async def test_calls_are_counted_by_outcome(self):
|
||||
ok_before, error_before = tool_calls("evaluate", "ok"), tool_calls("evaluate", "error")
|
||||
await self.call("evaluate", expression="1+1")
|
||||
await self.call_error("evaluate", expression="1/")
|
||||
self.assertEqual(tool_calls("evaluate", "ok"), ok_before + 1)
|
||||
self.assertEqual(tool_calls("evaluate", "error"), error_before + 1)
|
||||
|
||||
async def test_duration_is_recorded(self):
|
||||
before = REGISTRY.get_sample_value("mcp_calc_tool_duration_seconds_count", {"tool": "statistics"}) or 0.0
|
||||
await self.call("statistics", data=[1, 2, 3])
|
||||
self.assertEqual(REGISTRY.get_sample_value("mcp_calc_tool_duration_seconds_count", {"tool": "statistics"}), before + 1)
|
||||
112
tests/test_stats.py
Normal file
112
tests/test_stats.py
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import math
|
||||
|
||||
from mcp_calculator.ops.context import MathError
|
||||
from mcp_calculator.ops.stats import hypothesis_test, probability, regression, statistics
|
||||
from tests.support import MathTestCase
|
||||
|
||||
|
||||
class StatisticsTest(MathTestCase):
|
||||
def test_basic(self):
|
||||
r = statistics([2, 4, 4, 4, 5, 5, 7, 9])
|
||||
self.assertEqual(r["mean"], 5)
|
||||
self.assertEqual(r["median"], 4.5)
|
||||
self.assertEqual(r["mode"], [4.0])
|
||||
self.assertApprox(r["std_dev"], 2.13808993529939)
|
||||
|
||||
def test_population(self):
|
||||
r = statistics([2, 4, 4, 4, 5, 5, 7, 9], sample=False)
|
||||
self.assertEqual(r["std_dev"], 2)
|
||||
self.assertTrue(r["kind"].startswith("population"))
|
||||
|
||||
def test_percentiles(self):
|
||||
self.assertApprox(statistics(list(range(1, 101)), percentiles=[90])["percentiles"]["90"], 90.1)
|
||||
|
||||
def test_paired(self):
|
||||
self.assertApprox(statistics([1, 2, 3, 4, 5], y=[2, 4, 6, 8, 10])["paired"]["pearson_r"], 1)
|
||||
|
||||
def test_single_value(self):
|
||||
self.assertEqual(statistics([3])["mean"], 3)
|
||||
|
||||
def test_rejects_empty(self):
|
||||
with self.assertRaises(MathError):
|
||||
statistics([])
|
||||
|
||||
|
||||
class RegressionTest(MathTestCase):
|
||||
def test_linear(self):
|
||||
r = regression([1, 2, 3, 4], [3, 5, 7, 9])
|
||||
self.assertApprox(r["coefficients"]["intercept"], 1)
|
||||
self.assertApprox(r["coefficients"]["slope"], 2)
|
||||
self.assertApprox(r["r_squared"], 1)
|
||||
|
||||
def test_prediction(self):
|
||||
self.assertApprox(regression([1, 2, 3], [2, 4, 6], predict=[10])["predictions"][0]["y"], 20)
|
||||
|
||||
def test_polynomial(self):
|
||||
self.assertApprox(regression([0, 1, 2, 3], [1, 2, 5, 10], model="polynomial", degree=2)["coefficients"]["x^2"], 1)
|
||||
|
||||
def test_exponential(self):
|
||||
xs = [0, 1, 2, 3]
|
||||
r = regression(xs, [2 * math.exp(0.5 * x) for x in xs], model="exponential")
|
||||
self.assertApprox(r["coefficients"]["a"], 2)
|
||||
self.assertApprox(r["coefficients"]["b"], 0.5)
|
||||
|
||||
def test_length_mismatch(self):
|
||||
with self.assertRaisesRegex(MathError, "same length"):
|
||||
regression([1, 2], [1, 2, 3])
|
||||
|
||||
|
||||
class ProbabilityTest(MathTestCase):
|
||||
def test_normal_between(self):
|
||||
r = probability("normal", "between", {"mean": 100, "std": 15}, lower=85, upper=115)
|
||||
self.assertApprox(r["result"], 0.682689492137)
|
||||
|
||||
def test_binomial_discrete_semantics(self):
|
||||
r = probability("binomial", "sf", {"n": 10, "p": 0.5}, x=7)
|
||||
self.assertApprox(r["result"], 56 / 1024)
|
||||
self.assertIn("P(X >= 8)", r["meaning"])
|
||||
|
||||
def test_pmf(self):
|
||||
self.assertApprox(probability("poisson", "pdf", {"lambda": 2}, x=0)["result"], math.exp(-2))
|
||||
|
||||
def test_quantile(self):
|
||||
self.assertApprox(probability("normal", "quantile", q=0.975)["result"], 1.959963985)
|
||||
|
||||
def test_summary(self):
|
||||
self.assertApprox(probability("exponential", "summary", {"rate": 2})["result"]["mean"], 0.5)
|
||||
|
||||
def test_missing_parameter(self):
|
||||
with self.assertRaisesRegex(MathError, "needs parameter"):
|
||||
probability("binomial", "cdf", {"n": 10}, x=3)
|
||||
|
||||
def test_unknown_parameter(self):
|
||||
with self.assertRaisesRegex(MathError, "unknown parameter"):
|
||||
probability("normal", "cdf", {"mu": 0}, x=0)
|
||||
|
||||
|
||||
class HypothesisTestTest(MathTestCase):
|
||||
def test_one_sample_t(self):
|
||||
r = hypothesis_test("one_sample_t", sample=[5.1, 4.9, 5.3, 5.5, 5.0], mu=5)
|
||||
self.assertApprox(r["p_value"], 0.2115789946)
|
||||
self.assertIs(r["reject_null"], False)
|
||||
|
||||
def test_two_sample_welch(self):
|
||||
r = hypothesis_test("two_sample_t", sample=[1, 2, 3, 4, 5], sample2=[6, 7, 8, 9, 10])
|
||||
self.assertIs(r["reject_null"], True)
|
||||
self.assertEqual(r["variant"], "Welch")
|
||||
|
||||
def test_chi2_independence(self):
|
||||
r = hypothesis_test("chi2_independence", table=[[10, 20], [30, 40]])
|
||||
self.assertEqual(r["df"], 1)
|
||||
self.assertIn("cramers_v", r)
|
||||
|
||||
def test_goodness_of_fit_accepts_probabilities(self):
|
||||
self.assertApprox(hypothesis_test("chi2_goodness_of_fit", sample=[50, 50], expected=[0.5, 0.5])["p_value"], 1)
|
||||
|
||||
def test_binomial(self):
|
||||
r = hypothesis_test("binomial", sample=[9, 10], p=0.5, alternative="greater")
|
||||
self.assertApprox(r["p_value"], 11 / 1024)
|
||||
|
||||
def test_missing_input(self):
|
||||
with self.assertRaisesRegex(MathError, "needs 'sample2'"):
|
||||
hypothesis_test("paired_t", sample=[1, 2, 3])
|
||||
206
tests/test_transports.py
Normal file
206
tests/test_transports.py
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
"""End-to-end tests with real subprocesses: worker pool, backpressure, HTTP and stdio transport."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import unittest
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
import httpx2
|
||||
from mcp import Client, StdioServerParameters
|
||||
from mcp.client.streamable_http import streamable_http_client
|
||||
|
||||
from mcp_calculator.config import load_settings
|
||||
from mcp_calculator.executor import ComputationError, Executor
|
||||
from tests.support import slow
|
||||
|
||||
TOKEN = "test-token-0123456789"
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
with socket.socket() as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
def _http(method: str, url: str, body: dict | None = None, token: str | None = None) -> tuple[int, str]:
|
||||
headers = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream"}
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
try:
|
||||
with urllib.request.urlopen(urllib.request.Request(url, data=data, headers=headers, method=method), timeout=30) as r:
|
||||
return r.status, r.read().decode()
|
||||
except urllib.error.HTTPError as exc:
|
||||
return exc.code, exc.read().decode()
|
||||
|
||||
|
||||
def _start_server(port: int, **env: str) -> subprocess.Popen:
|
||||
full_env = {**os.environ, "MCP_CALC_TRANSPORT": "http", "MCP_CALC_PORT": str(port), "MCP_CALC_WORKERS": "1", **env}
|
||||
proc = subprocess.Popen([sys.executable, "-m", "mcp_calculator"], env=full_env, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True)
|
||||
# Poll readiness (503 until the workers are warm). Always pause between polls: every request
|
||||
# is access-logged, and a flood of log lines would fill the stderr pipe and block the server.
|
||||
deadline = time.time() + 30
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
if _http("GET", f"http://127.0.0.1:{port}/readyz")[0] == 200:
|
||||
return proc
|
||||
except OSError:
|
||||
pass
|
||||
time.sleep(0.2)
|
||||
proc.kill()
|
||||
raise RuntimeError(f"server did not start: {proc.communicate()[1]}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- worker pool
|
||||
|
||||
|
||||
@slow
|
||||
class WorkerPoolTest(unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self) -> None:
|
||||
self.executor = Executor(load_settings([], env={"MCP_CALC_TIMEOUT_SECONDS": "2", "MCP_CALC_WORKERS": "1"}))
|
||||
self.executor.start()
|
||||
self.addCleanup(self.executor.stop)
|
||||
|
||||
async def test_computes(self):
|
||||
self.assertEqual((await self.executor.run("algebra.evaluate", expression="2^10"))["exact"], "1024")
|
||||
|
||||
async def test_timeout_kills_worker_and_recovers(self):
|
||||
started = time.perf_counter()
|
||||
with self.assertRaisesRegex(ComputationError, "timeout") as ctx:
|
||||
await self.executor.run("algebra.evaluate", expression="9^9^9^9")
|
||||
self.assertEqual(ctx.exception.kind, "timeout")
|
||||
self.assertLess(time.perf_counter() - started, 5)
|
||||
# the killed worker is replaced transparently
|
||||
self.assertEqual((await self.executor.run("algebra.evaluate", expression="1+1"))["exact"], "2")
|
||||
self.assertTrue(self.executor.healthy)
|
||||
|
||||
async def test_ready_only_after_warm_up(self):
|
||||
executor = Executor(load_settings([], env={"MCP_CALC_WORKERS": "1"}))
|
||||
executor.start()
|
||||
self.addCleanup(executor.stop)
|
||||
self.assertFalse(executor.healthy, "workers cannot have loaded SymPy/SciPy yet")
|
||||
deadline = time.time() + 30
|
||||
while not executor.healthy and time.time() < deadline:
|
||||
await asyncio.sleep(0.05)
|
||||
self.assertTrue(executor.healthy)
|
||||
|
||||
async def test_math_error(self):
|
||||
with self.assertRaisesRegex(ComputationError, "unsupported character") as ctx:
|
||||
await self.executor.run("algebra.evaluate", expression="'x'")
|
||||
self.assertEqual(ctx.exception.kind, "error")
|
||||
|
||||
|
||||
@slow
|
||||
class BackpressureTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_requests_beyond_capacity_are_rejected_immediately(self):
|
||||
env = {"MCP_CALC_TIMEOUT_SECONDS": "1.5", "MCP_CALC_WORKERS": "1", "MCP_CALC_MAX_QUEUE": "1"}
|
||||
executor = Executor(load_settings([], env=env))
|
||||
executor.start()
|
||||
self.addCleanup(executor.stop)
|
||||
self.assertEqual(executor.capacity, 2)
|
||||
|
||||
slow_tasks = [asyncio.create_task(executor.run("algebra.evaluate", expression="9^9^9^9")) for _ in range(2)]
|
||||
await asyncio.sleep(0.1)
|
||||
self.assertEqual(executor.inflight, 2)
|
||||
|
||||
started = time.perf_counter()
|
||||
with self.assertRaisesRegex(ComputationError, "busy") as ctx:
|
||||
await executor.run("algebra.evaluate", expression="1+1")
|
||||
self.assertEqual(ctx.exception.kind, "busy")
|
||||
self.assertLess(time.perf_counter() - started, 0.5, "busy rejection must not wait")
|
||||
|
||||
results = await asyncio.gather(*slow_tasks, return_exceptions=True)
|
||||
self.assertTrue(all(isinstance(r, ComputationError) and r.kind == "timeout" for r in results), results)
|
||||
self.assertEqual(executor.inflight, 0)
|
||||
# capacity is free again
|
||||
self.assertEqual((await executor.run("algebra.evaluate", expression="1+1"))["exact"], "2")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- HTTP
|
||||
|
||||
|
||||
@slow
|
||||
class HttpTransportTest(unittest.IsolatedAsyncioTestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
port = _free_port()
|
||||
cls.base = f"http://127.0.0.1:{port}"
|
||||
cls.proc = _start_server(port, MCP_CALC_AUTH_TOKENS=f"tests:{TOKEN}", MCP_CALC_LOG_LEVEL="WARNING")
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls) -> None:
|
||||
cls.proc.terminate()
|
||||
cls.proc.communicate(timeout=30)
|
||||
|
||||
def test_health_endpoints_are_public(self):
|
||||
self.assertEqual(json.loads(_http("GET", f"{self.base}/healthz")[1])["status"], "ok")
|
||||
self.assertEqual(json.loads(_http("GET", f"{self.base}/readyz")[1])["status"], "ready")
|
||||
|
||||
def test_mcp_endpoint_requires_token(self):
|
||||
self.assertEqual(_http("POST", f"{self.base}/mcp", {"jsonrpc": "2.0", "id": 1, "method": "tools/list"})[0], 401)
|
||||
self.assertEqual(_http("POST", f"{self.base}/mcp", {}, token="wrong")[0], 401)
|
||||
|
||||
async def test_mcp_over_http(self):
|
||||
async with httpx2.AsyncClient(headers={"Authorization": f"Bearer {TOKEN}"}, timeout=30) as http:
|
||||
async with Client(streamable_http_client(f"{self.base}/mcp", http_client=http)) as client:
|
||||
self.assertEqual(len((await client.list_tools()).tools), 13)
|
||||
result = await client.call_tool("evaluate", {"expression": "sqrt(2)", "precision": 30})
|
||||
self.assertEqual(result.structured_content["numeric"], "1.41421356237309504880168872421")
|
||||
|
||||
def test_metrics_endpoint(self):
|
||||
body = {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "convert_units", "arguments": {"quantity": "1 m", "to_unit": "cm"}}}
|
||||
self.assertEqual(_http("POST", f"{self.base}/mcp", body, token=TOKEN)[0], 200)
|
||||
status, text = _http("GET", f"{self.base}/metrics") # public, like the probes
|
||||
self.assertEqual(status, 200)
|
||||
self.assertIn('mcp_calc_tool_calls_total{client="tests",status="ok",tool="convert_units"}', text)
|
||||
for name in ("mcp_calc_tool_duration_seconds_bucket", "mcp_calc_inflight_computations", "mcp_calc_capacity 21.0", "mcp_calc_build_info"):
|
||||
self.assertIn(name, text)
|
||||
|
||||
|
||||
@slow
|
||||
class HttpLifecycleTest(unittest.TestCase):
|
||||
def test_metrics_can_be_disabled(self):
|
||||
port = _free_port()
|
||||
proc = _start_server(port, MCP_CALC_METRICS="false")
|
||||
try:
|
||||
self.assertEqual(_http("GET", f"http://127.0.0.1:{port}/metrics")[0], 404)
|
||||
finally:
|
||||
proc.terminate()
|
||||
proc.communicate(timeout=30)
|
||||
|
||||
def test_sigterm_stops_worker_pool_cleanly(self):
|
||||
port = _free_port()
|
||||
proc = _start_server(port, MCP_CALC_LOG_LEVEL="INFO")
|
||||
body = {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "evaluate", "arguments": {"expression": "1+1"}}}
|
||||
self.assertEqual(_http("POST", f"http://127.0.0.1:{port}/mcp", body)[0], 200)
|
||||
proc.send_signal(signal.SIGTERM)
|
||||
# communicate() also waits for the multiprocessing resource tracker, which shares stderr
|
||||
# and reports leaked semaphores only when it exits.
|
||||
_, stderr = proc.communicate(timeout=30)
|
||||
self.assertIn("worker pool stopped", stderr)
|
||||
self.assertNotIn("leaked", stderr)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- stdio
|
||||
|
||||
|
||||
@slow
|
||||
class StdioTransportTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_mcp_over_stdio(self):
|
||||
params = StdioServerParameters(
|
||||
command=sys.executable,
|
||||
args=["-m", "mcp_calculator"],
|
||||
env={**os.environ, "MCP_CALC_WORKERS": "1", "MCP_CALC_LOG_LEVEL": "WARNING"},
|
||||
)
|
||||
async with Client(params) as client:
|
||||
result = await client.call_tool("solve", {"equations": ["x + y = 3", "x - y = 1"]})
|
||||
self.assertEqual(result.structured_content["solutions"], [{"x": "2", "y": "1"}])
|
||||
930
uv.lock
generated
Normal file
930
uv.lock
generated
Normal file
|
|
@ -0,0 +1,930 @@
|
|||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.12"
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.14'",
|
||||
"python_full_version < '3.14'",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "annotated-types"
|
||||
version = "0.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyio"
|
||||
version = "4.15.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "idna" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.15'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "attrs"
|
||||
version = "26.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cffi"
|
||||
version = "2.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "50.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/19/797e2aaac9df6a66f1550f49979dc1b1e39ecd2077501c30efa81e8d5d67/cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986", size = 4010153, upload-time = "2026-08-25T19:44:03.155Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/34/9ce9a62ed9dc82ca9fd6a34445b6904af56e5f38b3eae2ed32e49c36053d/cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f", size = 4723133, upload-time = "2026-08-25T19:44:05.461Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/26/e6d4fc8512a51a5f9ee7bfdbfb853bce1197087df40c9ad993ad370b846f/cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", size = 4712478, upload-time = "2026-08-25T19:44:07.375Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/de/d3cdc2815697aae84126cbd6a030ca7b6b452e28a88b501b836bd3aa7a86/cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8", size = 4730726, upload-time = "2026-08-25T19:44:09.294Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/32/38c0d344b98c06d34b5df8946565a9c0d6dbf32c8e0730a7f05f0a3c6cab/cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45", size = 5353524, upload-time = "2026-08-25T19:44:11.96Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/1b/82f0f0d8858d4432be1af790477edf62aef90324041aa07c57e57bef1af7/cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad", size = 4746720, upload-time = "2026-08-25T19:44:14.051Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/ba/042ca458b8c64348c768284b5d23e69b92ed53d057ab779fee628564676d/cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49", size = 4361866, upload-time = "2026-08-25T19:44:16.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/3b/e96c1ef71edef71057c7e3c3d982ce8fda554e0c52d0cc19c18845cde3eb/cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f", size = 4730028, upload-time = "2026-08-25T19:44:18.085Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/38/45abd72ef63f2e7d0754a6cacf97bd8b69512ace7f6130d24c39ece65da2/cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527", size = 5308405, upload-time = "2026-08-25T19:44:20.197Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/66/6ccca4722987ddedaa7fc9c3f4708af7431f5535666c174350830888c6b7/cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a", size = 4746230, upload-time = "2026-08-25T19:44:22.376Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596, upload-time = "2026-08-25T19:44:24.472Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082, upload-time = "2026-08-25T19:44:26.709Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/8b/cb12b1b60c91b074ca6bf0fdd59aa8f10d8bc5f73af8faece86ef0421b37/cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648", size = 3842826, upload-time = "2026-08-25T19:44:28.784Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/f0/424cb557d99aa86ac55da5e2add02e2882e44047b6264f93ade1b975a993/cryptography-50.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f", size = 3973525, upload-time = "2026-08-25T19:44:30.7Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/72/3a2711d967977ab5fc80b782837c7e8d1ac7445e764c20c381a265c57ef3/cryptography-50.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a", size = 4708817, upload-time = "2026-08-25T19:44:32.773Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/f2/bb1f56e10815b789df0b409a69fa4992ff3d3fef9c72747f4a6b26fed38e/cryptography-50.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367", size = 4697300, upload-time = "2026-08-25T19:44:35.144Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/bd/ed5396be499ffcf8807a585bfe38b71a1fbdd1c342b4f9b6d0ef5162a946/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5", size = 4716039, upload-time = "2026-08-25T19:44:37.192Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/6e/1cf405c5c8e8df7545378048e954792f00b7f2367af8863ce8b8f3e10607/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9", size = 5332388, upload-time = "2026-08-25T19:44:39.16Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/92/b4317e8c32c4f47b062f5398bd79106b220a124546f42be83bf32b761e2a/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0", size = 4730293, upload-time = "2026-08-25T19:44:41.298Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/0d/a1e7633e2c744d0f2983320a27e924ef2264c79c56e1a58d5fb0a1cfd413/cryptography-50.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc", size = 4346031, upload-time = "2026-08-25T19:44:43.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/dd/b215616f9bab3fc18510c78a4e5c9f362d77838503c363dc747c7d4f5c6f/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17", size = 4715344, upload-time = "2026-08-25T19:44:45.291Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/1b/ec3ebd31741d0e963612c4fe43caa39341b9b1e031e469820e42e4c83918/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6", size = 5287201, upload-time = "2026-08-25T19:44:47.297Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/01/0127d11a762b31a9ee0221894f540318761783f3fdc4bc5d057698caebd5/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3", size = 4730023, upload-time = "2026-08-25T19:44:49.435Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/b9/e7425ebfb599241a0c1d7000f1b466c3062da66c19d9525031315dff7213/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6", size = 4847362, upload-time = "2026-08-25T19:44:51.94Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/fd/60d0ddf4defa12e482c9d5e0f554384d6e8ab25341fd15f060028fd92e6a/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149", size = 4999247, upload-time = "2026-08-25T19:44:53.876Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/56/bc4f2b209e766c93372cfcd59b781a0b2b59700f62a969580415b699c2b2/cryptography-50.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf", size = 3825806, upload-time = "2026-08-25T19:44:56.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/a9/ee16a903f13755e914d1eecc482fe64d1f10761c3960e5d8fa6837377aff/cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0", size = 4035307, upload-time = "2026-08-25T19:44:58.305Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900, upload-time = "2026-08-25T19:45:00.401Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357, upload-time = "2026-08-25T19:45:02.786Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/3a/3c5f80daa4dcd47323c7af8a2fcb90de27a33564d4fcac69846c0972691a/cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88", size = 4758474, upload-time = "2026-08-25T19:45:04.889Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/2b/214cf0cf93db9628c3c20c896b229f327f6fb1b20e4b3743d8ad3f00af8b/cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054", size = 5375862, upload-time = "2026-08-25T19:45:07.163Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/51/3f9701867a46b6c1740c9b52fc4d3bed6cbdcfedcc9b6e64305c07f39cff/cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5", size = 4772942, upload-time = "2026-08-25T19:45:09.396Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/5c/13ea642e08e2544d0f5396122055f4820cfacb3203562197b5967125ea97/cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361", size = 4383347, upload-time = "2026-08-25T19:45:11.659Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/d5/7d1fe1cb93f91c428093ff234e128c89ba8ea61a6f26aab406081f9b996e/cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71", size = 4758050, upload-time = "2026-08-25T19:45:13.745Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/04/557fc5ead96a829e0bc812a3b9dc4a52a2f27e4f7f5950da7ff27653a805/cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80", size = 5332955, upload-time = "2026-08-25T19:45:16.193Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/eb/5d7124083e8d8cda8f5b348f544b71ad6f707ad63193758ef4d8e569da02/cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239", size = 4772694, upload-time = "2026-08-25T19:45:18.315Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/8e/f1f955e0921dd2b6d22eae7e8d24a4c4b638d10735ffbf6a71f99eb0fcb8/cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558", size = 4888413, upload-time = "2026-08-25T19:45:20.4Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/ab/89e2b798d2c3925f82e2bb72d5979f3d2f6da2dd22ef4a8cd8b70d920039/cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e", size = 5044355, upload-time = "2026-08-25T19:45:22.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429, upload-time = "2026-08-25T19:45:24.418Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flexcache"
|
||||
version = "0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/55/b0/8a21e330561c65653d010ef112bf38f60890051d244ede197ddaa08e50c1/flexcache-0.3.tar.gz", hash = "sha256:18743bd5a0621bfe2cf8d519e4c3bfdf57a269c15d1ced3fb4b64e0ff4600656", size = 15816, upload-time = "2024-03-09T03:21:07.555Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/27/cd/c883e1a7c447479d6e13985565080e3fea88ab5a107c21684c813dba1875/flexcache-0.3-py3-none-any.whl", hash = "sha256:d43c9fea82336af6e0115e308d9d33a185390b8346a017564611f1466dcd2e32", size = 13263, upload-time = "2024-03-09T03:21:05.635Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flexparser"
|
||||
version = "0.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/99/b4de7e39e8eaf8207ba1a8fa2241dd98b2ba72ae6e16960d8351736d8702/flexparser-0.4.tar.gz", hash = "sha256:266d98905595be2ccc5da964fe0a2c3526fbbffdc45b65b3146d75db992ef6b2", size = 31799, upload-time = "2024-11-07T02:00:56.249Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/5e/3be305568fe5f34448807976dc82fc151d76c3e0e03958f34770286278c1/flexparser-0.4-py3-none-any.whl", hash = "sha256:3738b456192dcb3e15620f324c447721023c0293f6af9955b481e91d00179846", size = 27625, upload-time = "2024-11-07T02:00:54.523Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpcore2"
|
||||
version = "2.12.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "h11" },
|
||||
{ name = "truststore" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/be/ad/f4f0e57345f1870f3e8cb624e058d7eca6e5a27d33bcc3311d9b618734cd/httpcore2-2.12.0.tar.gz", hash = "sha256:9293522bba0aa7c4c8e9e3f040c16575bd8868e155a77fa30c7a9085a5eae648", size = 67548, upload-time = "2026-08-18T13:22:08.211Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/74/d370e55600d9bcfa0d9794b0166126d49291a3d2b20c268fc98c453a4948/httpcore2-2.12.0-py3-none-any.whl", hash = "sha256:7e04258ce01013d7d615e5b910a3b27fac937d7a95038227e79652b4ba3b4ceb", size = 83074, upload-time = "2026-08-18T13:22:05.854Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx2"
|
||||
version = "2.12.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio", marker = "sys_platform != 'emscripten'" },
|
||||
{ name = "httpcore2", marker = "sys_platform != 'emscripten'" },
|
||||
{ name = "httpx2-jsfetch", marker = "sys_platform == 'emscripten'" },
|
||||
{ name = "idna" },
|
||||
{ name = "truststore", marker = "sys_platform != 'emscripten'" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7f/f8/579a8b51e42e38ee32647df9f08aa25643ae788e275cc625b199829c4671/httpx2-2.12.0.tar.gz", hash = "sha256:7631fe9887a8a2275f4a2540e053aa670fcc50742864a9ae7c66e609fdcf12cf", size = 100040, upload-time = "2026-08-18T13:22:09.086Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/95/411ba65569158e862368917aaf56597f3e5fa3b91b0502919638465a08f3/httpx2-2.12.0-py3-none-any.whl", hash = "sha256:cc8b6eecb8661c146b8f89a60e97456ee086e91a784ed31ac450c3a9e613dd36", size = 95427, upload-time = "2026-08-18T13:22:06.834Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx2-jsfetch"
|
||||
version = "1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.19"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema"
|
||||
version = "4.26.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "attrs" },
|
||||
{ name = "jsonschema-specifications" },
|
||||
{ name = "referencing" },
|
||||
{ name = "rpds-py" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema-specifications"
|
||||
version = "2025.9.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "referencing" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mcp"
|
||||
version = "2.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "httpx2" },
|
||||
{ name = "jsonschema" },
|
||||
{ name = "mcp-types" },
|
||||
{ name = "opentelemetry-api" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pyjwt", extra = ["crypto"] },
|
||||
{ name = "python-multipart" },
|
||||
{ name = "pywin32", marker = "sys_platform == 'win32'" },
|
||||
{ name = "sse-starlette" },
|
||||
{ name = "starlette" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/76/31/ac54fb0fdd5b37de704486e288bba4fbbb463f24cfcfedbede407b854513/mcp-2.2.0.tar.gz", hash = "sha256:2dc37ecb1974becdcebdbf7561e7c15a07dbbf20ba21ba16c3593b3038b3afbd", size = 4084129, upload-time = "2026-09-07T16:06:23.439Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/ff/8e7eade68b8a28f7da0ed1085544341b51f9c935dbf6b95c76b7edfea6a0/mcp-2.2.0-py3-none-any.whl", hash = "sha256:bde982589473a060ae145e3406e9a5333fe538c97229ba841f5a7f92be004f81", size = 365656, upload-time = "2026-09-07T16:06:19.711Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mcp-calculator"
|
||||
version = "1.2.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "mcp" },
|
||||
{ name = "mpmath" },
|
||||
{ name = "numpy" },
|
||||
{ name = "pebble" },
|
||||
{ name = "pint" },
|
||||
{ name = "prometheus-client" },
|
||||
{ name = "scipy" },
|
||||
{ name = "starlette" },
|
||||
{ name = "sympy" },
|
||||
{ name = "uvicorn" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
dev = [
|
||||
{ name = "ruff" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "mcp", specifier = ">=2.2,<3" },
|
||||
{ name = "mpmath", specifier = ">=1.3" },
|
||||
{ name = "numpy", specifier = ">=2.0" },
|
||||
{ name = "pebble", specifier = ">=5.1" },
|
||||
{ name = "pint", specifier = ">=0.24" },
|
||||
{ name = "prometheus-client", specifier = ">=0.20" },
|
||||
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.6" },
|
||||
{ name = "scipy", specifier = ">=1.13" },
|
||||
{ name = "starlette", specifier = ">=0.40" },
|
||||
{ name = "sympy", specifier = ">=1.13" },
|
||||
{ name = "uvicorn", specifier = ">=0.30" },
|
||||
]
|
||||
provides-extras = ["dev"]
|
||||
|
||||
[[package]]
|
||||
name = "mcp-types"
|
||||
version = "2.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ae/91/762d7755d971aff8a28d75f7961656148edf27875c8026e6385aaab08ae7/mcp_types-2.2.0.tar.gz", hash = "sha256:d3ed53703ddd10d9c6399f29d322bb66f3f67ab41348ac8556ba23e07fedefad", size = 65892, upload-time = "2026-09-07T16:06:25.187Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/d7/6ffba5d8cd5dd9b8a19478875c50e04945314ba5074e84d749283f27f62d/mcp_types-2.2.0-py3-none-any.whl", hash = "sha256:ea476b73ee86709ab5abc9452385ed36cc05907e582355622e294595c9a04f13", size = 69106, upload-time = "2026-09-07T16:06:21.461Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mpmath"
|
||||
version = "1.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "numpy"
|
||||
version = "2.5.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/13/01/11703282db468b85f6f7b8c7f22d058de5970d5c7e60a3a8aaa313c3de36/numpy-2.5.3.tar.gz", hash = "sha256:df2d5874ff183595a4ba404edd04f6bd9b5505c1d7708573f6a6c17489a67563", size = 20791231, upload-time = "2026-09-06T16:27:47.073Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/50/8fdbb16af64895706a45f06a4068e29db732ec180f3c1375f14123359138/numpy-2.5.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cb189f09db39283b26bfd061ec16189e14f71c6755207f72a0f7540867afe5b9", size = 16994982, upload-time = "2026-09-06T16:24:29.244Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/39/789131c1188c078dcb3a1692e72e1e050c68b88ffe72c9ccaac9bcd7a9cd/numpy-2.5.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f59a878c33d6b88122d80d239bb3b845d58708750b0cb06a09aebb9b18ec696c", size = 12009327, upload-time = "2026-09-06T16:24:32.491Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/59/a312e95696e5f601914dd8b6dd844692ba61670807417e24b68e337b5c70/numpy-2.5.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:a72f874bc9e10e4b8f80426fb49716d5141f64442a0c8418065093ec8017fbb0", size = 5445405, upload-time = "2026-09-06T16:24:35.071Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/d0/5623a1707ed4fe16e3909fe3cf5ee3da004ae677ad23d83bbf3adf1a6faf/numpy-2.5.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:fc36dc566135b5eceec4cf89758fcb719266a019ef07dae1754ae7c9f617ef3e", size = 6783213, upload-time = "2026-09-06T16:24:37.253Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/32/84146fc020ad3c25f805f70ab60da46fe3c540a21369754a7e4369754b6f/numpy-2.5.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76c2c1e6bfa5c84adc6434dfbf013aa92096a7985221762c8f11fedfd20fff58", size = 15687872, upload-time = "2026-09-06T16:24:39.751Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/af/aa78d1a88805456e212b65461354cd943197fb9acecc4c90fd12295123a3/numpy-2.5.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7e18c623bb5c95acb3b3328861272816ba199fb531921c5d6d0b675f1fde9e3", size = 16717410, upload-time = "2026-09-06T16:24:42.745Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/24/faa79d865e69a97ba17473b23a1b74094b2259c03e820c70297293b9ea49/numpy-2.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4f8929ee6c96bfbd7b4ed2032e0c03af86fe1826740ab61ddabf9072d06e57ff", size = 17040975, upload-time = "2026-09-06T16:24:45.961Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/4a/8877e629445a7176297dffcaf9c485faa96a95d81728a62521ad55bd4c0f/numpy-2.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b5d93cf48f687479941d12b69c873ad2cc76bbd487f0091c2200636497f34034", size = 18476479, upload-time = "2026-09-06T16:24:49.35Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/db/35e1c2d38b04cbd5b731f9d71495e055e813197669d22b612f11748d2ff9/numpy-2.5.3-cp312-cp312-win32.whl", hash = "sha256:bf63afbe037eb5d2fe87fbcc7778e61da53ebaf21d938a4515aa73b62532a5d4", size = 6133378, upload-time = "2026-09-06T16:24:51.915Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/a1/accf6d4f0c80c5d9ba9735d6b1550e444180599f34dec69ca01360f717ad/numpy-2.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:0a59a421a32580a009e8a1751345bf829631b990dc1794b80514ab722b435def", size = 12567828, upload-time = "2026-09-06T16:24:54.255Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/43/1764aff32e4652526ae2f71fa8b3efd8d25c8a3d6926914454e47138ed1e/numpy-2.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:ccb32e0525d29e8b0572eb84c9a57af0e7a4e615726927506f55063c62414034", size = 10485432, upload-time = "2026-09-06T16:24:57.278Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/e5/8fb89cd46d14e35699d13bf943a5f5f441ecee8667120a1f6105ab89e349/numpy-2.5.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:66a78fe4556c60aceda5916f9eacd638b18e9e681016ec302dcb4682d6d4d034", size = 16991061, upload-time = "2026-09-06T16:25:00.411Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/06/9dc9e48b5e5e941c8b10350c5ff2d721da42a20517d911d15544246775ff/numpy-2.5.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92f30e89b8ee0ecf363033576c422b2f58fed6a80bed0aa48dff6d14c654663e", size = 12003676, upload-time = "2026-09-06T16:25:03.475Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/2a/98282aa5b8f58b1157d440bb6282eed47e3632a5de53a714fbab17e659fe/numpy-2.5.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f9a2353b37a1a9e78fd82b27ad7e2a32a2d036604d18f02b05e3136c62ca3b09", size = 5439695, upload-time = "2026-09-06T16:25:05.978Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/f9/b6533d777be9d6ffd29dc1be0867e563e6e8cc9a220ff1b716adc317f060/numpy-2.5.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:ccbc4665079665c3cf3bab4db9f6b095370cd6437d66be549b6c2a1fd19e1958", size = 6779395, upload-time = "2026-09-06T16:25:08.599Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/85/735720d04ec197c5dcfacdfc9922667c7f1f5f496a279b7ba4d7c74c4cc7/numpy-2.5.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c76d5dde9f445058f83d0c02af00557a4db91de9a9a57c0df87d1535001d654b", size = 15681750, upload-time = "2026-09-06T16:25:11.173Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/1b/3b16a9bc514a440a7a0883684111dcb1ef1aee960af2ca95da8fc775f124/numpy-2.5.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5fa86b80fd24bcd1aff83ad23be44ea323de3f787be8f8b15d4a65621e25321", size = 16708577, upload-time = "2026-09-06T16:25:14.171Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/c4/386f397831b07328b639c96c5b62719346cf4baf07c68d927239752b1534/numpy-2.5.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd4cb9ad3c7889b9b3fe0a9a9fb5d2ed26f9879bff2608d9f01aed147a20d231", size = 17042047, upload-time = "2026-09-06T16:25:17.582Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/3e/a700ecbf36e85ae8328fd3b0e12eeddc22ed6358a64cb2bd913e0d195d65/numpy-2.5.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1302b90c0e52281681b2975adfe8a860cb7b12216a27b4b0b4207c44bf7bccf0", size = 18465724, upload-time = "2026-09-06T16:25:20.949Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/ee/38e785e88a4045f6ad1d1f2808dcdfafdca48c760260c0587bf171e29fc9/numpy-2.5.3-cp313-cp313-win32.whl", hash = "sha256:1c80eabb4035ecf4ca9cd49cde8a9fdd69a729e63e6474887d1523ade7aa277f", size = 6129003, upload-time = "2026-09-06T16:25:23.664Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/ec/100f2b1794ede74a9b3d7ec6b9736927f56713414c1dfe19ab6c383494bf/numpy-2.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:71cad2b2a7451ab79d8f5e71b453485b6775963d5cf794179144a7463fe6e8ec", size = 12560965, upload-time = "2026-09-06T16:25:26.602Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/b1/7dc825ca94c12acebbce4c37caa5e198695eb31424bc579679f32b1bb49d/numpy-2.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:8e4dd766076855b5ff7ea52fa5f07ce26286726e0f8bff446b7739d02e6ea204", size = 10482343, upload-time = "2026-09-06T16:25:29.772Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/78/cf416f15dc29375a229d9dfebf8db6e313f291580b39fa1a568b6052bb07/numpy-2.5.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:350ba9783ce969cf9f7ce6e6a9a58e1a6e2a19ca025b7ee448c4db727706212a", size = 16998686, upload-time = "2026-09-06T16:25:33.171Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/59/abcc2d8def4fd60eec7d87f92d27c13448ffd9ab14339bcc63a0d7a2fdea/numpy-2.5.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:012e66aca395d795496446e52aeeb5866312a5d4d3f27da270e5a0b43f70dc5c", size = 12013862, upload-time = "2026-09-06T16:25:36.748Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/75/4640d2d6e4b64a049e48425a82728a41ef4adb61332d2cba68055774878b/numpy-2.5.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:adc1ada2662f8a5f960b8a10d9986897e7499ef07e06d4cfe7197f8cce923c07", size = 5449793, upload-time = "2026-09-06T16:25:39.476Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/cd/625b57ae33d4ca560f32cc0b47b4a5922146d9beb998ddf773900d440a73/numpy-2.5.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:54a115e5a73b8fc44f0cebef486365a1894b5c9760685d4558b72b7c3eb846e0", size = 6785176, upload-time = "2026-09-06T16:25:42.069Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/72/12918652e7912ef9751e8694c88820fcd1908e0618cb23f5f3caa6004b7b/numpy-2.5.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be5a8381859b6da607c84f4f7d6847725f1cf1853ef8a2c9e115b7d58bef47dc", size = 15703377, upload-time = "2026-09-06T16:25:45.135Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/8f/9beacf79ca7c650688ad0baa80931adb988fe6e6e5d5903c23cc3dbd70eb/numpy-2.5.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b0521d0f4aebb6e06189451025fa17a913287b13c03d5fe05c017333b654ea5b", size = 16711928, upload-time = "2026-09-06T16:25:48.461Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/8d/41d0a56e1ac4c87495c897a211b1368691b7237aadabec8b3b8f3a74d48f/numpy-2.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9deb49575e5b0b94ed72c8a64ec4d033381adc27e9060ae842971f697ba96104", size = 17059507, upload-time = "2026-09-06T16:25:51.873Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/1e/0dfbc5cc251d54e2af790f254d24ec38637fa97ec7d5d11de7ffed787098/numpy-2.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b00eefbcf0f292945c4b4dec2ae845389ef5bcdcd596e6e4328051db5b5ba694", size = 18471002, upload-time = "2026-09-06T16:25:55.233Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/2c/dfa40f6991f8185c8c30ffd023dfcbb11888e823cfab9557b920f3bb7bed/numpy-2.5.3-cp314-cp314-win32.whl", hash = "sha256:c2381f82999704f818e2c987a865050e285ec3621262c66d40f5a96c8f899f8e", size = 6180485, upload-time = "2026-09-06T16:25:58.157Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/73/d2c08231e4fde7e415501fd02c715d96e98599b2d8384445933944152984/numpy-2.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:2c25dfa72943e4336ddb6b0ee4277b47a0c85bede0807530ec68103bf58e2c10", size = 12698179, upload-time = "2026-09-06T16:26:00.789Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/e9/dcdcc9b95cf5f49815055573aee1b11cfbf5299f38a180e437ded050810f/numpy-2.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:15aa985ac73a8db02db7663381aa109510449d3819d37206caed27b33a65a8a6", size = 10769383, upload-time = "2026-09-06T16:26:04.011Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/c4/af8bc08a7ef4e1529a7c0cf24969accce316b783999802089a581ec99272/numpy-2.5.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ac7bb1c52d445bd4f8f7f97fefe6abc3a084dc4d63df50d79b17fa2b78e89297", size = 12132668, upload-time = "2026-09-06T16:26:07.138Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/ae/0f15eb56d4ec5e13c1f7ff04ff407f997d1acbadb45d3e1f2e2645a8f43c/numpy-2.5.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e6ab667ba76450084eb64013762c438ea76d9d29cc676dcd6c2e9892ba37f841", size = 5568580, upload-time = "2026-09-06T16:26:09.828Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/fb/c72a8f25d4b6e96c354e7ab45ace3b27dc11e5d6a13b6c7d0cd6b08bf112/numpy-2.5.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:f7fabeb6cea87d65f3b926de33d03fb016cfdc29314c90974383b5582ae72891", size = 6882634, upload-time = "2026-09-06T16:26:12.524Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/a9/968c90ed2ab15060c338e8137f1215b5a60756ae07328e0a60d1c6734df4/numpy-2.5.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fb6f8fb9ff0b3a69f52c66ce397b0246583e9f28616231b0e32ca49259a5fa6", size = 15748923, upload-time = "2026-09-06T16:26:15.092Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/08/9df04103947b95e3b6b1f2ed1a70521f325647a31b82da6a2aae3a485508/numpy-2.5.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93e1f5447e2b1e479d7bd74701e84746b86450cff1fc368b132d195e2b8f8211", size = 16746748, upload-time = "2026-09-06T16:26:18.43Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/a0/14c8d5fe5b53a334aabb653deb391c0fef49558f491880ea300ed6785224/numpy-2.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c00abe94c1a69d75d827dcf1c025b25c8a45d230b3bcd77a9020883a1b047653", size = 17111561, upload-time = "2026-09-06T16:26:22.113Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/a6/d7e96e42f01522e154c32489640f16dfc4f6181d165d05fc3bec8c2c4999/numpy-2.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:536f963710a4e63934d80ac0dc4f478804a83e9a84b6828018f25d09953ada33", size = 18513945, upload-time = "2026-09-06T16:26:25.401Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/39/3453afb7119d0449ef11c886874120ff180e2c337760e0e2d88f70f1a945/numpy-2.5.3-cp314-cp314t-win32.whl", hash = "sha256:4c8a6d2ebce6305fd82fbefca827775437147052a976ee7c94b36a0c1b52ac6c", size = 6335421, upload-time = "2026-09-06T16:26:28.175Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/01/22815d2b19a1a746b1d45205cffebb3fe511a18acb75fba6c88491fc9894/numpy-2.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:9a37475425b431b4d060f23b4f52cd2f3aef6bc7c654bd760adf0040eec9d435", size = 12896420, upload-time = "2026-09-06T16:26:31.265Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/ee/a7cbba67eeaff038dc29ca8b98a88396c8b0cc9c89d4924f4a27a5c9150b/numpy-2.5.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2d8240cb4c16fd831074aa2b2cf9fc54664d826341d61c372245b96a74a49a9a", size = 10857177, upload-time = "2026-09-06T16:26:34.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/56/78194492883ff5eec90423fe56a3a44b154da047d88a6307f629713c584f/numpy-2.5.3-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:a6391fafaba97500887132cd582abc6e19452b1ac775a47caa7b24490e152058", size = 16996531, upload-time = "2026-09-06T16:26:37.287Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/39/dd55c0af90bbab564b09ae3b0aa60ec5c02b900fa4f1ba23440525c8b32d/numpy-2.5.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:09d5a423c71ad5feb5625844ad58050e35df43871004b52ac9c0ad44a56775be", size = 12012569, upload-time = "2026-09-06T16:26:40.707Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/51/04f67d32e4862b281b1cb84ceeaed3421189a84fb6fb51a391cd6d5009f7/numpy-2.5.3-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:f9579f383d1bf9df80081e72760e84960a7fd4f88cf0c9e535a8597c9bb646f5", size = 5448498, upload-time = "2026-09-06T16:26:43.435Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/c9/25b4dc0dd1344ec26c7319e84fd4e9809d2b5628f4e12decd618036e5178/numpy-2.5.3-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:86bff898a431c0fb71f7610b75726e75a54d47b37edc9d537f48de63bb3c0b90", size = 6783026, upload-time = "2026-09-06T16:26:46.374Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/c7/29285be1e5232a6e7ee3268a33c85843f5a8ee93350c6465cddd66ebbf76/numpy-2.5.3-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f3ed25271581281f2fccb1adcedfcde4c07362eec69189b50baf6f90e3ae159", size = 15697322, upload-time = "2026-09-06T16:26:49.415Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/49/bbad5335fb4996a16881f853ff3e0ba582f01720e55c89b1c06b8fc42a90/numpy-2.5.3-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ffdc76bfcae6b255dff75202c5e7feaf95b40246bc0a17944facc1fecf9f79ab", size = 16708995, upload-time = "2026-09-06T16:26:53.127Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/e9/1df35483760b04a65ea44669f89dc64f30e5aca098b48ceb8b1310b0e0fe/numpy-2.5.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:116f96cadd935c6122e9228d676fe7ede19e741f5c8bb1c3cddbe0c51ccebea2", size = 17052508, upload-time = "2026-09-06T16:26:56.464Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/99/66e54da8265cc8be8a7382bf96edce17aaa2837d6f484432025932a3caa5/numpy-2.5.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:09ffa5d903faeaa5c4dd05009cf81c8bab9f2cb37c548b8d39b65b4cfa7c97f7", size = 18468224, upload-time = "2026-09-06T16:26:59.966Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/bc/b5e90a91c115168d793dfd2ad9c69c438c2fe7a13a437e770bc5b078e732/numpy-2.5.3-cp315-cp315-win32.whl", hash = "sha256:e01c918ac3d48e18a927cf7b14a26a3e29ff2bdf2eacb976da0aecd6a43ed034", size = 6179919, upload-time = "2026-09-06T16:27:03.166Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/ea/780748fd3985109075514ef8fc64cd25f943e40dde13a6d59141eb268fc8/numpy-2.5.3-cp315-cp315-win_amd64.whl", hash = "sha256:e931e4f499e0dc7ef29d269a8e5b35dd722e5d14be07df6240166ea7c6532fae", size = 12697656, upload-time = "2026-09-06T16:27:06.153Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/16/407be69a2a87c8cab64d95975a8977a426a29e138f07e276ec258f0fe4e5/numpy-2.5.3-cp315-cp315-win_arm64.whl", hash = "sha256:26e15e4aecd8617dfbaecb37d223e365d7b39411fba20454be2670a96aa74cb5", size = 10767601, upload-time = "2026-09-06T16:27:09.297Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/bf/a97ffb01e41d50a32a9177aef942a4d0e389a3daf451d04e5f38ef6afb87/numpy-2.5.3-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:6cef4bb1706dfec49243c05d921eefb4e190d41e2528b30d8035ea1f36b4c24a", size = 17090092, upload-time = "2026-09-06T16:27:12.907Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/24/136c02f2c2af9a067a84d0c3aa10c99012c0476fa5066732fa4a4202557d/numpy-2.5.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:d1c89973648c85069c5046ad460f7b8a00218b29a2e42359ac8cc63e9ab94832", size = 12129429, upload-time = "2026-09-06T16:27:16.089Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/6c/b47582d6597789bf946d5efbeb6b9e56fd8bcbd5efc6fbf51dbe1ea31eb3/numpy-2.5.3-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:214045a5bf00113a146ab9ee9730c44501af6723cdf1f6830932f7b5ef2e7af0", size = 5565452, upload-time = "2026-09-06T16:27:19.868Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/b4/ef3cc6da73774202d4deae16bb321fd8298a4e0561e3539f8c4be237d916/numpy-2.5.3-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:8617bbfae4486cf99c9f899966699428d19da931d06ca94ad3da986c76e15997", size = 6876736, upload-time = "2026-09-06T16:27:22.232Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/24/e3813329498596cb842703dcacac1741612ed9fb9c4e6a3e0c7e2ebbc597/numpy-2.5.3-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:595d020938c84e320bcf40ad71089e108eac0d377cd018e14a8c094f39e98d85", size = 15745777, upload-time = "2026-09-06T16:27:25.181Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/9e/4e7a07fd0776dc2210cdacf2010be8665194d094defc10c419d7dea794cc/numpy-2.5.3-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f24021b9f22bc6301c37b196974a92c1c18dccedb6fef3dd252e95f2d6adbe4", size = 16746949, upload-time = "2026-09-06T16:27:28.576Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/db/01674c0e20335057813a00c2ebd546ed25bff9ed7914f9bced00f8c55d94/numpy-2.5.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:71b39d9f935b6ec0f8753e3e2afb51e3efba6f2e05b68b32a40754d24bcd4a3c", size = 17108994, upload-time = "2026-09-06T16:27:31.946Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/7a/584c5e71f8d378e57cac0b033891ed65c683ef90573ba4854e8c28203db0/numpy-2.5.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:6b05c171afb3aa07adbd20abc00aea86fe375beb0fdb9ef780ec5b7f63bab1c0", size = 18512266, upload-time = "2026-09-06T16:27:35.196Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/d2/4e1014173aa3c55e6a756e0e567290743a6ab33a288460374d7ef6bcd239/numpy-2.5.3-cp315-cp315t-win32.whl", hash = "sha256:f54660b0eb6b0b9f36e7fe1cdfdff472028dd0d14acd9b9b65098efbad059469", size = 6330292, upload-time = "2026-09-06T16:27:38.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/b0/ff5658a58199b7bcaad87bf260eef6713d9d42cca4e028f935b4fc5fbac6/numpy-2.5.3-cp315-cp315t-win_amd64.whl", hash = "sha256:1aad64d99730d013cfc6debafed22783b4fc5a7f4b8bc744d2d8cf7dcc880551", size = 12884918, upload-time = "2026-09-06T16:27:40.965Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/0b/b12a2df5d1b774bd9007a6fdff9381145b6223d37f11afc9c37ab0efd9a1/numpy-2.5.3-cp315-cp315t-win_arm64.whl", hash = "sha256:befa1ae5bd6030b3f512b43ff3fa5290bbed6b84411a44244b14adf835f5b89d", size = 10850807, upload-time = "2026-09-06T16:27:43.868Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-api"
|
||||
version = "1.44.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ee/8b/aa9e2d8b8dfa7c946f7dec5d1f8f6ba8eca062f43509a06bdb5ce93d26c0/opentelemetry_api-1.44.0.tar.gz", hash = "sha256:67647e5e9566edcf421166fdf022b3537f818635daa852b289e34604dc6fb33a", size = 72406, upload-time = "2026-07-16T15:25:32.678Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/6f/a04e900f465ff3221ccc395522503e2d10e79fa21f2723c8e177aae1e0d1/opentelemetry_api-1.44.0-py3-none-any.whl", hash = "sha256:94b98c893a91b88657eaac1e3ba89618cdb85be6918196705354f34728b2cdef", size = 60018, upload-time = "2026-07-16T15:25:11.657Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pebble"
|
||||
version = "5.2.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8a/60/daeaa992b73667dc6976fd6ed137aaff88801ad497e3ba19464d5dcf1c1d/pebble-5.2.2.tar.gz", hash = "sha256:eb49a0f6862f7e75df4a40107f1c8c267cf8adfbd6d29a0dac29d17ac188c878", size = 41367, upload-time = "2026-09-04T13:15:22.559Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/95/34/acd4067ea2dadc43038d7940836d893904671e404978eb455887cb3fb0f9/pebble-5.2.2-py3-none-any.whl", hash = "sha256:1058e3ed41c6bb5cc16fcf6a249502cd4637a597211b997d9a0a499f46bc91a4", size = 36355, upload-time = "2026-09-04T13:15:21.256Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pint"
|
||||
version = "0.26.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "flexcache" },
|
||||
{ name = "flexparser" },
|
||||
{ name = "platformdirs" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9f/bc/2c38c32e0fb1f966d3695f4493a3f3ce2cc0cca1bbe7c958b92261b31af9/pint-0.26.1.tar.gz", hash = "sha256:1bbde36eae57a5a289cd05081c6405618a5899814940752064ce351cd0204f71", size = 273631, upload-time = "2026-09-10T21:16:49.712Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/5c/9507ea3c732f8a259f45ddf190a741e9bf7d3fe229b5d473917ee6efbf1e/pint-0.26.1-py3-none-any.whl", hash = "sha256:e982b129415c09c63308f314ae44697d83e5c96f253bcc0d5b833fc3329eb6d4", size = 326478, upload-time = "2026-09-10T21:16:48.316Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "platformdirs"
|
||||
version = "4.11.8"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/53/18/f3bb8ef0d3b930692343da8aa4d3cbcd6749477c053959395ac81965a6e9/platformdirs-4.11.8.tar.gz", hash = "sha256:f23abafea7dd4276d1f29104b83598d7dcc567cafd07c9c951e66665645437fc", size = 37182, upload-time = "2026-09-08T22:20:42.866Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/e1/5b7b8bbb55084d1425bcb9bc823ff519e1b2be05f6ebb0089e2eacc38413/platformdirs-4.11.8-py3-none-any.whl", hash = "sha256:52f2f181bbfde907966932cc8312d967d02976422d66d537ea16092b8e291081", size = 24027, upload-time = "2026-09-08T22:20:41.537Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prometheus-client"
|
||||
version = "0.26.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/52/73/f1334c29c2af4cd9dba6c7817e61b611bd0215e2eb5565c6064a4de18802/prometheus_client-0.26.0.tar.gz", hash = "sha256:04a91bcf94e2cf74a44a1a874d651a2e853ed354b6e822f3b7487751465d5c2b", size = 92910, upload-time = "2026-07-24T19:36:41.893Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/a3/b69efbf4143b5b9859b977770bbbabcc2796b702fa69dc40271e45cd5a56/prometheus_client-0.26.0-py3-none-any.whl", hash = "sha256:fa93d06737aa02bacd05794768508bb97d2fbee28cb3bca04eaae92f0ca953d6", size = 64494, upload-time = "2026-07-24T19:36:40.854Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycparser"
|
||||
version = "3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.13.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-types" },
|
||||
{ name = "pydantic-core" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-core"
|
||||
version = "2.46.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885, upload-time = "2026-08-28T09:58:47.856Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768, upload-time = "2026-08-28T09:58:49.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241, upload-time = "2026-08-28T09:58:51.511Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975, upload-time = "2026-08-28T09:58:53.166Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542, upload-time = "2026-08-28T09:58:55.017Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692, upload-time = "2026-08-28T09:58:56.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633, upload-time = "2026-08-28T09:58:58.531Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235, upload-time = "2026-08-28T09:59:00.421Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367, upload-time = "2026-08-28T09:59:02.442Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420, upload-time = "2026-08-28T09:59:04.2Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588, upload-time = "2026-08-28T09:59:06.048Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866, upload-time = "2026-08-28T09:59:08.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580, upload-time = "2026-08-28T09:59:10.402Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980, upload-time = "2026-08-28T09:59:12.396Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213, upload-time = "2026-08-28T09:59:14.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/8a/14596f2a8367da50cf7cbac48169ee5d9c8e11d486a3b527082384630c72/pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355", size = 2074081, upload-time = "2026-08-28T09:59:16.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/d5/d8a4eb6d6c7f66b91dd37c576d76e9e60fba900caf5372c17bcf949febc2/pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e", size = 1920497, upload-time = "2026-08-28T09:59:18.065Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/26/092079428f86e927e030b2c0ced87df69dbb1c875cdeaa67bf42ea2be746/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3", size = 1952130, upload-time = "2026-08-28T09:59:20.476Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/c3/8ec0e290a9ebaebd64047bf5fda94be835c6b1551b02437e4b76778fbcd7/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c", size = 2026371, upload-time = "2026-08-28T09:59:22.227Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/72/4fd20ad520fb8da0157f95b27a7eb05a72790ef08138e7701ac972c342ea/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21", size = 2202822, upload-time = "2026-08-28T09:59:24.277Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/b0/d16e0771206b29314f0d52198b720be21e8a99ab2bf11e3bc0d7c9cebdff/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f", size = 2262756, upload-time = "2026-08-28T09:59:26.608Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f", size = 2068352, upload-time = "2026-08-28T09:59:29.044Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/7c/570abb1ad2155348dc754ea91be22e5aaa18eb6d69a6068f7c6f2679a6ed/pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a", size = 2104777, upload-time = "2026-08-28T09:59:30.95Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/25/5bf74adc65a1ac5b7be3f6cb0bcb5433615c1598a801c19d830d84c98ded/pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821", size = 2156312, upload-time = "2026-08-28T09:59:32.604Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/6a/2ef38830675e050121040618135564ed56b860b45433b02d9b4ebece46f3/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2", size = 2150067, upload-time = "2026-08-28T09:59:34.453Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/ef/a7dbb03a14a64c2a4621f989c615ed9a892535a6cad938fc27079f919d80/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47", size = 2304516, upload-time = "2026-08-28T09:59:36.194Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/f8/6bb4c4b80e8a6fde1904c64a51c62a1d04fcdfa3ea521a66b2ddefa1d885/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a", size = 2335223, upload-time = "2026-08-28T09:59:37.931Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/80/f46b8c681195190b2c1f1c7c0a81abce60663e987613e09ef64d433dd96b/pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074", size = 1934827, upload-time = "2026-08-28T09:59:39.836Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0", size = 2042648, upload-time = "2026-08-28T09:59:41.792Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/0c/117c562c7c1babdf44576b72a5e496906506c93690387ecfbca7c729ae2e/pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5", size = 1989652, upload-time = "2026-08-28T09:59:43.702Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/66/9336ae58f9eb68c41d121894e52c4c89eccb07eb8f602a04ee9c3f37736a/pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7", size = 2065829, upload-time = "2026-08-28T09:59:45.364Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/02/bc19b47a96c2d3109760711acf22369e56bd7e405ca52f7ade164d2ead57/pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3", size = 1905716, upload-time = "2026-08-28T09:59:47.18Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/a4/70b47c0509923dd98ccfed04fb3e32ea3849c82a0ff2205bb41009b43c00/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f", size = 1934216, upload-time = "2026-08-28T09:59:49.241Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/ab/aa03b65f7bb198585edf806b906c3223ecf1795543e39e23aec4cce27ad2/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7", size = 2010635, upload-time = "2026-08-28T09:59:51.692Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/8b/0da06343f30b84ec549aafd309c6456223d5dc8bd36af504c573faad561d/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c", size = 2209369, upload-time = "2026-08-28T09:59:53.582Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/5b/844c4defaa34a3df66eb9257087d121d70c201298b96abdf9f492fc2f1bf/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111", size = 2253238, upload-time = "2026-08-28T09:59:55.484Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/64/a4e536cb16d7f61a7fd3120b46c577fc7fa7325992f69c4f52bc786d77d8/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829", size = 2065740, upload-time = "2026-08-28T09:59:58.038Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/75/aaa38c6bc2d085f6605b34eabdc6a8a4e0b2e61fc9c8e6e52b28e97b3125/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa", size = 2087425, upload-time = "2026-08-28T09:59:59.898Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/ae/fcab4cfc39aba3689e1d20c8b5250ad280957022c09af2ed9cd585602a5e/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034", size = 2139306, upload-time = "2026-08-28T10:00:03.057Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/f4/f1d03a4bc9d9acbc62f4d742b8a319af52f71885079868b2ff8e48a651ee/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184", size = 2144589, upload-time = "2026-08-28T10:00:05.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/f3/7a53bb1356de514a4cd295f25b6ac39237895620c0462d2592b76c16e114/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38", size = 2288882, upload-time = "2026-08-28T10:00:07.931Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/94/5a81583660c175c59d49ffb09f4b3a44debeaf86a19fca664ae1cdd9ee32/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9", size = 2335210, upload-time = "2026-08-28T10:00:10.177Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/9f/5d685c2693b972d1a59c998586e8823712b66603aeff47ee60a4bdaafd37/pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9", size = 1921180, upload-time = "2026-08-28T10:00:12.35Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/12/5c94ee16d65a37a15f9e869f5e6256df111154491173801a4c5e800ab548/pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290", size = 2020515, upload-time = "2026-08-28T10:00:14.774Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyjwt"
|
||||
version = "2.14.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/af/c3/8a3b59c25070cc61dc517fbdfa5dc0904670c96f605cc69759dc09166b99/pyjwt-2.14.0.tar.gz", hash = "sha256:77283c83fb56ecf566a886c757a714bc83668e38156de2cce8263302f42e0b86", size = 113177, upload-time = "2026-09-11T13:11:54.638Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/97/672cb32ce0dfea44b740cb7b4f97038463b9cf7c0ead1aacf595572851d6/pyjwt-2.14.0-py3-none-any.whl", hash = "sha256:ad0cef71c756a56e74863c2919cf0985f72decbcfcb550ee2f422e7c62b5eedc", size = 32896, upload-time = "2026-09-11T13:11:53.409Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
crypto = [
|
||||
{ name = "cryptography" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-multipart"
|
||||
version = "0.0.32"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pywin32"
|
||||
version = "312"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "referencing"
|
||||
version = "0.37.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "attrs" },
|
||||
{ name = "rpds-py" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rpds-py"
|
||||
version = "2026.6.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726, upload-time = "2026-06-30T07:16:44.5Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587, upload-time = "2026-06-30T07:16:46.255Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585, upload-time = "2026-06-30T07:16:48.101Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479, upload-time = "2026-06-30T07:16:49.93Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418, upload-time = "2026-06-30T07:16:51.641Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123, upload-time = "2026-06-30T07:16:53.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351, upload-time = "2026-06-30T07:16:55.241Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827, upload-time = "2026-06-30T07:16:56.841Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966, upload-time = "2026-06-30T07:16:58.557Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680, upload-time = "2026-06-30T07:17:00.164Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853, upload-time = "2026-06-30T07:17:01.962Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715, upload-time = "2026-06-30T07:17:03.693Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864, upload-time = "2026-06-30T07:17:05.746Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430, upload-time = "2026-06-30T07:17:07.471Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877, upload-time = "2026-06-30T07:17:09.008Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933, upload-time = "2026-06-30T07:17:10.762Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274, upload-time = "2026-06-30T07:17:12.266Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763, upload-time = "2026-06-30T07:17:14.107Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467, upload-time = "2026-06-30T07:17:15.76Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689, upload-time = "2026-06-30T07:17:17.308Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340, upload-time = "2026-06-30T07:17:18.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179, upload-time = "2026-06-30T07:17:20.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993, upload-time = "2026-06-30T07:17:22.212Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909, upload-time = "2026-06-30T07:17:23.66Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584, upload-time = "2026-06-30T07:17:25.264Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357, upload-time = "2026-06-30T07:17:26.888Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533, upload-time = "2026-06-30T07:17:28.546Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204, upload-time = "2026-06-30T07:17:30.193Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.16.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/bb/5a449b9162e49b139d72f61672bd3ac1d790221f796d3304e2241fff4c58/ruff-0.16.7.tar.gz", hash = "sha256:5f71d004ac1263b22fa39462ac5ae618a4b77d58981af2cc79bf79a29c12b1a6", size = 4924184, upload-time = "2026-09-10T18:04:06.336Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/b2/c80aeeb7f9e469c0d63a85d2f1ab6e1ebfbe10ea7a8d2438b7e09e3ff09e/ruff-0.16.7-py3-none-linux_armv6l.whl", hash = "sha256:727307773e7c7f9181d3ed3a2484186e56c1fa1874255911c74585eb2c7c19f9", size = 10048917, upload-time = "2026-09-10T18:03:30.28Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/96/20bb7bcae008004df52afcb7ac83432d4a467f2c17b672fe46d26be231c5/ruff-0.16.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9d61c258deabf58f34c67bd4bb4d939c7f2e6b5f0e59c1cdd1cf771b11cde929", size = 10242929, upload-time = "2026-09-10T18:03:32.706Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/b2/f184b0d5abec02db69cfd7e49b688ae0237554528ca777136c613bf36bee/ruff-0.16.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ab81118df8945e0193d0240712aa4496573595b75185c3636ed825592a0f728", size = 9847245, upload-time = "2026-09-10T18:03:34.509Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/2d/db1633a641866ed801e34cc6b60ef236c5e16f9b2124ab1d49cc24a5fe4f/ruff-0.16.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c196c968874fc8019da8e7163de7a1a370f111e2309b4b7dfea0fce950198d0", size = 9961780, upload-time = "2026-09-10T18:03:36.618Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/98/edea21e1a3e38dbbc3bf6bb068b863b3b06184cf8533a4c7dbbe208a89d5/ruff-0.16.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac8c3bd0a7e10ad31e6ce51e7a99f3cb772e69aecdd6b9ea7e99b362f62a62c0", size = 9866337, upload-time = "2026-09-10T18:03:38.805Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/11/a15e60d4c87b214646f116ca9d204475bf993ee1047459bc9a360fd4d6d1/ruff-0.16.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:398d3988edde000b5c75dc1b3f584708da9bc990de069c18909142580fec1af9", size = 10562512, upload-time = "2026-09-10T18:03:40.71Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/42/eaff4c9b6d0c7cdf56df313a17e89ae854f5bbc0b0c8f9cce19be0ab7a8f/ruff-0.16.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce05b62b770a8217c4646a9c4139fca00efe8fe5d71f87df2b243ff20d4584d1", size = 11302938, upload-time = "2026-09-10T18:03:42.607Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/43/c75aa59a4ec181fe2ec06cab30e198c1c6d107229a9f008ae3a7c16cabd8/ruff-0.16.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:af1b576fddb9d9ef2ececfb5fadcd6a624b25070ed85e3cfcfe449fc3ff6a7b9", size = 10840857, upload-time = "2026-09-10T18:03:44.604Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/33/81f3da371942ea031105ba679d8d6e28ec1660ccd690a45f42d381161356/ruff-0.16.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ce7f8f22df67c93ed96c717f9128eadb797144ac2bad475cf536f31d6100c55", size = 10370001, upload-time = "2026-09-10T18:03:46.706Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/0b/6345fb4dbf6dd0ed1cfe5d18391dc9c3f59cc81622a7b0a65b84b3e730ba/ruff-0.16.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:06d0e93d04f392996435ebd600c153f65b47d73fbec2415aa99c5ee5756b3a5f", size = 10548735, upload-time = "2026-09-10T18:03:48.658Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/4d/c5576adf511f92a328e5569dda190ecdd430da51f1a649f3a4a2fd73e21e/ruff-0.16.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:142151a5e7b93c1b11111337142f89dd2fbfee92161225c99a97222f22e32656", size = 10108496, upload-time = "2026-09-10T18:03:50.563Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/8c/667d83c16199a17a56adc6b0bd4c3beb5b767a2babcd16a56f76f9be7fd6/ruff-0.16.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e6651f97a342d8b35d54d8991544ca22169b86dc54111cb604666940c431b750", size = 9860136, upload-time = "2026-09-10T18:03:52.621Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/75/78d401106731999a1dd20cc5a6961e37e1eb9397a3b589f73f3a5ce146a3/ruff-0.16.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ef140c6eb935fa9a84c9c607dfb2cb1b85843c192e79265b0c54f35f557ea8e5", size = 10286290, upload-time = "2026-09-10T18:03:55.207Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/49/56f9c3a8b755df93a0ad318b2147bf4ef5dae9a7e5ec61c460109c67957f/ruff-0.16.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:53e39506a730fadeee0d998ed5946f30671f0db240c6c7c73bdabbe33604bb6f", size = 10745048, upload-time = "2026-09-10T18:03:57.299Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/ea/7f9b938a63ece4bec677ad7f9f7fa02df3383db1949ed93a382441c09a87/ruff-0.16.7-py3-none-win32.whl", hash = "sha256:2ea3470fcebcbc5df2fb0c6f3b90333fa9084c534e0111c038fa4a6ab9f1c4b7", size = 10059082, upload-time = "2026-09-10T18:03:59.632Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/11/480a6973a927aa653e1cead6a6416008640e03a99d05b34c0434b8c6c366/ruff-0.16.7-py3-none-win_amd64.whl", hash = "sha256:7ac26aca826e9e21d0f1cb25b54ac660760a9fdd094d3e4df9848232be98cfc6", size = 10593368, upload-time = "2026-09-10T18:04:01.999Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/4b/51327018d056f0dad2c2238f26d1fb0f53707a9d91b75dea6d1b3039f136/ruff-0.16.7-py3-none-win_arm64.whl", hash = "sha256:aab7f39e2c9df6c596216070f98eef1207b94f8516cca20c808826974971855b", size = 10412401, upload-time = "2026-09-10T18:04:04.098Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scipy"
|
||||
version = "1.18.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "numpy" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7e/74/66de6258867beb2ef08f35f9f2ac017a52cacd5081714d239ff1a442d458/scipy-1.18.1.tar.gz", hash = "sha256:52c4b7422442aba924d03ad4019852b08a92e64ea187b933135687bfe2747307", size = 30781235, upload-time = "2026-08-21T23:28:50.599Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/18/f7/240c110c08693826b4513a52f5717d62ec7c7af72f2920821247c03b17b3/scipy-1.18.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:457fd7a2a8edeb044ab6ffbc0aa03ff6cd18491356e5e0c834d76ce621b916d1", size = 31111061, upload-time = "2026-08-21T23:23:44.522Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/4a/78c6285577c375e7cf27277ea8ee6961224327f1e1a0c44af5f17f23635c/scipy-1.18.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:e708533e8b2ae2497d65346538a7dcc92814410b25b81432eac66de0f2af8265", size = 28733332, upload-time = "2026-08-21T23:23:50.015Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/f6/a5b82f8abbe14d134691b8b903696f701d25a081353a29dc655c364d9e62/scipy-1.18.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:7bbf207c4453ce1ad2e00b17313852b33310b83090c2311bdaf97f93c0380d12", size = 20475078, upload-time = "2026-08-21T23:23:54.138Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/22/0858a0bbd6b3e825ceb8cd9baf9eaf3b2f2b1d77727eb6be40500bcdc92f/scipy-1.18.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:78c0665edead396b1abb4897c41a5c1d9bf090c8a637a4c20a61678e0a264e66", size = 23108904, upload-time = "2026-08-21T23:23:57.824Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/9a/2e71719f31eaefe0e3a1706c4a1ded94e664bfd95ffca2b219a671faee01/scipy-1.18.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c085faa2cfa879c5141df483f836f4d691045a078224a670fa570fa01612d89", size = 34025113, upload-time = "2026-08-21T23:24:02.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/64/ff35eb9e54894cf471ff4716abd3c81eb0a0626869217ce3e6ba4ccf17d7/scipy-1.18.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f55fa87b6c612ecd6b058f167c53231b1d14e412efe361d3d6e38b3631c73218", size = 35344199, upload-time = "2026-08-21T23:24:07.844Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/af/c5538be1792f7034c12c7db6ee67cace58253c7b87b122d68253eaf5de89/scipy-1.18.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c35d74ce0e193ff740c2f2be2ac913ddc232fe6c1ff40b26cfecb9c670c63314", size = 35639587, upload-time = "2026-08-21T23:24:13.05Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/4c/075e4f66471bac101141ac739e9e135549be1bae584571bd03a530c056e1/scipy-1.18.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d2924a03db38dc2e848bca2fe9f077dafb891480b91a00a0963a8cf86dfc31c1", size = 37480330, upload-time = "2026-08-21T23:24:19.608Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/e7/979fd14e75008623df31ba70d6bb144700f68feadcea042021c06a05bf82/scipy-1.18.1-cp312-cp312-win_amd64.whl", hash = "sha256:5e4d44984abc0020154ea81b247adeddcc3ac5527b975ff798bd1ba0adc513c2", size = 36658278, upload-time = "2026-08-21T23:24:25.463Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/0b/e1525354ff9d7d5feb6d1b31af6d14072e5c91e9607b421fa1ec889660b3/scipy-1.18.1-cp312-cp312-win_arm64.whl", hash = "sha256:d65d448389b8436493abcf629cc94ad0cf32aecaf06e1acca1de53cc795f2f12", size = 24400588, upload-time = "2026-08-21T23:24:30.579Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/55/4540ee0f9c42a9ad7109d0d1a8cc70de54c3572b01c6693a2b1c70e90ceb/scipy-1.18.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:3ab3523da44749156e1f68b464dc56af11ae4cbc5c739a49d05f32b982eca9f3", size = 31089958, upload-time = "2026-08-21T23:24:35.8Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/f5/769f36d14922b8071a43e95d24d18b6bdafad10d7f5cf647867e1ac052bc/scipy-1.18.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:e6fb6a55cc0ba97b59a1f288fb86dc6fce8bdfc0fffcbfd015e3a954bf2a2d93", size = 28715106, upload-time = "2026-08-21T23:24:40.775Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/d7/21d890274f75ea37a8209d5519e72da3da90302e3b9fb8397a0918386a62/scipy-1.18.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:ea324d9dd34c38bfb9bec8ca4d1b407db97dbb74029f566b8e322b1b6fe56fe6", size = 20456846, upload-time = "2026-08-21T23:24:45.066Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/01/798430ecea2e78ec7c02663d5f71c007bb6abeca931080debd40d7fa55ea/scipy-1.18.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:75b00eb8fb802090aa903f4ea1c7f5a584779f967361e68b7e98e531cc2d7174", size = 23087986, upload-time = "2026-08-21T23:24:49.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/5f/4634e9d35c68496e4e34cb6946eafab044458e6cedab42b40b6588e475b6/scipy-1.18.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d416b16cccfd70fbf62400e84d0bb2f4e6af519a45557f1692c749b37f14b315", size = 33998146, upload-time = "2026-08-21T23:24:54.714Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/48/6450ed9243315322bbc19ac57b9b70d66a20bf1d38d124c96bc4bf6af9ea/scipy-1.18.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fdaf5ea890a6183d0565f51a61799d67081bd5b1cf03c5f4b3fd3732108625c9", size = 35312578, upload-time = "2026-08-21T23:25:00.44Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/bd/bf5a4be6a3525676499f6dff307991739ff6fdcad1481b1aeb6745339f58/scipy-1.18.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c825cef2f49e46753726a7181a8e199804a912b29519ada542c6ebc654951899", size = 35612621, upload-time = "2026-08-21T23:25:06.144Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/4e/3c45c33e00a77996c4b1cb707929f833ba7b1d522ee29f882512c330676d/scipy-1.18.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e3b417bf8c2c7c16e8f58ad91db17783ec911ac16e7b50eb6eab6e809b4f5b07", size = 37457323, upload-time = "2026-08-21T23:25:12.483Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/0e/e0348fbc0dbab65c114cf78957e7dfeb49f8e8b556b4d930cc12ff195e18/scipy-1.18.1-cp313-cp313-win_amd64.whl", hash = "sha256:559ed65f60c1af5a03f3912605a1b5114f522c7c32fb23c3376ae8f03219fe28", size = 36622841, upload-time = "2026-08-21T23:25:18.722Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/a8/6a77f5f267c555108f0a864b6db714363dab567a8266422a79a385f9232b/scipy-1.18.1-cp313-cp313-win_arm64.whl", hash = "sha256:cd479fc04dd9401e3b4f49e76518768ef99c4f517a98c284eb091fd725719adf", size = 24399315, upload-time = "2026-08-21T23:25:23.458Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/d5/d8eb4e280ddb56a4ab2c6f02ee49b56b23f6e977cf0802fd6d68dbef14f5/scipy-1.18.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:83de5453a7799afc9048b4616bd085cef126e36412f0ea2f6370c36a2a3a51e7", size = 31090936, upload-time = "2026-08-21T23:25:28.686Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/49/59ea385dc3a62ff498ddf3cfff7c2b41b0f9f9d3c4122b3f1dcb6d6327fe/scipy-1.18.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:9554bcc6d715ee87a633a3cc8e7703c6628b100dd29cb8a2efc4c0533c7ff729", size = 28725221, upload-time = "2026-08-21T23:25:33.244Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/e8/6b0c288c50942d78193696c9f15f9a0874f5178aa0ddf40f83d9924b3e8d/scipy-1.18.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:011413b7426b75012840e35649e00fe0a2c3bae89fed433876e3a99251572efc", size = 20466839, upload-time = "2026-08-21T23:25:37.516Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/e0/54fd3793c729e3b936782f181b59cbb1205bf250ab605a16cb1ba61cdd5e/scipy-1.18.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:88f0e784020649f88ea48c9f5ddfa403bf9205820667c0914740b392035afb82", size = 23089121, upload-time = "2026-08-21T23:25:42.019Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/56/030af62bea3cf878e0028515dff78c123b01633606a879b63f42d2db99cc/scipy-1.18.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d3ab0e8c69a17dd3559eab8cbb88f258e285c94d572c2719033f90f83290c89", size = 34053851, upload-time = "2026-08-21T23:25:47.998Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/89/2a844506d49651e9aa1af6ef95b6bd8031cb1d5a4375edec6155037e04cf/scipy-1.18.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ac0333bdf38309aa3dcbe7e3fa7ea29e7a2c37c6ea306a757b700ded8e4596ad", size = 35329183, upload-time = "2026-08-21T23:25:53.522Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/56/c7370c3640e92ac9613cbf26cb3f729f9b12ddf1727b55b94b53b24d6f48/scipy-1.18.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:911de823097db8b63f034299d12662db93344e6ffa0b881cbb57748974b70168", size = 35672551, upload-time = "2026-08-21T23:25:59.387Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/16/ec8536f351421f8bf60a1120930638f83790f4710b8230446aca3d6159d4/scipy-1.18.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:95298364e251be3e60249facbeeca03631d3bb7584f85879516ec55ac717b81f", size = 37469416, upload-time = "2026-08-21T23:26:05.432Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/94/d73da0d28f16c45bb9b0a5691b91610b0275c5ef0eb5e43c87cf2dc1bf31/scipy-1.18.1-cp314-cp314-win_amd64.whl", hash = "sha256:78a0d7c918e74a232394117160e7e3db503377572a45bcef8826e4ab8a35feba", size = 37362755, upload-time = "2026-08-21T23:26:11.366Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/25/e996e4dc74e10e227b1e14db5eaf6608bb6dd33884a64851c38f18dd4249/scipy-1.18.1-cp314-cp314-win_arm64.whl", hash = "sha256:cbf38d043c1aa4ab306e1ada6ab6eddacc3322a20b7af1b30bc93254b366fe09", size = 25036090, upload-time = "2026-08-21T23:26:15.887Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/c9/c00213f92309d753b48903e6a451b87eb52ff5b7a16e789d1568bbf221c4/scipy-1.18.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0fcb3c93519f27bb4f0c4b0f7802cdcaca7fcf93267b75edda2e9f4e8a55cbd7", size = 31485550, upload-time = "2026-08-21T23:26:20.776Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/b2/e3067c487982d4eeab2938928529410370c06fea84a4d3f4925e7d96647d/scipy-1.18.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:ddef79fb382df40104a19bb7151b3b23e57c1778fcf857c71ceecd9bd264513f", size = 29174642, upload-time = "2026-08-21T23:26:25.395Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/ab/374c9fe2d1ec014e576c781a4b5d8e1ba340e8f6b4638c16f711d2b194f0/scipy-1.18.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:0e82073ecc7acc6436fac4b31674109c7e1d3e596789767eda01258a8c9e8123", size = 20916357, upload-time = "2026-08-21T23:26:30.112Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/38/223915c88a17317cafbf8ca2a42b11c265a9fb1e804aa665544132b5fe8a/scipy-1.18.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:8bcf3c1ba5d6456e2effd30fcbd3459b044d683fcdac79a2e6830f0bdf7de487", size = 23482611, upload-time = "2026-08-21T23:26:34.846Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/d1/db0948da8ca57a80b36520ef0a768b967d99f3af65f4b6f1bf6362ad4dd4/scipy-1.18.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cfbf154f2ba187f2ed6cce2639efff7d105f1140573642c0161615b6d91d6a87", size = 34143202, upload-time = "2026-08-21T23:26:40.4Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/53/39d046cc7574ed6acacb6bd5723e220107ece80bff12faaf3efc4ddeede4/scipy-1.18.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a1d33a7836f7ddc1993427966a0823468ec41bcbdb1a9f9942d1d7e57f803ba3", size = 35380876, upload-time = "2026-08-21T23:26:46.1Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/da/32e0e799d875a85ca57d9bde6c78148afcc0e38276df683d95854eadc8c3/scipy-1.18.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4b8bc363b6d65ee2152bec57568e3c52639bb34c46057b09857a307ed5e21d", size = 35770885, upload-time = "2026-08-21T23:26:51.533Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/2e/f97a666d362fee68b18f41c9c30ed502ca5c98b549749bfcb52a8b74d1eb/scipy-1.18.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:11c423f1049c5755ad4409af52a9ada1cff96fe9b50795d4af3619f292901239", size = 37525424, upload-time = "2026-08-21T23:26:56.751Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/d5/a9e765a84654ebba8479a1fd1b059ced1af72b168a3b2a3a46540ea38d20/scipy-1.18.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c24acac1e18912761c4700239bbc1fd32f615af690f1584d49b35859be51324d", size = 37416961, upload-time = "2026-08-21T23:27:01.546Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/16/e79e0d1c63ef698879d85439d37e9fb434e3b804e506a6991038d086ebd9/scipy-1.18.1-cp314-cp314t-win_arm64.whl", hash = "sha256:9f2897bf7737392ad0d5213ea7b6add72a4edf5679b3153106aeb88b6507b3b9", size = 25331848, upload-time = "2026-08-21T23:27:05.884Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/4f/1bd37c883b67163e2ca1f60977a399500e6879c15defecac62831c8d078d/scipy-1.18.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:eb0dfcf4e28a99c12c999744a2ff67c9b06200e20401c7c88186e33552a46331", size = 31091484, upload-time = "2026-08-21T23:27:11.051Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/c5/ba929d7feb9b2332f96827c12e0e924b61973b59b4dea383b603372c65ce/scipy-1.18.1-cp315-cp315-macosx_12_0_arm64.whl", hash = "sha256:30f464bee641fa8e282577c7dce027308403213c6ca8270bba73285c91024bc5", size = 28725057, upload-time = "2026-08-21T23:27:15.9Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/19/68f1c50f609d955d230e66d25d02bd3e1e167ec540232135354fb9a4b9e3/scipy-1.18.1-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:1bca3b943fc2567ea49cd02c99abde49da4d5178ec46f624bd8255cda8755beb", size = 20466734, upload-time = "2026-08-21T23:27:20.044Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/6d/319fa29b73d1802fa80b32a6eaf3f5be456ef81526da2716a9493bcb5501/scipy-1.18.1-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:c9d18a33309122074ea483dd92dd444189166b8b2ec429fe9ed5ac73c7a0aa23", size = 23089664, upload-time = "2026-08-21T23:27:24.345Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/db/30992f9b51a63de671daf3888ffd18378b6cb9ec9f2c972264238ffa7fd6/scipy-1.18.1-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82f201b4c878551d48558337aab270d3c6cca5507b8737c8d8a608d234cccde0", size = 34054035, upload-time = "2026-08-21T23:27:29.409Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/d4/bf3e735dc0b9d5a8ff45079d2540e17d3aff7a2f0048dd8f552ffd031d2b/scipy-1.18.1-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ac49ea97594532dd44b7136094d35f5440fa06e6d9c6384a74c01764df388c5", size = 35333883, upload-time = "2026-08-21T23:27:34.293Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/93/12d78ce9f871fe945fca588d32644e6e63f553c2a35c564d73f3b22a3313/scipy-1.18.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:ceb30a00ce7c92d459819443d29ca486d882b83fb6738bdcbb2a1cce94ac5daa", size = 35673124, upload-time = "2026-08-21T23:27:39.059Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/cd/886219313a1012a48e6ae0ec4f302c837151beb92e1ff0d709ef8fdfc488/scipy-1.18.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f29633129f9fa7e88a3f0fca835de2d030bfc9643f7799e1a0c46cee24d38fc7", size = 37470753, upload-time = "2026-08-21T23:27:44.435Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/6c/a776888ce618bee54fbde26172f0f46ac1da70d27b63861797fe78e1904b/scipy-1.18.1-cp315-cp315-win_amd64.whl", hash = "sha256:92c14f5bdbfb6216315ce33e78080474082de8b3830122ba97809bfbe65f75c0", size = 37361483, upload-time = "2026-08-21T23:27:49.334Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/09/97b651691322ebee97999b017ffc18a15a0b815103844c97e8da9d469731/scipy-1.18.1-cp315-cp315-win_arm64.whl", hash = "sha256:e402cf31eb68f453dbb2d36fc6d722b33f24a55d68b2ae1d92fa6305ca71c298", size = 25035883, upload-time = "2026-08-21T23:27:53.596Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/0f/9ec20467bbabd0d44e2a77d0fd3d124f884b4d67df92af82c91d2d6a486f/scipy-1.18.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:2a0b02f9fc46f8520330c23d45e6560db7e3a0d927232139427637f98943e11d", size = 31474926, upload-time = "2026-08-21T23:27:57.993Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/58/dcb79161e56efbedc50079fcd2f5fe427a0ebb53022eb476aa73c015ad8f/scipy-1.18.1-cp315-cp315t-macosx_12_0_arm64.whl", hash = "sha256:1d73131e358976663dd969e1fb4ed1404b815cd977eaaedc3b3a133ba2d81c35", size = 29164940, upload-time = "2026-08-21T23:28:03.062Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/d3/1eeea80c817fcb8ef7bd4a05a58824977a0e57a375cfc3d7ea7c911c01ad/scipy-1.18.1-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:bff0b729edd992766136b34e39cc76bc2fad905aa58897ee72a9cd000a6d8443", size = 20906742, upload-time = "2026-08-21T23:28:07.642Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/46/e59350428b6099301a20128108c995e2eb175a43f383af9a346e38824f9b/scipy-1.18.1-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:10ac20c69d880f77f375db44c22e3e6a644f9fefa291d4cd2fb9790a89fc99fd", size = 23472183, upload-time = "2026-08-21T23:28:12.109Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/31/cc91623fa98f0621766a0f0aaaadb2c66de74a7ea7e3837164f6e4354260/scipy-1.18.1-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33a834464fdabc0f26a45508df31b3cc5d028e04dbf6c5ed398541418e0a12fe", size = 34130796, upload-time = "2026-08-21T23:28:17.906Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/3e/8572ef536957ddb8aa81bb4090d9e25f257e3b4e05d97deb54319deb8a3a/scipy-1.18.1-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49023963c193dacee096301452f223ee24d86ec5807f8df93c0f7221d119e305", size = 35374253, upload-time = "2026-08-21T23:28:23.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/c6/59fdeffb4f1435299f93d9dc8140b43ad2916e6cfc944be6c3041fcec86d/scipy-1.18.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:d84a09d0dad90ba6525d8ac1c2334b33e64bf3ccfe9e841f02feb867a22681e4", size = 35758543, upload-time = "2026-08-21T23:28:29.431Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/d9/135be205d9de8783193aff9cc3bf483a03a38e4b29432c954e8cb66ac14e/scipy-1.18.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:179ce34a8d0fe273d8883ba59e17e052247d08973dfcb743ca52bb1cce2d60b0", size = 37521946, upload-time = "2026-08-21T23:28:35.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/a2/5b7d5270621ab7cfa3f7766067bf95dc360b5efb6394694e8143b4156e2b/scipy-1.18.1-cp315-cp315t-win_amd64.whl", hash = "sha256:5632e3ae3d09197c446310cd5187de63e28448ce22f0f67b2b93d97503c0c230", size = 37408295, upload-time = "2026-08-21T23:28:40.724Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/ad/741c19fcb66755ff953daf9243af8480e4bf3d7fbe57583c178c7d2b6b51/scipy-1.18.1-cp315-cp315t-win_arm64.whl", hash = "sha256:eda632a7981f69730d6281f451db9c1c370993a2c0d7ddb43e2a809a2862b83a", size = 25319710, upload-time = "2026-08-21T23:28:45.713Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sse-starlette"
|
||||
version = "3.4.11"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "starlette" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2b/54/6767bb789b2f2fed6e0f953df949cd39dc263a384c1b65a95232598621d6/sse_starlette-3.4.11.tar.gz", hash = "sha256:1bae716c02f3e6f294be41ff333220692dae7c3cbab077c900f159676719dade", size = 34972, upload-time = "2026-09-05T12:11:04.607Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/98/6a/2ba3ed4a69babf3afdddf7d8314a48d87562c0a442206bbc2a1b50d5efc0/sse_starlette-3.4.11-py3-none-any.whl", hash = "sha256:c7b2244bdff016fe7f64e10075e89a3e6bbf899649cc89b0fe884b5545042453", size = 17122, upload-time = "2026-09-05T12:11:03.195Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "starlette"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b5/b4/205b0d5241d934e8add0c38aa924c4f9fb7330834ff11e5444db964ec3f9/starlette-1.6.0.tar.gz", hash = "sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b", size = 2716969, upload-time = "2026-08-08T18:27:57.512Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sympy"
|
||||
version = "1.14.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "mpmath" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "truststore"
|
||||
version = "0.10.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-inspection"
|
||||
version = "0.4.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uvicorn"
|
||||
version = "0.52.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f2/0f/3f86e61397dd33bf2ccf28188c40db6a740658aeebbbf6e7dbc101a1f487/uvicorn-0.52.4.tar.gz", hash = "sha256:73acfee47a0b133c5de13d219492d62d8a31e935f4fe6e41a232451a15379f86", size = 100627, upload-time = "2026-08-19T06:27:41.821Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/79/4a20b54ab0491485ccd8c077db2d39187c7f12b3e15485d38a7be37c81b4/uvicorn-0.52.4-py3-none-any.whl", hash = "sha256:f86e41a149d7d05a9969337e3946a9c171c06a5d42680896daaba624aeac8da1", size = 79871, upload-time = "2026-08-19T06:27:40.36Z" },
|
||||
]
|
||||
Loading…
Add table
Reference in a new issue