test: raise coverage 64% -> 90% with behavior-focused tests + enforced gate
build-image / test (push) Failing after 53s
build-image / build (push) Has been skipped

New suites assert real behavior, not just that code runs:
- test_types: workout serialization round-trips (recursive steps, camelCase
  keys, enum conversion) + __str__ formatting.
- test_api_client: request construction (URL/method/auth/body) and the full
  HTTP status-code -> message mapping.
- test_auth: RS256 JWT verification — valid -> AccessToken; expired/wrong-aud/
  wrong-issuer/wrong-key/missing-claim -> None; audience slash variants.
- test_server_setup: transport selection + start_server dispatch.
- test_events / test_activities / test_custom_items: request payloads
  (create vs update, POST/PUT/DELETE), delete accounting, JSON-content parsing,
  and error/empty branches.

Enforce >=90 via pytest --cov-fail-under=90; CI test job now gates the build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-04 18:39:20 -04:00
parent 1ec2a76d1e
commit 43bbbb6bbb
11 changed files with 1346 additions and 2 deletions
+17
View File
@@ -10,7 +10,24 @@ permissions:
packages: write
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 15
env:
API_KEY: test
ATHLETE_ID: i1
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install (with dev extras)
run: pip install --quiet ".[dev]"
- name: Test + coverage gate (>=90%)
run: pytest
build:
needs: test
runs-on: ubuntu-latest
timeout-minutes: 20
env:
+1
View File
@@ -0,0 +1 @@
{"id":89,"owner":{"id":16,"login":"farhoodlabs","login_name":"","source_id":0,"full_name":"Farhood Labs","email":"","avatar_url":"https://git.farh.net/avatars/460b032d00536bd5638f3c0913c6a9d5","html_url":"https://git.farh.net/farhoodlabs","language":"","is_admin":false,"last_login":"0001-01-01T00:00:00Z","created":"2026-05-16T13:56:14Z","restricted":false,"active":false,"prohibit_login":false,"location":"","website":"","description":"","visibility":"public","followers_count":0,"following_count":0,"starred_repos_count":0,"username":"farhoodlabs"},"name":"intervalsicu-mcp","full_name":"farhoodlabs/intervalsicu-mcp","description":"Intervals.icu MCP server (FastMCP, native OAuth) — farhoodlabs build","empty":false,"private":true,"fork":false,"template":false,"mirror":false,"size":169,"language":"Python","languages_url":"https://git.farh.net/api/v1/repos/farhoodlabs/intervalsicu-mcp/languages","html_url":"https://git.farh.net/farhoodlabs/intervalsicu-mcp","url":"https://git.farh.net/api/v1/repos/farhoodlabs/intervalsicu-mcp","link":"","ssh_url":"git@git.farh.net:farhoodlabs/intervalsicu-mcp.git","clone_url":"https://git.farh.net/farhoodlabs/intervalsicu-mcp.git","original_url":"","website":"","stars_count":0,"forks_count":0,"watchers_count":8,"branch_count":1,"open_issues_count":0,"open_pr_counter":0,"release_counter":0,"default_branch":"main","archived":false,"created_at":"2026-07-04T17:57:36Z","updated_at":"2026-07-04T19:12:59Z","archived_at":"1970-01-01T00:00:00Z","permissions":{"admin":true,"push":true,"pull":true},"has_code":true,"has_issues":true,"internal_tracker":{"enable_time_tracker":true,"allow_only_contributors_to_track_time":true,"enable_issue_dependencies":true},"has_wiki":true,"has_pull_requests":true,"has_projects":true,"projects_mode":"all","has_releases":true,"has_packages":true,"has_actions":true,"ignore_whitespace_conflicts":false,"allow_merge_commits":true,"allow_rebase":true,"allow_rebase_explicit":true,"allow_squash_merge":true,"allow_fast_forward_only_merge":true,"allow_rebase_update":true,"allow_manual_merge":false,"autodetect_manual_merge":false,"default_delete_branch_after_merge":false,"default_merge_style":"merge","default_allow_maintainer_edit":true,"avatar_url":"","internal":false,"mirror_interval":"","object_format_name":"sha1","mirror_updated":"0001-01-01T00:00:00Z","topics":[],"licenses":[]}
+2 -2
View File
@@ -31,7 +31,7 @@ keywords = ["intervals", "cycling", "running", "mcp", "ai"]
"Upstream" = "https://github.com/mvilanova/intervals-mcp-server"
[project.optional-dependencies]
dev = ["pytest>=8.3.5", "mypy>=1.0.0", "ruff>=0.1.0", "pytest-asyncio>=0.21", "pre-commit", "hatch", "pytest-mock==3.12.0"]
dev = ["pytest>=8.3.5", "mypy>=1.0.0", "ruff>=0.1.0", "pytest-asyncio>=0.21", "pre-commit", "hatch", "pytest-mock==3.12.0", "pytest-cov>=5.0", "cryptography>=42.0"]
[tool.hatch.build]
include = ["server.py", "utils/*.py", "README.md", ".env.example"]
@@ -114,7 +114,7 @@ default.unicode = true
default.locale = "en-us"
[tool.pytest.ini_options]
addopts = "-q"
addopts = "-q --cov=src/intervals_mcp_server --cov-report=term-missing --cov-fail-under=90"
testpaths = ["tests"]
python_files = "test_*.py"
asyncio_default_fixture_loop_scope = "function"
+184
View File
@@ -0,0 +1,184 @@
"""
Tests for intervals_mcp_server.tools.activities.
Covers the result-parsing helpers, the named-activity filtering + fetch-more
top-up logic, request params, and the per-tool error/empty/format branches.
Gear resolution is stubbed so these isolate the activity logic.
"""
import asyncio
import pytest
from intervals_mcp_server.tools import activities
from intervals_mcp_server.tools.activities import (
_filter_named_activities,
_format_activities_response,
_parse_activities_from_result,
)
async def _noop(*_args, **_kwargs):
return None
@pytest.fixture(autouse=True)
def _stub_gear(monkeypatch):
monkeypatch.setattr(activities, "resolve_gear_for_activities", _noop)
monkeypatch.setattr(activities, "resolve_gear_for_activity", _noop)
def _patch_request(monkeypatch, handler):
calls: list[dict] = []
async def fake(**kwargs):
calls.append(kwargs)
return handler(len(calls), kwargs)
monkeypatch.setattr(activities, "make_intervals_request", fake)
return calls
# --------------------------------------------------------------------------- #
# Pure helpers
# --------------------------------------------------------------------------- #
def test_parse_from_list_keeps_only_dicts():
assert _parse_activities_from_result([{"a": 1}, "junk", {"b": 2}]) == [{"a": 1}, {"b": 2}]
def test_parse_from_container_dict():
result = {"activities": [{"name": "Ride"}], "meta": 1}
assert _parse_activities_from_result(result) == [{"name": "Ride"}]
def test_parse_single_activity_dict():
single = {"name": "Ride", "distance": 1000}
assert _parse_activities_from_result(single) == [single]
def test_parse_unrecognized_dict_returns_empty():
assert _parse_activities_from_result({"foo": "bar"}) == []
def test_filter_named_drops_unnamed_and_blank():
acts = [{"name": "Ride"}, {"name": "Unnamed"}, {"name": ""}, {"id": 1}]
assert _filter_named_activities(acts) == [{"name": "Ride"}]
def test_format_response_empty_named_hint():
out = _format_activities_response([], "i1", include_unnamed=False)
assert "include_unnamed=True" in out
# --------------------------------------------------------------------------- #
# get_activities
# --------------------------------------------------------------------------- #
def test_get_activities_error(monkeypatch):
_patch_request(monkeypatch, lambda _n, _k: {"error": True, "message": "rate limited"})
out = asyncio.run(activities.get_activities(athlete_id="i1"))
assert "Error fetching activities: rate limited" in out
def test_get_activities_requests_triple_limit_and_filters(monkeypatch):
def handler(n, _k):
if n == 1:
return [{"name": "Ride A", "id": 1, "distance": 1000}]
return [{"name": "Ride B", "id": 2, "distance": 2000}, {"name": "Unnamed", "id": 3}]
calls = _patch_request(monkeypatch, handler)
out = asyncio.run(
activities.get_activities(athlete_id="i1", start_date="2026-06-01", end_date="2026-06-30", limit=5)
)
assert calls[0]["params"]["limit"] == 15 # limit * 3 when filtering unnamed
assert len(calls) == 2 # topped up because named < limit
assert "Ride A" in out and "Ride B" in out
assert "Unnamed" not in out
def test_get_activities_include_unnamed_no_topup(monkeypatch):
calls = _patch_request(monkeypatch, lambda _n, _k: [{"name": "Unnamed", "id": 1, "distance": 5}])
out = asyncio.run(activities.get_activities(athlete_id="i1", include_unnamed=True, limit=10))
assert calls[0]["params"]["limit"] == 10 # no *3
assert len(calls) == 1 # no fetch-more
assert "Activities:" in out
# --------------------------------------------------------------------------- #
# get_activity_details
# --------------------------------------------------------------------------- #
def test_activity_details_renders_zones(monkeypatch):
_patch_request(
monkeypatch,
lambda _n, _k: {
"name": "Ride",
"id": 1,
"distance": 1000,
"zones": {
"power": [{"number": 1, "secondsInZone": 100}],
"hr": [{"number": 2, "secondsInZone": 200}],
},
},
)
out = asyncio.run(activities.get_activity_details("1"))
assert "Power Zones:" in out and "Zone 1: 100 seconds" in out
assert "Heart Rate Zones:" in out and "Zone 2: 200 seconds" in out
def test_activity_details_error(monkeypatch):
_patch_request(monkeypatch, lambda _n, _k: {"error": True, "message": "nope"})
assert "Error fetching activity details: nope" in asyncio.run(activities.get_activity_details("1"))
# --------------------------------------------------------------------------- #
# get_activity_intervals
# --------------------------------------------------------------------------- #
def test_activity_intervals_unrecognized_format(monkeypatch):
_patch_request(monkeypatch, lambda _n, _k: {"something": "else"})
out = asyncio.run(activities.get_activity_intervals("1"))
assert "unrecognized format" in out
# --------------------------------------------------------------------------- #
# get_activity_streams
# --------------------------------------------------------------------------- #
def test_streams_default_types_and_small_preview(monkeypatch):
calls = _patch_request(
monkeypatch,
lambda _n, _k: [{"type": "watts", "name": "Power", "data": [1, 2, 3], "valueType": "int"}],
)
out = asyncio.run(activities.get_activity_streams("1"))
assert "time,watts,heartrate" in calls[0]["params"]["types"] # default stream types
assert "Data Points: 3" in out
assert "Values: [1, 2, 3]" in out
def test_streams_large_preview_first_last_five(monkeypatch):
_patch_request(
monkeypatch,
lambda _n, _k: [{"type": "watts", "data": list(range(20)), "valueType": "int"}],
)
out = asyncio.run(activities.get_activity_streams("1", stream_types="watts"))
assert "First 5 values: [0, 1, 2, 3, 4]" in out
assert "Last 5 values: [15, 16, 17, 18, 19]" in out
def test_streams_empty(monkeypatch):
_patch_request(monkeypatch, lambda _n, _k: [])
assert "No stream data found" in asyncio.run(activities.get_activity_streams("1"))
# --------------------------------------------------------------------------- #
# messages
# --------------------------------------------------------------------------- #
def test_get_activity_messages_empty(monkeypatch):
_patch_request(monkeypatch, lambda _n, _k: [])
assert "No messages found" in asyncio.run(activities.get_activity_messages("1"))
def test_add_activity_message_posts_content(monkeypatch):
calls = _patch_request(monkeypatch, lambda _n, _k: {"id": 55})
out = asyncio.run(activities.add_activity_message("1", "great ride"))
call = calls[0]
assert call["method"] == "POST"
assert call["data"] == {"content": "great ride"}
assert "Successfully added message (ID: 55)" in out
+204
View File
@@ -0,0 +1,204 @@
"""
Tests for intervals_mcp_server.api.client.make_intervals_request and helpers.
This is the single function every API call flows through, so these cover the
parts that actually matter: request construction (URL/method/auth/body),
the HTTP status-code -> friendly-message mapping, and the failure branches
(missing key, request errors, invalid JSON).
"""
import asyncio
from http import HTTPStatus
import httpx
import pytest
from intervals_mcp_server.api import client as api_client
from intervals_mcp_server.config import Config
@pytest.fixture(autouse=True)
def _stub_config(monkeypatch):
"""Give the client a deterministic config with a real API key."""
monkeypatch.setattr(
api_client,
"get_config",
lambda: Config(
api_key="secret",
athlete_id="i1",
intervals_api_base_url="https://intervals.icu/api/v1",
user_agent="test-agent",
),
)
class _Resp:
def __init__(self, *, json_data=None, content=b"{}", raise_exc=None, text=""):
self._json = json_data if json_data is not None else {}
self.content = content
self._raise = raise_exc
self.text = text
def json(self):
return self._json
def raise_for_status(self):
if self._raise is not None:
raise self._raise
class _Client:
"""Records request kwargs and returns a canned response (or raises)."""
def __init__(self, response=None, exc=None):
self.is_closed = False
self._response = response
self._exc = exc
self.calls: list[dict] = []
async def request(self, **kwargs):
self.calls.append(kwargs)
if self._exc is not None:
raise self._exc
return self._response
async def aclose(self):
self.is_closed = True
def _inject(monkeypatch, client):
"""Route make_intervals_request through our fake client."""
import intervals_mcp_server.server as server # noqa: PLC0415
monkeypatch.setattr(server, "httpx_client", client, raising=False)
def _run(url, **kwargs):
return asyncio.run(api_client.make_intervals_request(url, **kwargs))
# --------------------------------------------------------------------------- #
# Request construction
# --------------------------------------------------------------------------- #
def test_get_request_builds_url_auth_and_returns_json(monkeypatch):
client = _Client(response=_Resp(json_data={"ok": 1}, content=b'{"ok":1}'))
_inject(monkeypatch, client)
result = _run("/athlete/i1/activities", params={"limit": 5})
assert result == {"ok": 1}
call = client.calls[0]
assert call["method"] == "GET"
assert call["url"] == "https://intervals.icu/api/v1/athlete/i1/activities"
assert call["params"] == {"limit": 5}
assert isinstance(call["auth"], httpx.BasicAuth) # HTTP Basic with the API key
def test_list_response_passthrough(monkeypatch):
client = _Client(response=_Resp(json_data=[{"a": 1}], content=b"[]"))
_inject(monkeypatch, client)
assert _run("/x") == [{"a": 1}]
def test_post_sends_json_body_and_content_type(monkeypatch):
client = _Client(response=_Resp(json_data={"created": True}, content=b"{}"))
_inject(monkeypatch, client)
_run("/athlete/i1/events", method="POST", data={"name": "Threshold"})
call = client.calls[0]
assert call["method"] == "POST"
# POST body is serialized JSON, not form params
assert call["content"] == '{"name": "Threshold"}'
assert call["headers"]["Content-Type"] == "application/json"
def test_empty_response_body_returns_empty_dict(monkeypatch):
client = _Client(response=_Resp(content=b"")) # no content -> {}
_inject(monkeypatch, client)
assert _run("/x") == {}
# --------------------------------------------------------------------------- #
# Failure branches
# --------------------------------------------------------------------------- #
def test_missing_api_key_short_circuits(monkeypatch):
monkeypatch.setattr(
api_client,
"get_config",
lambda: Config(api_key="", athlete_id="i1", intervals_api_base_url="https://x", user_agent="t"),
)
client = _Client(response=_Resp())
_inject(monkeypatch, client)
result = _run("/x")
assert result["error"] is True
assert "API key is required" in result["message"]
assert client.calls == [] # no HTTP call attempted
def test_request_error_is_wrapped(monkeypatch):
client = _Client(exc=httpx.RequestError("connection refused"))
_inject(monkeypatch, client)
result = _run("/x")
assert result["error"] is True
assert "Request error" in result["message"]
def test_http_status_error_is_mapped(monkeypatch):
req = httpx.Request("GET", "https://intervals.icu/api/v1/x")
resp = httpx.Response(status_code=404, request=req, text="nope")
err = httpx.HTTPStatusError("404", request=req, response=resp)
client = _Client(response=_Resp(raise_exc=err))
_inject(monkeypatch, client)
result = _run("/x")
assert result["error"] is True
assert result["status_code"] == 404
assert "doesn't exist" in result["message"] # friendly 404 message
def test_invalid_json_returns_error(monkeypatch):
class _BadJson(_Resp):
def json(self):
raise ValueError("bad json")
client = _Client(response=_BadJson(content=b"garbage"))
_inject(monkeypatch, client)
# JSONDecodeError is a subclass of ValueError; _parse_response catches it
from json import JSONDecodeError
class _BadJson2(_Resp):
def json(self):
raise JSONDecodeError("x", "garbage", 0)
client2 = _Client(response=_BadJson2(content=b"garbage"))
_inject(monkeypatch, client2)
result = _run("/x")
assert result["error"] is True
assert "Invalid JSON" in result["message"]
# --------------------------------------------------------------------------- #
# Status-code -> message mapping (pure logic)
# --------------------------------------------------------------------------- #
@pytest.mark.parametrize(
"code,needle",
[
(HTTPStatus.UNAUTHORIZED, "check your API key"),
(HTTPStatus.FORBIDDEN, "permission"),
(HTTPStatus.NOT_FOUND, "doesn't exist"),
(HTTPStatus.UNPROCESSABLE_ENTITY, "couldn't process"),
(HTTPStatus.TOO_MANY_REQUESTS, "Too many requests"),
(HTTPStatus.INTERNAL_SERVER_ERROR, "internal error"),
(HTTPStatus.SERVICE_UNAVAILABLE, "maintenance"),
],
)
def test_get_error_message_known_codes(code, needle):
assert needle in api_client._get_error_message(int(code), "raw") # noqa: SLF001
def test_get_error_message_unknown_code_falls_back_to_text():
# a valid-but-unmapped status, and an out-of-range code, both return raw text
assert api_client._get_error_message(418, "teapot") == "teapot" # noqa: SLF001
assert api_client._get_error_message(999, "weird") == "weird" # noqa: SLF001
+155
View File
@@ -0,0 +1,155 @@
"""
Tests for intervals_mcp_server.auth — native OAuth token verification.
Covers the real security logic: a valid RS256 JWT yields an AccessToken with the
right claims; tampered / expired / wrong-audience / wrong-key tokens yield None;
the audience accepts both trailing-slash forms; and build_auth only enables auth
when the environment is configured.
"""
import asyncio
import time
import types
import jwt
import pytest
from cryptography.hazmat.primitives.asymmetric import rsa
from intervals_mcp_server import auth as auth_mod
from intervals_mcp_server.auth import AuthentikTokenVerifier, _audience_variants, build_auth
ISSUER = "https://auth.example/application/o/x/"
RESOURCE = "https://res.example"
def _keypair():
priv = rsa.generate_private_key(public_exponent=65537, key_size=2048)
return priv, priv.public_key()
def _verifier(pub, audience):
v = AuthentikTokenVerifier("https://jwks.invalid", ISSUER, audience)
# avoid network: hand the verifier a fake JWKS client returning our public key
v._jwks = types.SimpleNamespace( # noqa: SLF001
get_signing_key_from_jwt=lambda _t: types.SimpleNamespace(key=pub)
)
return v
def _token(priv, **overrides):
now = int(time.time())
claims = {
"iss": ISSUER,
"aud": RESOURCE,
"exp": now + 3600,
"iat": now,
"sub": "user-123",
"scope": "read write",
"azp": "client-abc",
}
claims.update(overrides)
return jwt.encode(claims, priv, algorithm="RS256")
# --------------------------------------------------------------------------- #
# verify_token
# --------------------------------------------------------------------------- #
def test_valid_token_returns_access_token_with_claims():
priv, pub = _keypair()
v = _verifier(pub, [RESOURCE, "client-abc"])
tok = _token(priv)
result = asyncio.run(v.verify_token(tok))
assert result is not None
assert result.subject == "user-123"
assert result.client_id == "client-abc"
assert result.scopes == ["read", "write"]
assert result.resource == RESOURCE
assert result.token == tok
def test_expired_token_rejected():
priv, pub = _keypair()
v = _verifier(pub, [RESOURCE])
tok = _token(priv, exp=int(time.time()) - 10)
assert asyncio.run(v.verify_token(tok)) is None
def test_wrong_audience_rejected():
priv, pub = _keypair()
v = _verifier(pub, ["https://someone-else"]) # verifier expects a different aud
tok = _token(priv, aud=RESOURCE)
assert asyncio.run(v.verify_token(tok)) is None
def test_wrong_issuer_rejected():
priv, pub = _keypair()
v = _verifier(pub, [RESOURCE])
tok = _token(priv, iss="https://evil")
assert asyncio.run(v.verify_token(tok)) is None
def test_signature_from_other_key_rejected():
priv, _ = _keypair()
_, other_pub = _keypair() # verifier gets a key that did NOT sign the token
v = _verifier(other_pub, [RESOURCE])
tok = _token(priv)
assert asyncio.run(v.verify_token(tok)) is None
def test_missing_required_claim_rejected():
priv, pub = _keypair()
v = _verifier(pub, [RESOURCE])
# drop exp -> options require ["exp",...] -> rejected
now = int(time.time())
tok = jwt.encode({"iss": ISSUER, "aud": RESOURCE, "iat": now}, priv, algorithm="RS256")
assert asyncio.run(v.verify_token(tok)) is None
def test_client_id_falls_back_to_resource_when_no_azp():
priv, pub = _keypair()
v = _verifier(pub, [RESOURCE])
tok = _token(priv, azp=None)
del_claims = jwt.decode(tok, options={"verify_signature": False})
assert "azp" in del_claims # azp present but None
result = asyncio.run(v.verify_token(tok))
assert result is not None
assert result.client_id == RESOURCE # falls back to the aud/resource
# --------------------------------------------------------------------------- #
# _audience_variants
# --------------------------------------------------------------------------- #
def test_audience_variants_include_both_slash_forms_and_client_id():
variants = _audience_variants("https://res.example", "cid")
assert "https://res.example" in variants
assert "https://res.example/" in variants
assert "cid" in variants
def test_audience_variants_dedupe_and_no_client_id():
variants = _audience_variants("https://res.example/", None)
assert variants == ["https://res.example", "https://res.example/"]
# --------------------------------------------------------------------------- #
# build_auth
# --------------------------------------------------------------------------- #
def test_build_auth_disabled_without_env(monkeypatch):
for var in ("MCP_ISSUER", "MCP_RESOURCE", "MCP_JWKS_URI", "MCP_CLIENT_ID"):
monkeypatch.delenv(var, raising=False)
assert build_auth() == (None, None)
def test_build_auth_enabled_with_env(monkeypatch):
monkeypatch.setenv("MCP_ISSUER", ISSUER)
monkeypatch.setenv("MCP_RESOURCE", RESOURCE)
monkeypatch.setenv("MCP_JWKS_URI", "https://auth.example/jwks/")
monkeypatch.setenv("MCP_CLIENT_ID", "client-abc")
settings, verifier = build_auth()
assert settings is not None
assert isinstance(verifier, AuthentikTokenVerifier)
assert str(settings.issuer_url) == ISSUER
# audience carries both slash forms + client id
assert "client-abc" in verifier._audience # noqa: SLF001
+135
View File
@@ -0,0 +1,135 @@
"""
Tests for intervals_mcp_server.tools.custom_items.
Focuses on the write-path logic: which fields end up in the POST/PUT payload,
JSON-string content parsing (and the invalid-JSON guard), method/URL selection,
and the error/empty branches.
"""
import asyncio
import pytest
from intervals_mcp_server.tools import custom_items
class _Recorder:
def __init__(self, handler):
self.calls: list[dict] = []
self._handler = handler
async def __call__(self, **kwargs):
self.calls.append(kwargs)
return self._handler(kwargs)
def _patch(monkeypatch, handler):
rec = _Recorder(handler)
monkeypatch.setattr(custom_items, "make_intervals_request", rec)
return rec
def _run(coro):
return asyncio.run(coro)
# --------------------------------------------------------------------------- #
# read
# --------------------------------------------------------------------------- #
def test_get_custom_items_lists(monkeypatch):
_patch(monkeypatch, lambda _k: [{"id": 1, "name": "Zones", "type": "ZONES", "description": "d"}])
out = _run(custom_items.get_custom_items(athlete_id="i1"))
assert "Custom Items:" in out
assert "ID: 1" in out and "Name: Zones" in out and "Type: ZONES" in out
def test_get_custom_items_error(monkeypatch):
_patch(monkeypatch, lambda _k: {"error": True, "message": "boom"})
assert "Error fetching custom items: boom" in _run(custom_items.get_custom_items(athlete_id="i1"))
def test_get_custom_item_by_id_not_found(monkeypatch):
_patch(monkeypatch, lambda _k: []) # falsy / not a dict
assert "No custom item found with ID 7" in _run(custom_items.get_custom_item_by_id(7, athlete_id="i1"))
# --------------------------------------------------------------------------- #
# create
# --------------------------------------------------------------------------- #
def test_create_builds_full_payload(monkeypatch):
rec = _patch(monkeypatch, lambda _k: {"id": 9, "name": "Chart", "type": "FITNESS_CHART"})
out = _run(
custom_items.create_custom_item(
name="Chart", item_type="FITNESS_CHART", athlete_id="i1",
description="desc", content={"a": 1}, visibility="PRIVATE",
)
)
call = rec.calls[0]
assert call["method"] == "POST"
assert call["url"] == "/athlete/i1/custom-item"
assert call["data"] == {
"name": "Chart", "type": "FITNESS_CHART", "description": "desc",
"content": {"a": 1}, "visibility": "PRIVATE",
}
assert "Successfully created custom item" in out
def test_create_parses_json_string_content(monkeypatch):
rec = _patch(monkeypatch, lambda _k: {"id": 1, "name": "X", "type": "ZONES"})
_run(
custom_items.create_custom_item(
name="X", item_type="ZONES", athlete_id="i1", content='{"expression": "icu_training_load"}'
)
)
assert rec.calls[0]["data"]["content"] == {"expression": "icu_training_load"} # parsed to dict
def test_create_rejects_invalid_json_string(monkeypatch):
rec = _patch(monkeypatch, lambda _k: {"id": 1})
out = _run(
custom_items.create_custom_item(name="X", item_type="ZONES", athlete_id="i1", content="{not json")
)
assert "content must be valid JSON" in out
assert rec.calls == [] # bailed before any request
def test_create_error_surfaced(monkeypatch):
_patch(monkeypatch, lambda _k: {"error": True, "message": "bad type"})
out = _run(custom_items.create_custom_item(name="X", item_type="NOPE", athlete_id="i1"))
assert "Error creating custom item: bad type" in out
# --------------------------------------------------------------------------- #
# update
# --------------------------------------------------------------------------- #
def test_update_sends_only_provided_fields_via_put(monkeypatch):
rec = _patch(monkeypatch, lambda _k: {"id": 5, "name": "New", "type": "ZONES"})
_run(custom_items.update_custom_item(item_id=5, athlete_id="i1", name="New"))
call = rec.calls[0]
assert call["method"] == "PUT"
assert call["url"] == "/athlete/i1/custom-item/5"
assert call["data"] == {"name": "New"} # only the field that was set
def test_update_invalid_json_string(monkeypatch):
rec = _patch(monkeypatch, lambda _k: {"id": 5})
out = _run(custom_items.update_custom_item(item_id=5, athlete_id="i1", content="{bad"))
assert "content must be valid JSON" in out
assert rec.calls == []
# --------------------------------------------------------------------------- #
# delete
# --------------------------------------------------------------------------- #
def test_delete_uses_delete_method(monkeypatch):
rec = _patch(monkeypatch, lambda _k: {})
out = _run(custom_items.delete_custom_item(item_id=3, athlete_id="i1"))
call = rec.calls[0]
assert call["method"] == "DELETE"
assert call["url"] == "/athlete/i1/custom-item/3"
assert "Successfully deleted custom item 3" in out
def test_delete_error(monkeypatch):
_patch(monkeypatch, lambda _k: {"error": True, "message": "locked"})
assert "Error deleting custom item: locked" in _run(custom_items.delete_custom_item(item_id=3, athlete_id="i1"))
+187
View File
@@ -0,0 +1,187 @@
"""
Tests for intervals_mcp_server.tools.events.
Verifies the request payloads sent to the API (create vs update, note vs workout),
the delete-by-range accounting, and the response/error handling branches — not just
that a happy-path string comes back.
"""
import asyncio
import pytest
from intervals_mcp_server.tools import events
from intervals_mcp_server.tools.events import _handle_event_response, _prepare_event_data
from intervals_mcp_server.utils.types import Step, WorkoutDoc
class _Recorder:
"""Fake make_intervals_request that records calls and returns canned data."""
def __init__(self, handler):
self.calls: list[dict] = []
self._handler = handler
async def __call__(self, **kwargs):
self.calls.append(kwargs)
return self._handler(kwargs)
def _patch(monkeypatch, handler):
rec = _Recorder(handler)
monkeypatch.setattr(events, "make_intervals_request", rec)
return rec
def _run(coro):
return asyncio.run(coro)
# --------------------------------------------------------------------------- #
# _prepare_event_data / _handle_event_response (pure helpers)
# --------------------------------------------------------------------------- #
def test_prepare_event_data_shapes_payload():
doc = WorkoutDoc(description="VO2", steps=[Step(duration=600)])
data = _prepare_event_data("Morning Ride", "", "2026-07-10", doc, 3600, 40000)
assert data["start_date_local"] == "2026-07-10T00:00:00"
assert data["category"] == "WORKOUT"
assert data["type"] == "Ride" # resolved from the name "Morning Ride"
assert data["moving_time"] == 3600
assert data["distance"] == 40000
assert "VO2" in data["description"]
def test_prepare_event_data_null_description_without_doc():
data = _prepare_event_data("Run", "Run", "2026-07-10", None, None, None)
assert data["description"] is None
@pytest.mark.parametrize(
"result,action,needle",
[
({"error": "x", "message": "boom"}, "creating", "Error creating event: boom"),
(None, "created", "No events created"),
({"id": "e42"}, "created", "Successfully created event id: e42"),
([{"id": 1}], "updated", "Event updated successfully"),
],
)
def test_handle_event_response_branches(result, action, needle):
assert needle in _handle_event_response(result, action, "i1", "2026-07-10")
# --------------------------------------------------------------------------- #
# get_events / get_event_by_id
# --------------------------------------------------------------------------- #
def test_get_events_sends_date_params_and_formats(monkeypatch):
rec = _patch(monkeypatch, lambda _k: [{"start_date_local": "2026-07-10", "id": "e1", "name": "Race", "race": True}])
out = _run(events.get_events(athlete_id="i1", start_date="2026-07-01", end_date="2026-07-31"))
call = rec.calls[0]
assert call["url"] == "/athlete/i1/events"
assert call["params"] == {"oldest": "2026-07-01", "newest": "2026-07-31"}
assert "Events:" in out and "Race" in out
def test_get_events_error_surfaced(monkeypatch):
_patch(monkeypatch, lambda _k: {"error": True, "message": "rate limited"})
out = _run(events.get_events(athlete_id="i1"))
assert "Error fetching events: rate limited" in out
def test_get_events_empty(monkeypatch):
_patch(monkeypatch, lambda _k: [])
out = _run(events.get_events(athlete_id="i1"))
assert "No events found" in out
def test_get_event_by_id_invalid_format(monkeypatch):
_patch(monkeypatch, lambda _k: [1, 2, 3]) # list, not a dict
out = _run(events.get_event_by_id("e1", athlete_id="i1"))
assert "Invalid event format" in out
def test_get_event_by_id_error(monkeypatch):
_patch(monkeypatch, lambda _k: {"error": True, "message": "nope"})
out = _run(events.get_event_by_id("e1", athlete_id="i1"))
assert "Error fetching event details: nope" in out
# --------------------------------------------------------------------------- #
# create / update
# --------------------------------------------------------------------------- #
def test_add_event_posts_when_no_event_id(monkeypatch):
rec = _patch(monkeypatch, lambda _k: {"id": "e99"})
out = _run(
events.add_or_update_event(
workout_type="Ride", name="Threshold", athlete_id="i1",
start_date="2026-07-10", moving_time=3600, distance=40000,
workout_doc=WorkoutDoc(description="d", steps=[Step(duration=600)]),
)
)
call = rec.calls[0]
assert call["method"] == "POST"
assert call["url"] == "/athlete/i1/events"
assert call["data"]["name"] == "Threshold"
assert "Successfully created event id: e99" in out
def test_update_event_puts_when_event_id(monkeypatch):
rec = _patch(monkeypatch, lambda _k: {"id": "e5"})
_run(
events.add_or_update_event(
workout_type="Ride", name="Threshold", athlete_id="i1",
event_id="e5", start_date="2026-07-10",
)
)
call = rec.calls[0]
assert call["method"] == "PUT"
assert call["url"] == "/athlete/i1/events/e5"
def test_add_event_invalid_date_returns_error(monkeypatch):
_patch(monkeypatch, lambda _k: {"id": "e1"})
out = _run(events.add_or_update_event(workout_type="Ride", name="X", athlete_id="i1", start_date="07/10/2026"))
assert out.startswith("Error:")
def test_add_note_uses_note_category_and_color(monkeypatch):
rec = _patch(monkeypatch, lambda _k: {"id": "n1"})
_run(events.add_or_update_note(name="Sick day", description="rest", athlete_id="i1", start_date="2026-07-10", color="red"))
data = rec.calls[0]["data"]
assert data["category"] == "NOTE"
assert data["color"] == "red"
assert data["description"] == "rest"
# --------------------------------------------------------------------------- #
# delete
# --------------------------------------------------------------------------- #
def test_delete_event_requires_id(monkeypatch):
_patch(monkeypatch, lambda _k: {})
out = _run(events.delete_event("", athlete_id="i1"))
assert "No event ID provided" in out
def test_delete_event_uses_delete_method(monkeypatch):
rec = _patch(monkeypatch, lambda _k: {"deleted": True})
_run(events.delete_event("e7", athlete_id="i1"))
call = rec.calls[0]
assert call["method"] == "DELETE"
assert call["url"] == "/athlete/i1/events/e7"
def test_delete_by_range_counts_successes_and_failures(monkeypatch):
def handler(kwargs):
if kwargs.get("method") == "DELETE":
return {"error": True, "message": "no"} if kwargs["url"].endswith("/2") else {}
return [{"id": 1}, {"id": 2}] # the GET listing
_patch(monkeypatch, handler)
out = _run(events.delete_events_by_date_range("2026-07-01", "2026-07-31", athlete_id="i1"))
assert "Deleted 1 events" in out
assert "Failed to delete 1 events: [2]" in out
def test_delete_by_range_fetch_error(monkeypatch):
_patch(monkeypatch, lambda _k: {"error": True, "message": "boom"})
out = _run(events.delete_events_by_date_range("2026-07-01", "2026-07-31", athlete_id="i1"))
assert "Error deleting events: boom" in out
+74
View File
@@ -0,0 +1,74 @@
"""
Tests for intervals_mcp_server.server_setup — transport selection and startup.
Verifies MCP_TRANSPORT parsing (including the http -> streamable-http mapping and
the invalid-value error) and that start_server dispatches to mcp.run with the
correct transport arguments for each mode.
"""
from unittest.mock import MagicMock
import pytest
from intervals_mcp_server import server_setup
from intervals_mcp_server.utils.types import TransportAliases
# --------------------------------------------------------------------------- #
# setup_transport
# --------------------------------------------------------------------------- #
def test_default_is_stdio(monkeypatch):
monkeypatch.delenv("MCP_TRANSPORT", raising=False)
assert server_setup.setup_transport() == TransportAliases.STDIO
@pytest.mark.parametrize(
"value,expected",
[
("sse", TransportAliases.SSE),
("http", TransportAliases.STREAMABLE_HTTP), # http is an alias for streamable-http
("streamable-http", TransportAliases.STREAMABLE_HTTP),
("STDIO", TransportAliases.STDIO), # case-insensitive
],
)
def test_transport_mapping(monkeypatch, value, expected):
monkeypatch.setenv("MCP_TRANSPORT", value)
assert server_setup.setup_transport() == expected
def test_invalid_transport_raises(monkeypatch):
monkeypatch.setenv("MCP_TRANSPORT", "carrier-pigeon")
with pytest.raises(ValueError, match="Unsupported MCP_TRANSPORT"):
server_setup.setup_transport()
# --------------------------------------------------------------------------- #
# start_server
# --------------------------------------------------------------------------- #
def _mock_mcp():
m = MagicMock()
m.settings.host = "0.0.0.0"
m.settings.port = 8080
m.settings.sse_path = "/sse"
m.settings.message_path = "/messages"
m.settings.streamable_http_path = "/mcp"
return m
def test_start_server_stdio():
m = _mock_mcp()
server_setup.start_server(m, TransportAliases.STDIO)
m.run.assert_called_once_with()
def test_start_server_streamable_http():
m = _mock_mcp()
server_setup.start_server(m, TransportAliases.STREAMABLE_HTTP)
m.run.assert_called_once_with(transport="streamable-http")
def test_start_server_sse_uses_mount_path(monkeypatch):
monkeypatch.setenv("MCP_SSE_MOUNT_PATH", "/custom")
m = _mock_mcp()
server_setup.start_server(m, TransportAliases.SSE)
m.run.assert_called_once_with(transport="sse", mount_path="/custom")
+300
View File
@@ -0,0 +1,300 @@
"""
Tests for the workout data model in intervals_mcp_server.utils.types.
These exercise the serialization logic that builds ``add_or_update_event`` payloads:
round-trips (to_dict/from_dict/to_json/from_json), enum conversion, the camelCase
API key mapping, recursive nested steps, and the human-readable ``__str__`` output.
A bug in any branch (wrong key, missed enum conversion, broken recursion) fails here.
"""
import json
import pytest
from intervals_mcp_server.utils.types import (
HrTarget,
Intensity,
PaceUnits,
SportSettings,
Step,
Value,
ValueUnits,
WorkoutDoc,
WorkoutTarget,
float_to_str,
)
# --------------------------------------------------------------------------- #
# float_to_str
# --------------------------------------------------------------------------- #
def test_float_to_str_drops_trailing_zero():
assert float_to_str(95.0) == "95"
assert float_to_str(1.5) == "1.5"
assert float_to_str(0.0) == "0"
# --------------------------------------------------------------------------- #
# Value
# --------------------------------------------------------------------------- #
def test_value_to_dict_only_includes_set_fields():
assert Value(value=200, units=ValueUnits.WATTS).to_dict() == {"value": 200, "units": "w"}
# unset fields must be omitted, not serialized as null
assert "start" not in Value(value=1).to_dict()
def test_value_roundtrip_dict_and_json():
original = Value(start=65, end=85, units=ValueUnits.PERCENT_FTP, target=HrTarget.THREE_SECOND)
assert Value.from_dict(original.to_dict()) == original
assert Value.from_json(original.to_json()) == original
def test_value_from_dict_converts_enums():
val = Value.from_dict({"value": 3, "units": "power_zone", "target": "lap"})
assert val.units is ValueUnits.POWER_ZONE
assert val.target is HrTarget.LAP
@pytest.mark.parametrize(
"value,units,expected",
[
(95.0, ValueUnits.PERCENT_FTP, "95% ftp"),
(200.0, ValueUnits.WATTS, "200W"),
(3.0, ValueUnits.POWER_ZONE, "Z3 W"),
(90.0, ValueUnits.CADENCE, "90rpm Cadence"),
(150.0, ValueUnits.PERCENT_HR, "150% HR"),
],
)
def test_value_str_formats_by_unit(value, units, expected):
assert str(Value(value=value, units=units)) == expected
def test_value_str_ramp_and_target():
assert str(Value(start=65, end=85, units=ValueUnits.PERCENT_FTP)) == "65%-85% ftp"
assert str(Value(value=150, target=HrTarget.LAP)) == "150 hr=lap"
# --------------------------------------------------------------------------- #
# Step
# --------------------------------------------------------------------------- #
def test_step_roundtrip_with_nested_steps_and_values():
"""Recursive round-trip: a repeat block containing a step with power/hr targets."""
original = Step(
reps=3,
intensity=Intensity.INTERVAL,
steps=[
Step(
duration=300,
power=Value(value=250, units=ValueUnits.WATTS),
hr=Value(start=150, end=165, units=ValueUnits.PERCENT_LTHR),
cadence=Value(value=90, units=ValueUnits.CADENCE),
),
Step(duration=60, freeride=True),
],
)
as_dict = original.to_dict()
# nested steps must serialize recursively, and enums become their .value
assert as_dict["intensity"] == "interval"
assert as_dict["steps"][0]["power"] == {"value": 250, "units": "w"}
assert isinstance(as_dict["steps"], list) and len(as_dict["steps"]) == 2
# full round-trip preserves the structure
assert Step.from_dict(as_dict) == original
assert Step.from_json(original.to_json()) == original
def test_step_to_dict_omits_unset_and_serializes_resolved_fields():
step = Step(duration=120, _power=Value(value=248, units=ValueUnits.WATTS), _distance=1000.0)
data = step.to_dict()
assert data == {"duration": 120, "_power": {"value": 248, "units": "w"}, "_distance": 1000.0}
assert "hr" not in data
@pytest.mark.parametrize(
"duration,expected",
[
(45, "45s"),
(120, "2m"),
(125, "2m5s"),
(3720, "1h2m"),
],
)
def test_step_format_duration(duration, expected):
assert Step(duration=duration)._format_duration() == expected # noqa: SLF001
@pytest.mark.parametrize("distance,expected", [(500, "500mtr"), (1500, "1.5km"), (2000, "2km")])
def test_step_format_distance(distance, expected):
assert Step(distance=distance)._format_distance() == expected # noqa: SLF001
def test_step_str_warmup_and_targets():
out = str(Step(warmup=True, duration=600, power=Value(value=150, units=ValueUnits.WATTS)))
assert "Warmup" in out
assert "- 10m" in out
assert "150W" in out
def test_step_str_reps_block_renders_children():
block = Step(reps=3, steps=[Step(duration=60, power=Value(value=100, units=ValueUnits.WATTS))])
out = str(block)
assert "3x" in out
assert "100W" in out
def test_step_str_nested_reps_raises():
"""A repeat inside a repeat is unsupported and must raise, not silently mis-render."""
with pytest.raises(ValueError, match="Nested steps not supported"):
Step(reps=2)._to_str(nested=True) # noqa: SLF001
# --------------------------------------------------------------------------- #
# SportSettings
# --------------------------------------------------------------------------- #
def test_sport_settings_roundtrip():
assert SportSettings().to_dict() == {}
assert SportSettings.from_dict({"anything": 1}) == SportSettings()
assert SportSettings.from_json(SportSettings().to_json()) == SportSettings()
# --------------------------------------------------------------------------- #
# WorkoutDoc
# --------------------------------------------------------------------------- #
def test_workout_doc_uses_camelcase_api_keys():
doc = WorkoutDoc(
description="Threshold 3x10",
sport_settings=SportSettings(),
zone_times=[600, 300, 120],
target=WorkoutTarget.POWER,
pace_units=PaceUnits.MINS_KM,
steps=[Step(duration=600, power=Value(value=240, units=ValueUnits.WATTS))],
)
data = doc.to_dict()
# API expects camelCase for these two specifically
assert "sportSettings" in data and "sport_settings" not in data
assert "zoneTimes" in data and "zone_times" not in data
assert data["target"] == "POWER"
assert data["pace_units"] == "MINS_KM"
assert data["steps"][0]["power"] == {"value": 240, "units": "w"}
def test_workout_doc_roundtrip_dict_and_json():
doc = WorkoutDoc(
description="desc",
duration=1800,
ftp=266,
target=WorkoutTarget.AUTO,
sport_settings=SportSettings(),
steps=[Step(reps=2, steps=[Step(duration=300, hr=Value(value=160, units=ValueUnits.HR_ZONE))])],
zone_times=[{"id": 1, "secs": 100}], # zone_times can be objects, not just ints
options={"pool_length": "25m"},
locales=["en"],
)
assert WorkoutDoc.from_dict(doc.to_dict()) == doc
assert WorkoutDoc.from_json(doc.to_json()) == doc
def test_workout_doc_from_dict_maps_camelcase_back():
doc = WorkoutDoc.from_dict(
{"description": "d", "sportSettings": {}, "zoneTimes": [1, 2], "target": "HR"}
)
assert doc.sport_settings == SportSettings()
assert doc.zone_times == [1, 2]
assert doc.target is WorkoutTarget.HR
def test_workout_doc_str_includes_description_and_steps():
doc = WorkoutDoc(description="Endurance", steps=[Step(duration=3600, freeride=True)])
out = str(doc)
assert out.startswith("Endurance")
assert "freeride" in out
def test_workout_doc_json_is_valid_and_minimal():
"""to_json must emit real JSON with only the set fields."""
payload = json.loads(WorkoutDoc(description="x", ftp=250).to_json())
assert payload == {"description": "x", "ftp": 250}
# --------------------------------------------------------------------------- #
# Exhaustive round-trips (exercise every optional-field branch in to/from_dict)
# --------------------------------------------------------------------------- #
def test_value_str_no_units():
assert str(Value(value=42)) == "42"
def test_step_all_fields_roundtrip():
step = Step(
text="Main set",
text_locale={"en": "Main set"},
duration=600,
distance=1000.0,
until_lap_press=True,
reps=4,
warmup=True,
cooldown=True,
intensity=Intensity.INTERVAL,
steps=[Step(duration=60, freeride=True)],
ramp=True,
freeride=False,
maxeffort=True,
power=Value(value=250, units=ValueUnits.WATTS),
hr=Value(value=160, units=ValueUnits.PERCENT_HR),
pace=Value(value=300, units=ValueUnits.MINS_KM),
cadence=Value(value=90, units=ValueUnits.CADENCE),
hidepower=True,
_power=Value(value=248, units=ValueUnits.WATTS),
_hr=Value(value=158, units=ValueUnits.PERCENT_HR),
_pace=Value(value=298, units=ValueUnits.MINS_KM),
_distance=995.0,
)
assert Step.from_dict(step.to_dict()) == step
def test_step_format_none_paths():
assert Step()._format_duration() == "" # noqa: SLF001
assert Step()._format_distance() == "" # noqa: SLF001
def test_step_str_distance_flags_and_text():
out = str(
Step(
cooldown=True,
distance=500,
maxeffort=True,
ramp=True,
hidepower=True,
intensity=Intensity.REST,
hr=Value(value=140, units=ValueUnits.PERCENT_HR),
pace=Value(value=300, units=ValueUnits.MINS_KM),
cadence=Value(value=85, units=ValueUnits.CADENCE),
text="hold steady",
)
)
assert "Cooldown" in out
assert "500mtr" in out
assert "maxeffort" in out and "ramp" in out and "hidepower" in out
assert "intensity=rest" in out
assert "140% HR" in out
assert "85rpm Cadence" in out
assert "hold steady" in out
def test_workout_doc_all_fields_roundtrip():
doc = WorkoutDoc(
description="Full",
description_locale={"en": "Full"},
duration=3600,
distance=40000.0,
ftp=266,
lthr=174,
threshold_pace=4.2,
pace_units=PaceUnits.MINS_MILE,
sport_settings=SportSettings(),
category="WORKOUT",
target=WorkoutTarget.PACE,
steps=[Step(duration=600, power=Value(value=240, units=ValueUnits.WATTS))],
zone_times=[600, 300],
options={"pool_length": "25m"},
locales=["en", "es"],
)
assert WorkoutDoc.from_dict(doc.to_dict()) == doc
Generated
+87
View File
@@ -136,6 +136,75 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "coverage"
version = "7.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cc/8b/adeb62ea8951f13c4c7fef2e7a85e1a06b499c8d8237ea589d496029e53f/coverage-7.15.0.tar.gz", hash = "sha256:9ac3fe7a1435986463eaa8ee253ae2f2a268709ba4ae5c7dd1f52a05391ad78f", size = 925362, upload-time = "2026-07-02T13:10:50.535Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/74/fd4c0901137c4f8d81a76ada99e43c65163b4c94a02ece107a4ec0c6b615/coverage-7.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b75ee5e8cb7575636ac598719b4307ac529ec8fcd79608a35c3cd4d4dada812d", size = 220838, upload-time = "2026-07-02T13:09:02.084Z" },
{ url = "https://files.pythonhosted.org/packages/0f/2e/2347583467bd7f0402635101a916961915cc68fce652cd0db5f173ea04fc/coverage-7.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffb31267816b93b075302248cc1737506081b4f163df4401e9df1a6424aafabe", size = 221197, upload-time = "2026-07-02T13:09:03.617Z" },
{ url = "https://files.pythonhosted.org/packages/f0/17/99fa688541ae1d6e84543a0e544f83de0c944815b63e9e7b1ed411d15036/coverage-7.15.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e4d0bb73455bf97ab243a8f12c37c686ccf1c13bb614b7b85f1d062f06f42b2c", size = 252705, upload-time = "2026-07-02T13:09:05.059Z" },
{ url = "https://files.pythonhosted.org/packages/fb/02/6a95a5cd83b74839017ef9cf48d2d8c9ae60af919e17a3f336e6f9f1b7bd/coverage-7.15.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:20d9ccc4ebd0edc434d86dfd2a1dd2a8efa6b6b3073d0485a394fee86459ebb4", size = 255441, upload-time = "2026-07-02T13:09:06.559Z" },
{ url = "https://files.pythonhosted.org/packages/67/f2/406f6c57d600f68185942422c4c00f1a3255d60aee6e5fd961425cd9987e/coverage-7.15.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20c8a976c365c8cb12f0cbd099508772ea41fb5fa80657a8506df0e11bd278c5", size = 256556, upload-time = "2026-07-02T13:09:08.197Z" },
{ url = "https://files.pythonhosted.org/packages/74/8e/d3fa48489c15ecdec1ba48fd61f68798555dddd2f6716f9ad42adeb1a2a9/coverage-7.15.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f948fd5ba1b9cbca91f0ae08b4c1ce2b139509149a435e2585d056d57d70bf01", size = 258815, upload-time = "2026-07-02T13:09:09.691Z" },
{ url = "https://files.pythonhosted.org/packages/47/2e/2d40ddd110462c6a2769677cf7f1c119a52b45f568978fc6c98e4cc0dd0f/coverage-7.15.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f58185f06edf6ad68ec9fb155d63ef650c82f3fbd7e1770e2867751fb13158f4", size = 253117, upload-time = "2026-07-02T13:09:11.212Z" },
{ url = "https://files.pythonhosted.org/packages/51/c0/310782f0d7c3cb2b5ac05ba8d205fe91f24a36f6bf3256098f1782181c38/coverage-7.15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:02adc79a920c73c647c5d117f55747df7f2de94571884758ce8bc58e04f0a796", size = 254475, upload-time = "2026-07-02T13:09:13.029Z" },
{ url = "https://files.pythonhosted.org/packages/86/f7/702da6c275f8ae6ade423d2877243122932c9b27f5403003b9ef8c927d12/coverage-7.15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6eb7c300fbed667fd6e3588eba71c1904cdb06110ca6fdf908c26bdd88b8e382", size = 252619, upload-time = "2026-07-02T13:09:14.699Z" },
{ url = "https://files.pythonhosted.org/packages/fb/84/c5b15a7e5ecba4e56218d772d99fe80a63e63f8d11f12783723a6005ab45/coverage-7.15.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b5fb23fa2de9dce1f5c36c09066d8fcda16cd96e8e26686caa2d7cb9b567d65c", size = 256689, upload-time = "2026-07-02T13:09:16.103Z" },
{ url = "https://files.pythonhosted.org/packages/95/2f/c8b07559b57701230c61b23a953858c052890c12ef568d81780c6c46e92e/coverage-7.15.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cec79341dbe6281484024979976d0c7f22beae08b4a254655decd25d42cbe766", size = 252189, upload-time = "2026-07-02T13:09:17.828Z" },
{ url = "https://files.pythonhosted.org/packages/6b/80/6d2f049dd3fd3dbfd60b62ba6b2162a04009e2c002ce70b24cf3878dec7a/coverage-7.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c664c5444b1d970b1b2a450e21fb19ee5c9cfdf151ded2dda37260031cca0da", size = 254059, upload-time = "2026-07-02T13:09:19.304Z" },
{ url = "https://files.pythonhosted.org/packages/ce/92/b0287a2c42031d25c628f815f89a3cd9f8268ee78bb1252c9356cda1c689/coverage-7.15.0-cp312-cp312-win32.whl", hash = "sha256:5f764a3fa339bde6b3aa97657f5a6a3a9451e4a5b4ea98a2892c773a43525f77", size = 222893, upload-time = "2026-07-02T13:09:20.812Z" },
{ url = "https://files.pythonhosted.org/packages/a9/69/e34c481915fecb499b3146975061dac528752e37706edc1804f32c822469/coverage-7.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:52f9a4d2c4c56c8848bc2f524916698354b0211488b38c49ad9ae54f6cafbff6", size = 223429, upload-time = "2026-07-02T13:09:22.315Z" },
{ url = "https://files.pythonhosted.org/packages/fe/98/6e878f0b571d32684ef3f38d7c03db241ca5b82a5da8a5391596a8f209c4/coverage-7.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:31e5c3e70c85307ea35a12964e2e40f56ca2ee4b1c8c721ccf4609d17071080b", size = 222810, upload-time = "2026-07-02T13:09:23.812Z" },
{ url = "https://files.pythonhosted.org/packages/76/04/145a3748098bcc86b631a85408d2c3dc5c104e0bd86d605468239b25b6c4/coverage-7.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5be4caf3b28836f078abe700f8944dac4a65d78f16d6c600c89cb624e5535782", size = 220863, upload-time = "2026-07-02T13:09:25.371Z" },
{ url = "https://files.pythonhosted.org/packages/a4/5c/4ed55708fed2c64b63c9bc5715daef670872202101938869b7fe5d5fbb8f/coverage-7.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dd58ad1404704303ca8d4f4b8a1095e7cbc7040ef17a66df1e6619aa10176430", size = 221230, upload-time = "2026-07-02T13:09:26.897Z" },
{ url = "https://files.pythonhosted.org/packages/7b/19/3a80b97d3b2a5c77a01ae359c6bed20c13738fe3d9380f08616d4fec0281/coverage-7.15.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bbcbb317c2e5ded5b21104af81c29f391be2af98d065693ffbe8d23949b948e5", size = 252227, upload-time = "2026-07-02T13:09:28.543Z" },
{ url = "https://files.pythonhosted.org/packages/a1/fa/b70062750686bd7da454da27927622f48bbac6990ac7a4c4a4653e7b0036/coverage-7.15.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:27f31ecb458da3f859aab3f15ada871eb7a7768807d88df4a9f186bb17737970", size = 254823, upload-time = "2026-07-02T13:09:30.177Z" },
{ url = "https://files.pythonhosted.org/packages/a9/09/dad6a75a2e561b9dc5086a8c5257a7591d584246f67e23e70d2995b89ab6/coverage-7.15.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fb759be317fdc62e0f56bffdf61cfcb45c7761ad6b71e3e583e71a67ae753c", size = 256059, upload-time = "2026-07-02T13:09:31.979Z" },
{ url = "https://files.pythonhosted.org/packages/e6/e7/b5d2941fa9564573d44b693a871ff3156f0c42cbefe977a09fa7fdc59971/coverage-7.15.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5cf007add5ab4bb8fa9f4c77e3732127c9e6cad501d7db43355fbfafca0be84", size = 258190, upload-time = "2026-07-02T13:09:34.035Z" },
{ url = "https://files.pythonhosted.org/packages/7c/1d/8e895bcde3c57ccd46d896dda5f2b3d5df761a1b0c6c9d450d175dedc632/coverage-7.15.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc78d9843bd576fbe2118248258d485e968dc535f95ed504a7b0867ba9b51389", size = 252456, upload-time = "2026-07-02T13:09:35.765Z" },
{ url = "https://files.pythonhosted.org/packages/14/4c/f6997da343ddeb959be82c3b05322793f92c071ad45f7cb8a96336e2dd5f/coverage-7.15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a263060f1de0b4b74b4e089c2a70b8003b3781c733329a9c8fd54995328f9950", size = 254192, upload-time = "2026-07-02T13:09:37.445Z" },
{ url = "https://files.pythonhosted.org/packages/17/27/a0bc09d032267b9da89d95a2d874cfbef2a5aebbf0e87cf7aba221d79a99/coverage-7.15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c48decf16e0dfd5b049c7d5e82200c23c08126719142998d4f172444e3d0529e", size = 252153, upload-time = "2026-07-02T13:09:39.422Z" },
{ url = "https://files.pythonhosted.org/packages/54/c0/77fc233d9fba07b244c40948c53fe27308b8f21732fb3417f87fbd6fd992/coverage-7.15.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:08fb028000ed0aaa0a4cbdfbb98be7cb42f370db973fbbb469733505ab20e13e", size = 256310, upload-time = "2026-07-02T13:09:41.006Z" },
{ url = "https://files.pythonhosted.org/packages/d5/24/601cecfb5825becacb8d45219a018a3b55b9dbaec624efdb0ea249d08be2/coverage-7.15.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb7dc0c3b7d8a1077abea0b8546ebc5e26d6ef6ecefc2f0f5ad2b8a53bdad837", size = 251974, upload-time = "2026-07-02T13:09:42.733Z" },
{ url = "https://files.pythonhosted.org/packages/47/1e/6f45e5a5b3d5484318d368702af6716b5ab8913b0428bec981a562fcf296/coverage-7.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cb3602054ccbe9f0d8c2dc04bbeba90d5719236e2cd06e042ddd6d3fc7b6e37", size = 253745, upload-time = "2026-07-02T13:09:44.376Z" },
{ url = "https://files.pythonhosted.org/packages/8e/db/4df027a77bd11d0e527f44c53557c76e54ad027413d0304252ea3a78d67e/coverage-7.15.0-cp313-cp313-win32.whl", hash = "sha256:0bf781da64326b677be344df505171435b6f58716108606621d5d27d964fff8b", size = 222902, upload-time = "2026-07-02T13:09:46.122Z" },
{ url = "https://files.pythonhosted.org/packages/a0/10/0355894d34e231f2c5449e71287e81a50793a325df2e2b027b7bcd9dfd19/coverage-7.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:2c57a275078ee3fa185f83e400f765bc764a549de66d99b47881645cbd4ea629", size = 223444, upload-time = "2026-07-02T13:09:47.687Z" },
{ url = "https://files.pythonhosted.org/packages/06/ef/bb725f263befaaff851203ab338e68af15e195d7f7b5f323162532d9b6a8/coverage-7.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:3812c61afc6685c7999b39320779ab8f43b7a3081fdb0def39976e56fbdb9a21", size = 222839, upload-time = "2026-07-02T13:09:49.717Z" },
{ url = "https://files.pythonhosted.org/packages/4f/9c/1e3ca54f72a3185ece06c58d871099898c48f0ed6430d17b6ab75f0d180a/coverage-7.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:41cb79af843222e11da87127ad0ecbfa878abadd0f770a4a99391a27d3887324", size = 220906, upload-time = "2026-07-02T13:09:51.339Z" },
{ url = "https://files.pythonhosted.org/packages/09/37/f718613d83b274880382f6b67e78f3802549ae39b0b3e65ae5b5974df56e/coverage-7.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7d2008989ef8fe54188d3f3bfa2e3099b025af11e90a6a1b9e7dc433d04263d8", size = 221239, upload-time = "2026-07-02T13:09:53.138Z" },
{ url = "https://files.pythonhosted.org/packages/a7/ce/22bae91e0b75445f68d365c7643ed0aa4880bbf77450ee74ca65bdae53a7/coverage-7.15.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:769e8ece11a596315ebf5aa7ec383aeeed016c091d2bf6363ffb996d41529092", size = 252286, upload-time = "2026-07-02T13:09:54.996Z" },
{ url = "https://files.pythonhosted.org/packages/dd/1e/bec5e32aa508615d9d7a2790effb25fb4dc28606e995816afe400b25ece3/coverage-7.15.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:65a6b6164ee5c39e2f3803f314292d6c61a607ba7fee253d1e03c42dc3903502", size = 254789, upload-time = "2026-07-02T13:09:56.678Z" },
{ url = "https://files.pythonhosted.org/packages/17/29/0e865435b4354e4a7c03b1b7920046d31d0a273d55decefea27e011cb9bf/coverage-7.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75128817f95a5c45bb01d65fd2d8b9cb54bbe03d81608fb70e3e14b437ad56c2", size = 256135, upload-time = "2026-07-02T13:09:58.343Z" },
{ url = "https://files.pythonhosted.org/packages/84/ff/33a870b58a13325d62fc0a6c8f01fa0ff667cef60c7498e2382a147dfa18/coverage-7.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9887bb428fe2d4cd4bee89bac1a6c9932f484afd5b36fbd4ff6ea5f825bb1f5e", size = 258449, upload-time = "2026-07-02T13:10:00.057Z" },
{ url = "https://files.pythonhosted.org/packages/18/7b/6fffe596bf3ddba8462758d02c5dad730fd91055a6634aa2e4226229181a/coverage-7.15.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0bfc0be1f702042207a93a00523b1065ee1fe951e96edf311581c0bbc2e34888", size = 252313, upload-time = "2026-07-02T13:10:01.946Z" },
{ url = "https://files.pythonhosted.org/packages/58/1b/11468dd6c1676ab831a70cb9a8d4e198e8607fa0b7220ab918b73fe9bfbd/coverage-7.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f64627d55def5a43282d70e08396672692f77e4da610a5bb8bb4060b432b6859", size = 254142, upload-time = "2026-07-02T13:10:04.065Z" },
{ url = "https://files.pythonhosted.org/packages/79/41/29328e21d16b1b95092c30dd700e08cf915bd3734f836df8f3bdb0e8fa9f/coverage-7.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:2c6f0fa473003905c6d5bac328ee4eba9fbea654f15bc24b8a3274b23363fa99", size = 252108, upload-time = "2026-07-02T13:10:06.11Z" },
{ url = "https://files.pythonhosted.org/packages/9b/de/05ccfb990439655b35afbfd8e0d13fe66677565a7d4eb38c3f5ef2635e1c/coverage-7.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2bcf9afaf064172c6ec3c58a325a9957ad1178c05dd934e25f253321776e0676", size = 256385, upload-time = "2026-07-02T13:10:08.141Z" },
{ url = "https://files.pythonhosted.org/packages/51/0e/486828a3d2695ea7a2609f17ff572f6b01905e608379440a11da4b8dffbe/coverage-7.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:baf06bc987115d6fb938d403f7eab684a057766c490367999a2b71a6883110c6", size = 251923, upload-time = "2026-07-02T13:10:10.179Z" },
{ url = "https://files.pythonhosted.org/packages/18/c7/03582b6715f078e5e558354c87616d945b9894cda2dace8e4009b17035e4/coverage-7.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f0405f2ff97b1c4c0e782cb32e02f32369bcf2e6b618b591d67e1ea754575dfe", size = 253580, upload-time = "2026-07-02T13:10:12.052Z" },
{ url = "https://files.pythonhosted.org/packages/db/dc/9e578bbaf2ecb4959a81b7e7601ad8cca772cba2892e8d144cb749b4a71a/coverage-7.15.0-cp314-cp314-win32.whl", hash = "sha256:ab282853ed5fbd64bbb162f19cb8fcb7087187508a6374b4f9c34ec1577c4e8f", size = 223107, upload-time = "2026-07-02T13:10:13.994Z" },
{ url = "https://files.pythonhosted.org/packages/ae/3e/c8c3b75d8dbe0e35f7b0cc3ff5e949fc59500f70b21d0398813f66740664/coverage-7.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:3bb3040e9f4bbe26fcb0cd7cc85ac63e630d3f3a9c74f027abf4caa27e706663", size = 223597, upload-time = "2026-07-02T13:10:15.906Z" },
{ url = "https://files.pythonhosted.org/packages/cd/bc/3cbc9fb036eb388519bccd521f783499c39b64256013fbc362782f196fe1/coverage-7.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:346771144d34f7fa84ec28386f78e0f31653f33cf35e19d253d5b35f9e8201da", size = 223020, upload-time = "2026-07-02T13:10:17.844Z" },
{ url = "https://files.pythonhosted.org/packages/28/00/199c4a8d656dff63102577a056c0fce2ff6a79e40adac092fc986c49cbf1/coverage-7.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d34a010905fb6401324ba016b5da03d574967f7b21ce48ea41e66f0f1f95f641", size = 221638, upload-time = "2026-07-02T13:10:19.703Z" },
{ url = "https://files.pythonhosted.org/packages/ba/8e/9d0092c96a3d3a26951ecc7020826aa57bcb1b119ca81acbba996884ab13/coverage-7.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bb25d825d885ca8036795dacfc3924d33091fc76d71ebc99420c6b79e77d96fa", size = 221903, upload-time = "2026-07-02T13:10:21.514Z" },
{ url = "https://files.pythonhosted.org/packages/6d/b4/c0ca3028f42c9a08e51feb4561ef1192e5de99797cd1db5b04590c215bda/coverage-7.15.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:94c9686bfe8a9a6810297aecbd99beaa3445f9e8dc2f80b1382cca0d86b64461", size = 263267, upload-time = "2026-07-02T13:10:23.261Z" },
{ url = "https://files.pythonhosted.org/packages/5f/aa/a375e3846e5d3c013dc600b2a3231089055c73d77f5393dd2192a8d64da6/coverage-7.15.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9bd671c25f9d85f09d7ec481d0e43d5139f486c06a37139847a7ce569788af72", size = 265390, upload-time = "2026-07-02T13:10:25.152Z" },
{ url = "https://files.pythonhosted.org/packages/92/e1/5783cdabb797305e1c9e4809fea496d31834c51fa772514f73dc148bcfc9/coverage-7.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:110cbdf8d2e216577312cf06ccf85539c0e5a5420ef747e4a4719b5e483c88cd", size = 267811, upload-time = "2026-07-02T13:10:27.249Z" },
{ url = "https://files.pythonhosted.org/packages/85/31/96d8bbf58b8e9193bc8389574a91a0db48355ee98feb66aa6bf8d1b32eea/coverage-7.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2c5d4619214f1d9993e7b00a8600d14614b7e9d84e89507460b126aa5e6559e5", size = 268928, upload-time = "2026-07-02T13:10:29.242Z" },
{ url = "https://files.pythonhosted.org/packages/5e/7a/5294567e811a1cb7eda93140c628fa050d66189da28da320f93d1d815c73/coverage-7.15.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:781a704516e2d8346fbbd5be6c6f3412dd824785146528b3a01816f26c081007", size = 262378, upload-time = "2026-07-02T13:10:31.107Z" },
{ url = "https://files.pythonhosted.org/packages/69/3f/3f48538421f899f28946f90a3d272136a4686e1abf461cc9249a783ee0f3/coverage-7.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd4a1b44bcb65ee29e947ac92bbee04956df3a6bfc6143641bb6cae7ede00fc9", size = 265263, upload-time = "2026-07-02T13:10:32.942Z" },
{ url = "https://files.pythonhosted.org/packages/ce/d3/092df15efcab8a9c1467ee960eb8019bbad3f9300d115d89ea6195f369ff/coverage-7.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0e4950c9d6d3e39c64c991814ff315e2d0b9cb8152363594212c9e55208c0a8f", size = 262866, upload-time = "2026-07-02T13:10:35.104Z" },
{ url = "https://files.pythonhosted.org/packages/e5/ab/0254d2b88665efb2c57ad368cc77ab5de3435bd8d5add4729c1b0e79431e/coverage-7.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:fe9c87ff42e5472d80d21704972e1f96e104a0a599d77c5e35db5a3c562e2571", size = 266599, upload-time = "2026-07-02T13:10:37.05Z" },
{ url = "https://files.pythonhosted.org/packages/a8/79/1cfa4023e489ce6fbc7be4a5d442dbc375edb4f4fda39a352cedb53263c2/coverage-7.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f00d5ae1dd2fe13fb8186e3e7d37bcbd8b25c0d764ff7d1b32cef9be058510a8", size = 261714, upload-time = "2026-07-02T13:10:38.966Z" },
{ url = "https://files.pythonhosted.org/packages/b7/eb/fee5c8665656be63f497418d410484637c438172568688e8ac92e06574e7/coverage-7.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:363ab38cc78b615f11c9cac3cf1d7eef950c18b9fdedfb9066f59461dcf84d68", size = 264025, upload-time = "2026-07-02T13:10:40.789Z" },
{ url = "https://files.pythonhosted.org/packages/ab/99/63005db722f91edc81abc16302f9cc2f6228c1679e46e15be9ae144b14d0/coverage-7.15.0-cp314-cp314t-win32.whl", hash = "sha256:54fd9c53a5fafff509195f1b6a3f9be615d8e8362a3629ff1de23d270c03c86b", size = 223413, upload-time = "2026-07-02T13:10:42.597Z" },
{ url = "https://files.pythonhosted.org/packages/c1/e8/2bc6181c4fb06f1a6b981eb85330cc57bfad7e3f710fc9c9d350013ba228/coverage-7.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:87b47553097ba185ed964866078e7e63adea9f5f51b5f39691c34f30afd21080", size = 224245, upload-time = "2026-07-02T13:10:44.47Z" },
{ url = "https://files.pythonhosted.org/packages/79/b8/4d959bf9cc45d0cfed2f4d35cafcab978cdb6ea02eb5100009cd740632a3/coverage-7.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aeefb2dd178fe7eee79f0ad25d75855cb35ee9ed472db2c5ea06f5b4fd00cec5", size = 223558, upload-time = "2026-07-02T13:10:46.368Z" },
{ url = "https://files.pythonhosted.org/packages/52/30/21b2ad45959cd50e909e02ebac1e30b4ceb7162e91c11d4c570223a458b7/coverage-7.15.0-py3-none-any.whl", hash = "sha256:56da6a4cbe8f7e9e80bd072ca9cefe67d7106a440a7ec06519ec6507ac94ad19", size = 212632, upload-time = "2026-07-02T13:10:48.641Z" },
]
[[package]]
name = "cryptography"
version = "46.0.3"
@@ -350,17 +419,20 @@ dependencies = [
[package.optional-dependencies]
dev = [
{ name = "cryptography" },
{ name = "hatch" },
{ name = "mypy" },
{ name = "pre-commit" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-cov" },
{ name = "pytest-mock" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "cryptography", marker = "extra == 'dev'", specifier = ">=42.0" },
{ name = "hatch", marker = "extra == 'dev'" },
{ name = "httpx", specifier = ">=0.25.0" },
{ name = "mcp", extras = ["cli"], specifier = ">=1.28.1" },
@@ -369,6 +441,7 @@ requires-dist = [
{ name = "pyjwt", extras = ["crypto"], specifier = ">=2.8.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.5" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.21" },
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5.0" },
{ name = "pytest-mock", marker = "extra == 'dev'", specifier = "==3.12.0" },
{ name = "python-dotenv", specifier = ">=1.0.0" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" },
@@ -806,6 +879,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
]
[[package]]
name = "pytest-cov"
version = "7.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "coverage" },
{ name = "pluggy" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
]
[[package]]
name = "pytest-mock"
version = "3.12.0"