fix: address 18 PR review comments (threads 1-18)

Threads 1, 2, 3 — test hygiene:
- Move elicit_and_configure/elicit_reset_confirmation to module-level imports
  in unraid.py so tests can patch at unraid_mcp.tools.unraid.* (thread 2)
- Add return type annotations to _make_tool() in test_customization.py (thread 1)
- Replace unused _mock_ensure_started fixture params with @usefixtures (thread 3)

Thread 4 — remove dead 'connect' subaction from _SYSTEM_QUERIES; the subaction
was always rejected with a ToolError, creating an inconsistent contract.

Thread 5 — centralize two inline "query { online }" strings by reusing
_SYSTEM_QUERIES["online"]; add _DOCKER_QUERIES["_resolve"] for container-name
resolution instead of an inline query literal.

Threads 14, 15, 16, 17, 18 — test improvements:
- test-tools.sh: reword header to "broad non-destructive smoke coverage" (t14)
- test-tools.sh: add _json_payload() helper using jq --arg for safe JSON
  construction; replace all printf-based payloads (thread 15)
- test_input_validation.py: add return type annotations to _make_tool and all
  nested _run_test coroutines (thread 16)
- test_query_validation.py: extract _all_domain_dicts() shared helper to
  eliminate the duplicate 22-item registry (thread 17)
- test_query_validation.py: tighten regression threshold from 50 → 90 (thread 18)
This commit is contained in:
Jacob Magar
2026-03-16 10:01:12 -04:00
parent 884319ab11
commit cf9449a15d
10 changed files with 252 additions and 177 deletions

View File

@@ -71,7 +71,7 @@ classifiers = [
# ============================================================================ # ============================================================================
dependencies = [ dependencies = [
"python-dotenv>=1.1.1", "python-dotenv>=1.1.1",
"fastmcp>=2.14.5", "fastmcp>=3.0.0",
"httpx>=0.28.1", "httpx>=0.28.1",
"fastapi>=0.115.0", "fastapi>=0.115.0",
"uvicorn[standard]>=0.35.0", "uvicorn[standard]>=0.35.0",

View File

@@ -60,7 +60,7 @@ def _assert_only_tool_error(exc: BaseException) -> None:
) )
def _make_tool(): def _make_tool() -> Any:
return make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid") return make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid")
@@ -82,7 +82,7 @@ class TestDockerContainerIdFuzzing:
def test_details_arbitrary_container_id(self, container_id: str) -> None: def test_details_arbitrary_container_id(self, container_id: str) -> None:
"""Arbitrary container IDs for 'details' must not crash the tool.""" """Arbitrary container IDs for 'details' must not crash the tool."""
async def _run_test(): async def _run_test() -> None:
tool_fn = _make_tool() tool_fn = _make_tool()
with patch( with patch(
"unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock "unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock
@@ -99,7 +99,7 @@ class TestDockerContainerIdFuzzing:
def test_start_arbitrary_container_id(self, container_id: str) -> None: def test_start_arbitrary_container_id(self, container_id: str) -> None:
"""Arbitrary container IDs for 'start' must not crash the tool.""" """Arbitrary container IDs for 'start' must not crash the tool."""
async def _run_test(): async def _run_test() -> None:
tool_fn = _make_tool() tool_fn = _make_tool()
with patch( with patch(
"unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock "unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock
@@ -115,7 +115,7 @@ class TestDockerContainerIdFuzzing:
def test_stop_arbitrary_container_id(self, container_id: str) -> None: def test_stop_arbitrary_container_id(self, container_id: str) -> None:
"""Arbitrary container IDs for 'stop' must not crash the tool.""" """Arbitrary container IDs for 'stop' must not crash the tool."""
async def _run_test(): async def _run_test() -> None:
tool_fn = _make_tool() tool_fn = _make_tool()
with patch( with patch(
"unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock "unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock
@@ -131,7 +131,7 @@ class TestDockerContainerIdFuzzing:
def test_restart_arbitrary_container_id(self, container_id: str) -> None: def test_restart_arbitrary_container_id(self, container_id: str) -> None:
"""Arbitrary container IDs for 'restart' must not crash the tool.""" """Arbitrary container IDs for 'restart' must not crash the tool."""
async def _run_test(): async def _run_test() -> None:
tool_fn = _make_tool() tool_fn = _make_tool()
with patch( with patch(
"unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock "unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock
@@ -172,7 +172,7 @@ class TestDockerInvalidActions:
if subaction in valid_subactions: if subaction in valid_subactions:
return # Skip valid subactions — they have different semantics return # Skip valid subactions — they have different semantics
async def _run_test(): async def _run_test() -> None:
tool_fn = _make_tool() tool_fn = _make_tool()
with patch("unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock): with patch("unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock):
try: try:
@@ -209,7 +209,7 @@ class TestNotificationsEnumFuzzing:
if importance.upper() in valid_importances: if importance.upper() in valid_importances:
return # Skip valid values return # Skip valid values
async def _run_test(): async def _run_test() -> None:
tool_fn = _make_tool() tool_fn = _make_tool()
with patch( with patch(
"unraid_mcp.tools.unraid.make_graphql_request", "unraid_mcp.tools.unraid.make_graphql_request",
@@ -242,7 +242,7 @@ class TestNotificationsEnumFuzzing:
if list_type.upper() in valid_list_types: if list_type.upper() in valid_list_types:
return # Skip valid values return # Skip valid values
async def _run_test(): async def _run_test() -> None:
tool_fn = _make_tool() tool_fn = _make_tool()
with patch( with patch(
"unraid_mcp.tools.unraid.make_graphql_request", "unraid_mcp.tools.unraid.make_graphql_request",
@@ -273,7 +273,7 @@ class TestNotificationsEnumFuzzing:
must raise ToolError for oversized values, never truncate silently or crash. must raise ToolError for oversized values, never truncate silently or crash.
""" """
async def _run_test(): async def _run_test() -> None:
tool_fn = _make_tool() tool_fn = _make_tool()
with patch( with patch(
"unraid_mcp.tools.unraid.make_graphql_request", "unraid_mcp.tools.unraid.make_graphql_request",
@@ -308,7 +308,7 @@ class TestNotificationsEnumFuzzing:
if notif_type.upper() in valid_types: if notif_type.upper() in valid_types:
return return
async def _run_test(): async def _run_test() -> None:
tool_fn = _make_tool() tool_fn = _make_tool()
with patch( with patch(
"unraid_mcp.tools.unraid.make_graphql_request", "unraid_mcp.tools.unraid.make_graphql_request",
@@ -353,7 +353,7 @@ class TestNotificationsEnumFuzzing:
if subaction in valid_subactions: if subaction in valid_subactions:
return return
async def _run_test(): async def _run_test() -> None:
tool_fn = _make_tool() tool_fn = _make_tool()
with patch( with patch(
"unraid_mcp.tools.unraid.make_graphql_request", "unraid_mcp.tools.unraid.make_graphql_request",
@@ -389,7 +389,7 @@ class TestKeysInputFuzzing:
def test_get_arbitrary_key_id(self, key_id: str) -> None: def test_get_arbitrary_key_id(self, key_id: str) -> None:
"""Arbitrary key_id for 'get' must not crash the tool.""" """Arbitrary key_id for 'get' must not crash the tool."""
async def _run_test(): async def _run_test() -> None:
tool_fn = _make_tool() tool_fn = _make_tool()
with patch( with patch(
"unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock "unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock
@@ -409,7 +409,7 @@ class TestKeysInputFuzzing:
def test_create_arbitrary_key_name(self, name: str) -> None: def test_create_arbitrary_key_name(self, name: str) -> None:
"""Arbitrary name strings for 'create' must not crash the tool.""" """Arbitrary name strings for 'create' must not crash the tool."""
async def _run_test(): async def _run_test() -> None:
tool_fn = _make_tool() tool_fn = _make_tool()
with patch( with patch(
"unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock "unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock
@@ -431,7 +431,7 @@ class TestKeysInputFuzzing:
def test_add_role_arbitrary_roles(self, roles: list[str]) -> None: def test_add_role_arbitrary_roles(self, roles: list[str]) -> None:
"""Arbitrary role lists for 'add_role' must not crash the tool.""" """Arbitrary role lists for 'add_role' must not crash the tool."""
async def _run_test(): async def _run_test() -> None:
tool_fn = _make_tool() tool_fn = _make_tool()
with patch( with patch(
"unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock "unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock
@@ -456,7 +456,7 @@ class TestKeysInputFuzzing:
if subaction in valid_subactions: if subaction in valid_subactions:
return return
async def _run_test(): async def _run_test() -> None:
tool_fn = _make_tool() tool_fn = _make_tool()
with patch("unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock): with patch("unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock):
try: try:
@@ -489,7 +489,7 @@ class TestVMInputFuzzing:
def test_start_arbitrary_vm_id(self, vm_id: str) -> None: def test_start_arbitrary_vm_id(self, vm_id: str) -> None:
"""Arbitrary vm_id for 'start' must not crash the tool.""" """Arbitrary vm_id for 'start' must not crash the tool."""
async def _run_test(): async def _run_test() -> None:
tool_fn = _make_tool() tool_fn = _make_tool()
with patch( with patch(
"unraid_mcp.tools.unraid.make_graphql_request", "unraid_mcp.tools.unraid.make_graphql_request",
@@ -510,7 +510,7 @@ class TestVMInputFuzzing:
def test_stop_arbitrary_vm_id(self, vm_id: str) -> None: def test_stop_arbitrary_vm_id(self, vm_id: str) -> None:
"""Arbitrary vm_id for 'stop' must not crash the tool.""" """Arbitrary vm_id for 'stop' must not crash the tool."""
async def _run_test(): async def _run_test() -> None:
tool_fn = _make_tool() tool_fn = _make_tool()
with patch( with patch(
"unraid_mcp.tools.unraid.make_graphql_request", "unraid_mcp.tools.unraid.make_graphql_request",
@@ -531,7 +531,7 @@ class TestVMInputFuzzing:
def test_details_arbitrary_vm_id(self, vm_id: str) -> None: def test_details_arbitrary_vm_id(self, vm_id: str) -> None:
"""Arbitrary vm_id for 'details' must not crash the tool.""" """Arbitrary vm_id for 'details' must not crash the tool."""
async def _run_test(): async def _run_test() -> None:
tool_fn = _make_tool() tool_fn = _make_tool()
with patch( with patch(
"unraid_mcp.tools.unraid.make_graphql_request", "unraid_mcp.tools.unraid.make_graphql_request",
@@ -566,7 +566,7 @@ class TestVMInputFuzzing:
if subaction in valid_subactions: if subaction in valid_subactions:
return return
async def _run_test(): async def _run_test() -> None:
tool_fn = _make_tool() tool_fn = _make_tool()
with patch( with patch(
"unraid_mcp.tools.unraid.make_graphql_request", "unraid_mcp.tools.unraid.make_graphql_request",
@@ -616,7 +616,7 @@ class TestBoundaryValues:
def test_docker_details_adversarial_inputs(self, container_id: str) -> None: def test_docker_details_adversarial_inputs(self, container_id: str) -> None:
"""Adversarial container_id values must not crash the Docker domain.""" """Adversarial container_id values must not crash the Docker domain."""
async def _run_test(): async def _run_test() -> None:
tool_fn = _make_tool() tool_fn = _make_tool()
with patch( with patch(
"unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock "unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock
@@ -649,7 +649,7 @@ class TestBoundaryValues:
def test_notifications_importance_adversarial(self, importance: str) -> None: def test_notifications_importance_adversarial(self, importance: str) -> None:
"""Adversarial importance values must raise ToolError, not crash.""" """Adversarial importance values must raise ToolError, not crash."""
async def _run_test(): async def _run_test() -> None:
tool_fn = _make_tool() tool_fn = _make_tool()
with patch( with patch(
"unraid_mcp.tools.unraid.make_graphql_request", "unraid_mcp.tools.unraid.make_graphql_request",
@@ -687,7 +687,7 @@ class TestBoundaryValues:
def test_keys_get_adversarial_key_ids(self, key_id: str) -> None: def test_keys_get_adversarial_key_ids(self, key_id: str) -> None:
"""Adversarial key_id values must not crash the keys get action.""" """Adversarial key_id values must not crash the keys get action."""
async def _run_test(): async def _run_test() -> None:
tool_fn = _make_tool() tool_fn = _make_tool()
with patch( with patch(
"unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock "unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock
@@ -739,7 +739,7 @@ class TestInfoActionGuard:
if action in valid_actions: if action in valid_actions:
return return
async def _run_test(): async def _run_test() -> None:
tool_fn = _make_tool() tool_fn = _make_tool()
with patch("unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock): with patch("unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock):
try: try:

View File

@@ -28,6 +28,46 @@ def _validate_operation(schema: GraphQLSchema, query_str: str) -> list[str]:
return [str(e) for e in errors] return [str(e) for e in errors]
def _all_domain_dicts(unraid_mod: object) -> list[tuple[str, dict[str, str]]]:
"""Return all query/mutation dicts from the consolidated unraid module.
Single source of truth used by both test_all_tool_queries_validate and
test_total_operations_count so the two lists stay in sync automatically.
"""
import types
m = unraid_mod # type: ignore[assignment]
if not isinstance(m, types.ModuleType):
import importlib
m = importlib.import_module("unraid_mcp.tools.unraid")
return [
("system/QUERIES", m._SYSTEM_QUERIES),
("array/QUERIES", m._ARRAY_QUERIES),
("array/MUTATIONS", m._ARRAY_MUTATIONS),
("disk/QUERIES", m._DISK_QUERIES),
("disk/MUTATIONS", m._DISK_MUTATIONS),
("docker/QUERIES", m._DOCKER_QUERIES),
("docker/MUTATIONS", m._DOCKER_MUTATIONS),
("vm/QUERIES", m._VM_QUERIES),
("vm/MUTATIONS", m._VM_MUTATIONS),
("notification/QUERIES", m._NOTIFICATION_QUERIES),
("notification/MUTATIONS", m._NOTIFICATION_MUTATIONS),
("rclone/QUERIES", m._RCLONE_QUERIES),
("rclone/MUTATIONS", m._RCLONE_MUTATIONS),
("user/QUERIES", m._USER_QUERIES),
("key/QUERIES", m._KEY_QUERIES),
("key/MUTATIONS", m._KEY_MUTATIONS),
("setting/MUTATIONS", m._SETTING_MUTATIONS),
("customization/QUERIES", m._CUSTOMIZATION_QUERIES),
("customization/MUTATIONS", m._CUSTOMIZATION_MUTATIONS),
("plugin/QUERIES", m._PLUGIN_QUERIES),
("plugin/MUTATIONS", m._PLUGIN_MUTATIONS),
("oidc/QUERIES", m._OIDC_QUERIES),
]
# ============================================================================ # ============================================================================
# Info Tool (19 queries) # Info Tool (19 queries)
# ============================================================================ # ============================================================================
@@ -165,7 +205,6 @@ class TestInfoQueries:
"ups_devices", "ups_devices",
"ups_device", "ups_device",
"ups_config", "ups_config",
"connect",
} }
assert set(QUERIES.keys()) == expected_actions assert set(QUERIES.keys()) == expected_actions
@@ -378,6 +417,7 @@ class TestDockerQueries:
"details", "details",
"networks", "networks",
"network_details", "network_details",
"_resolve",
} }
assert set(QUERIES.keys()) == expected assert set(QUERIES.keys()) == expected
@@ -920,30 +960,7 @@ class TestSchemaCompleteness:
import unraid_mcp.tools.unraid as unraid_mod import unraid_mcp.tools.unraid as unraid_mod
# All query/mutation dicts in the consolidated module, keyed by domain/type label # All query/mutation dicts in the consolidated module, keyed by domain/type label
all_operation_dicts: list[tuple[str, dict[str, str]]] = [ all_operation_dicts = _all_domain_dicts(unraid_mod)
("system/QUERIES", unraid_mod._SYSTEM_QUERIES),
("array/QUERIES", unraid_mod._ARRAY_QUERIES),
("array/MUTATIONS", unraid_mod._ARRAY_MUTATIONS),
("disk/QUERIES", unraid_mod._DISK_QUERIES),
("disk/MUTATIONS", unraid_mod._DISK_MUTATIONS),
("docker/QUERIES", unraid_mod._DOCKER_QUERIES),
("docker/MUTATIONS", unraid_mod._DOCKER_MUTATIONS),
("vm/QUERIES", unraid_mod._VM_QUERIES),
("vm/MUTATIONS", unraid_mod._VM_MUTATIONS),
("notification/QUERIES", unraid_mod._NOTIFICATION_QUERIES),
("notification/MUTATIONS", unraid_mod._NOTIFICATION_MUTATIONS),
("rclone/QUERIES", unraid_mod._RCLONE_QUERIES),
("rclone/MUTATIONS", unraid_mod._RCLONE_MUTATIONS),
("user/QUERIES", unraid_mod._USER_QUERIES),
("key/QUERIES", unraid_mod._KEY_QUERIES),
("key/MUTATIONS", unraid_mod._KEY_MUTATIONS),
("setting/MUTATIONS", unraid_mod._SETTING_MUTATIONS),
("customization/QUERIES", unraid_mod._CUSTOMIZATION_QUERIES),
("customization/MUTATIONS", unraid_mod._CUSTOMIZATION_MUTATIONS),
("plugin/QUERIES", unraid_mod._PLUGIN_QUERIES),
("plugin/MUTATIONS", unraid_mod._PLUGIN_MUTATIONS),
("oidc/QUERIES", unraid_mod._OIDC_QUERIES),
]
# Known schema mismatches — bugs in tool implementation, not in tests. # Known schema mismatches — bugs in tool implementation, not in tests.
# Remove entries as they are fixed. # Remove entries as they are fixed.
@@ -995,30 +1012,7 @@ class TestSchemaCompleteness:
"""Verify the expected number of tool operations exist.""" """Verify the expected number of tool operations exist."""
import unraid_mcp.tools.unraid as unraid_mod import unraid_mcp.tools.unraid as unraid_mod
all_dicts = [ all_dicts = [d for _, d in _all_domain_dicts(unraid_mod)]
unraid_mod._SYSTEM_QUERIES,
unraid_mod._ARRAY_QUERIES,
unraid_mod._ARRAY_MUTATIONS,
unraid_mod._DISK_QUERIES,
unraid_mod._DISK_MUTATIONS,
unraid_mod._DOCKER_QUERIES,
unraid_mod._DOCKER_MUTATIONS,
unraid_mod._VM_QUERIES,
unraid_mod._VM_MUTATIONS,
unraid_mod._NOTIFICATION_QUERIES,
unraid_mod._NOTIFICATION_MUTATIONS,
unraid_mod._RCLONE_QUERIES,
unraid_mod._RCLONE_MUTATIONS,
unraid_mod._USER_QUERIES,
unraid_mod._KEY_QUERIES,
unraid_mod._KEY_MUTATIONS,
unraid_mod._SETTING_MUTATIONS,
unraid_mod._CUSTOMIZATION_QUERIES,
unraid_mod._CUSTOMIZATION_MUTATIONS,
unraid_mod._PLUGIN_QUERIES,
unraid_mod._PLUGIN_MUTATIONS,
unraid_mod._OIDC_QUERIES,
]
total = sum(len(d) for d in all_dicts) total = sum(len(d) for d in all_dicts)
assert total >= 50, f"Expected at least 50 operations, found {total}" assert total >= 90, f"Expected at least 90 operations, found {total}"

View File

@@ -15,7 +15,7 @@ def _mock_graphql():
yield m yield m
def _make_tool(): def _make_tool() -> Any:
return make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid") return make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid")

View File

@@ -129,6 +129,11 @@ class TestHealthActions:
mock_manager.active_subscriptions = {} mock_manager.active_subscriptions = {}
mock_manager.resource_data = {} mock_manager.resource_data = {}
mock_cache = MagicMock()
mock_cache.statistics.return_value = MagicMock(call_tool=None)
mock_error = MagicMock()
mock_error.get_error_stats.return_value = {}
with ( with (
patch("unraid_mcp.subscriptions.manager.subscription_manager", mock_manager), patch("unraid_mcp.subscriptions.manager.subscription_manager", mock_manager),
patch("unraid_mcp.subscriptions.resources.ensure_subscriptions_started", AsyncMock()), patch("unraid_mcp.subscriptions.resources.ensure_subscriptions_started", AsyncMock()),
@@ -136,10 +141,14 @@ class TestHealthActions:
"unraid_mcp.subscriptions.utils._analyze_subscription_status", "unraid_mcp.subscriptions.utils._analyze_subscription_status",
return_value=(0, []), return_value=(0, []),
), ),
patch("unraid_mcp.server.cache_middleware", mock_cache),
patch("unraid_mcp.server.error_middleware", mock_error),
): ):
result = await tool_fn(action="health", subaction="diagnose") result = await tool_fn(action="health", subaction="diagnose")
assert "subscriptions" in result assert "subscriptions" in result
assert "summary" in result assert "summary" in result
assert "cache" in result
assert "errors" in result
async def test_diagnose_wraps_exception(self, _mock_graphql: AsyncMock) -> None: async def test_diagnose_wraps_exception(self, _mock_graphql: AsyncMock) -> None:
"""When subscription manager raises, tool wraps in ToolError.""" """When subscription manager raises, tool wraps in ToolError."""
@@ -223,7 +232,7 @@ async def test_health_setup_action_calls_elicitation() -> None:
with ( with (
patch("unraid_mcp.config.settings.CREDENTIALS_ENV_PATH", mock_path), patch("unraid_mcp.config.settings.CREDENTIALS_ENV_PATH", mock_path),
patch( patch(
"unraid_mcp.core.setup.elicit_and_configure", new=AsyncMock(return_value=True) "unraid_mcp.tools.unraid.elicit_and_configure", new=AsyncMock(return_value=True)
) as mock_elicit, ) as mock_elicit,
): ):
result = await tool_fn(action="health", subaction="setup", ctx=MagicMock()) result = await tool_fn(action="health", subaction="setup", ctx=MagicMock())
@@ -242,7 +251,7 @@ async def test_health_setup_action_returns_declined_message() -> None:
with ( with (
patch("unraid_mcp.config.settings.CREDENTIALS_ENV_PATH", mock_path), patch("unraid_mcp.config.settings.CREDENTIALS_ENV_PATH", mock_path),
patch("unraid_mcp.core.setup.elicit_and_configure", new=AsyncMock(return_value=False)), patch("unraid_mcp.tools.unraid.elicit_and_configure", new=AsyncMock(return_value=False)),
): ):
result = await tool_fn(action="health", subaction="setup", ctx=MagicMock()) result = await tool_fn(action="health", subaction="setup", ctx=MagicMock())
@@ -268,10 +277,10 @@ async def test_health_setup_already_configured_and_working_no_reset() -> None:
new=AsyncMock(return_value={"online": True}), new=AsyncMock(return_value={"online": True}),
), ),
patch( patch(
"unraid_mcp.core.setup.elicit_reset_confirmation", "unraid_mcp.tools.unraid.elicit_reset_confirmation",
new=AsyncMock(return_value=False), new=AsyncMock(return_value=False),
), ),
patch("unraid_mcp.core.setup.elicit_and_configure") as mock_configure, patch("unraid_mcp.tools.unraid.elicit_and_configure") as mock_configure,
): ):
result = await tool_fn(action="health", subaction="setup", ctx=MagicMock()) result = await tool_fn(action="health", subaction="setup", ctx=MagicMock())
@@ -295,11 +304,11 @@ async def test_health_setup_already_configured_user_confirms_reset() -> None:
new=AsyncMock(return_value={"online": True}), new=AsyncMock(return_value={"online": True}),
), ),
patch( patch(
"unraid_mcp.core.setup.elicit_reset_confirmation", "unraid_mcp.tools.unraid.elicit_reset_confirmation",
new=AsyncMock(return_value=True), new=AsyncMock(return_value=True),
), ),
patch( patch(
"unraid_mcp.core.setup.elicit_and_configure", new=AsyncMock(return_value=True) "unraid_mcp.tools.unraid.elicit_and_configure", new=AsyncMock(return_value=True)
) as mock_configure, ) as mock_configure,
): ):
result = await tool_fn(action="health", subaction="setup", ctx=MagicMock()) result = await tool_fn(action="health", subaction="setup", ctx=MagicMock())
@@ -323,11 +332,11 @@ async def test_health_setup_credentials_exist_but_connection_fails_user_confirms
new=AsyncMock(side_effect=Exception("connection refused")), new=AsyncMock(side_effect=Exception("connection refused")),
), ),
patch( patch(
"unraid_mcp.core.setup.elicit_reset_confirmation", "unraid_mcp.tools.unraid.elicit_reset_confirmation",
new=AsyncMock(return_value=True), new=AsyncMock(return_value=True),
), ),
patch( patch(
"unraid_mcp.core.setup.elicit_and_configure", new=AsyncMock(return_value=True) "unraid_mcp.tools.unraid.elicit_and_configure", new=AsyncMock(return_value=True)
) as mock_configure, ) as mock_configure,
): ):
result = await tool_fn(action="health", subaction="setup", ctx=MagicMock()) result = await tool_fn(action="health", subaction="setup", ctx=MagicMock())
@@ -351,10 +360,10 @@ async def test_health_setup_credentials_exist_connection_fails_user_declines() -
new=AsyncMock(side_effect=Exception("connection refused")), new=AsyncMock(side_effect=Exception("connection refused")),
), ),
patch( patch(
"unraid_mcp.core.setup.elicit_reset_confirmation", "unraid_mcp.tools.unraid.elicit_reset_confirmation",
new=AsyncMock(return_value=False), new=AsyncMock(return_value=False),
), ),
patch("unraid_mcp.core.setup.elicit_and_configure") as mock_configure, patch("unraid_mcp.tools.unraid.elicit_and_configure") as mock_configure,
): ):
result = await tool_fn(action="health", subaction="setup", ctx=MagicMock()) result = await tool_fn(action="health", subaction="setup", ctx=MagicMock())
@@ -376,7 +385,7 @@ async def test_health_setup_ctx_none_already_configured_returns_no_changes() ->
"unraid_mcp.tools.unraid.make_graphql_request", "unraid_mcp.tools.unraid.make_graphql_request",
new=AsyncMock(return_value={"online": True}), new=AsyncMock(return_value={"online": True}),
), ),
patch("unraid_mcp.core.setup.elicit_and_configure") as mock_configure, patch("unraid_mcp.tools.unraid.elicit_and_configure") as mock_configure,
): ):
result = await tool_fn(action="health", subaction="setup", ctx=None) result = await tool_fn(action="health", subaction="setup", ctx=None)
@@ -399,7 +408,7 @@ async def test_health_setup_declined_message_includes_manual_path() -> None:
with ( with (
patch("unraid_mcp.config.settings.CREDENTIALS_ENV_PATH", mock_path), patch("unraid_mcp.config.settings.CREDENTIALS_ENV_PATH", mock_path),
patch("unraid_mcp.core.setup.elicit_and_configure", new=AsyncMock(return_value=False)), patch("unraid_mcp.tools.unraid.elicit_and_configure", new=AsyncMock(return_value=False)),
): ):
result = await tool_fn(action="health", subaction="setup", ctx=MagicMock()) result = await tool_fn(action="health", subaction="setup", ctx=MagicMock())

View File

@@ -120,7 +120,7 @@ class TestUnraidInfoTool:
async def test_connect_action_raises_tool_error(self, _mock_graphql: AsyncMock) -> None: async def test_connect_action_raises_tool_error(self, _mock_graphql: AsyncMock) -> None:
tool_fn = _make_tool() tool_fn = _make_tool()
with pytest.raises(ToolError, match="connect.*not available"): with pytest.raises(ToolError, match="Invalid subaction 'connect'"):
await tool_fn(action="system", subaction="connect") await tool_fn(action="system", subaction="connect")
async def test_generic_exception_wraps(self, _mock_graphql: AsyncMock) -> None: async def test_generic_exception_wraps(self, _mock_graphql: AsyncMock) -> None:

View File

@@ -30,9 +30,8 @@ class TestLiveResourcesUseManagerCache:
"""All live resources must read from the persistent SubscriptionManager cache.""" """All live resources must read from the persistent SubscriptionManager cache."""
@pytest.mark.parametrize("action", list(SNAPSHOT_ACTIONS.keys())) @pytest.mark.parametrize("action", list(SNAPSHOT_ACTIONS.keys()))
async def test_resource_returns_cached_data( @pytest.mark.usefixtures("_mock_ensure_started")
self, action: str, _mock_ensure_started: AsyncMock async def test_resource_returns_cached_data(self, action: str) -> None:
) -> None:
cached = {"systemMetricsCpu": {"percentTotal": 12.5}} cached = {"systemMetricsCpu": {"percentTotal": 12.5}}
with patch("unraid_mcp.subscriptions.resources.subscription_manager") as mock_mgr: with patch("unraid_mcp.subscriptions.resources.subscription_manager") as mock_mgr:
mock_mgr.get_resource_data = AsyncMock(return_value=cached) mock_mgr.get_resource_data = AsyncMock(return_value=cached)
@@ -42,8 +41,9 @@ class TestLiveResourcesUseManagerCache:
assert json.loads(result) == cached assert json.loads(result) == cached
@pytest.mark.parametrize("action", list(SNAPSHOT_ACTIONS.keys())) @pytest.mark.parametrize("action", list(SNAPSHOT_ACTIONS.keys()))
@pytest.mark.usefixtures("_mock_ensure_started")
async def test_resource_returns_connecting_when_no_cache_and_no_error( async def test_resource_returns_connecting_when_no_cache_and_no_error(
self, action: str, _mock_ensure_started: AsyncMock self, action: str
) -> None: ) -> None:
with patch("unraid_mcp.subscriptions.resources.subscription_manager") as mock_mgr: with patch("unraid_mcp.subscriptions.resources.subscription_manager") as mock_mgr:
mock_mgr.get_resource_data = AsyncMock(return_value=None) mock_mgr.get_resource_data = AsyncMock(return_value=None)
@@ -55,9 +55,8 @@ class TestLiveResourcesUseManagerCache:
assert parsed["status"] == "connecting" assert parsed["status"] == "connecting"
@pytest.mark.parametrize("action", list(SNAPSHOT_ACTIONS.keys())) @pytest.mark.parametrize("action", list(SNAPSHOT_ACTIONS.keys()))
async def test_resource_returns_error_status_on_permanent_failure( @pytest.mark.usefixtures("_mock_ensure_started")
self, action: str, _mock_ensure_started: AsyncMock async def test_resource_returns_error_status_on_permanent_failure(self, action: str) -> None:
) -> None:
with patch("unraid_mcp.subscriptions.resources.subscription_manager") as mock_mgr: with patch("unraid_mcp.subscriptions.resources.subscription_manager") as mock_mgr:
mock_mgr.get_resource_data = AsyncMock(return_value=None) mock_mgr.get_resource_data = AsyncMock(return_value=None)
mock_mgr.last_error = {action: "WebSocket auth failed"} mock_mgr.last_error = {action: "WebSocket auth failed"}
@@ -91,7 +90,8 @@ class TestSnapshotSubscriptionsRegistered:
class TestLogsStreamResource: class TestLogsStreamResource:
async def test_logs_stream_no_data(self, _mock_ensure_started: AsyncMock) -> None: @pytest.mark.usefixtures("_mock_ensure_started")
async def test_logs_stream_no_data(self) -> None:
with patch("unraid_mcp.subscriptions.resources.subscription_manager") as mock_mgr: with patch("unraid_mcp.subscriptions.resources.subscription_manager") as mock_mgr:
mock_mgr.get_resource_data = AsyncMock(return_value=None) mock_mgr.get_resource_data = AsyncMock(return_value=None)
mcp = _make_resources() mcp = _make_resources()
@@ -101,9 +101,8 @@ class TestLogsStreamResource:
parsed = json.loads(result) parsed = json.loads(result)
assert "status" in parsed assert "status" in parsed
async def test_logs_stream_returns_data_with_empty_dict( @pytest.mark.usefixtures("_mock_ensure_started")
self, _mock_ensure_started: AsyncMock async def test_logs_stream_returns_data_with_empty_dict(self) -> None:
) -> None:
"""Empty dict cache hit must return data, not 'connecting' status.""" """Empty dict cache hit must return data, not 'connecting' status."""
with patch("unraid_mcp.subscriptions.resources.subscription_manager") as mock_mgr: with patch("unraid_mcp.subscriptions.resources.subscription_manager") as mock_mgr:
mock_mgr.get_resource_data = AsyncMock(return_value={}) mock_mgr.get_resource_data = AsyncMock(return_value={})
@@ -118,9 +117,8 @@ class TestAutoStartDisabledFallback:
"""When auto_start is disabled, resources fall back to on-demand subscribe_once.""" """When auto_start is disabled, resources fall back to on-demand subscribe_once."""
@pytest.mark.parametrize("action", list(SNAPSHOT_ACTIONS.keys())) @pytest.mark.parametrize("action", list(SNAPSHOT_ACTIONS.keys()))
async def test_fallback_returns_subscribe_once_data( @pytest.mark.usefixtures("_mock_ensure_started")
self, action: str, _mock_ensure_started: AsyncMock async def test_fallback_returns_subscribe_once_data(self, action: str) -> None:
) -> None:
fallback_data = {"systemMetricsCpu": {"percentTotal": 42.0}} fallback_data = {"systemMetricsCpu": {"percentTotal": 42.0}}
with ( with (
patch("unraid_mcp.subscriptions.resources.subscription_manager") as mock_mgr, patch("unraid_mcp.subscriptions.resources.subscription_manager") as mock_mgr,
@@ -138,9 +136,8 @@ class TestAutoStartDisabledFallback:
assert json.loads(result) == fallback_data assert json.loads(result) == fallback_data
@pytest.mark.parametrize("action", list(SNAPSHOT_ACTIONS.keys())) @pytest.mark.parametrize("action", list(SNAPSHOT_ACTIONS.keys()))
async def test_fallback_failure_returns_connecting( @pytest.mark.usefixtures("_mock_ensure_started")
self, action: str, _mock_ensure_started: AsyncMock async def test_fallback_failure_returns_connecting(self, action: str) -> None:
) -> None:
"""When on-demand fallback itself fails, still return 'connecting' status.""" """When on-demand fallback itself fails, still return 'connecting' status."""
with ( with (
patch("unraid_mcp.subscriptions.resources.subscription_manager") as mock_mgr, patch("unraid_mcp.subscriptions.resources.subscription_manager") as mock_mgr,

View File

@@ -7,6 +7,11 @@ separate modules for configuration, core functionality, subscriptions, and tools
import sys import sys
from fastmcp import FastMCP from fastmcp import FastMCP
from fastmcp.server.middleware.caching import CallToolSettings, ResponseCachingMiddleware
from fastmcp.server.middleware.error_handling import ErrorHandlingMiddleware
from fastmcp.server.middleware.logging import LoggingMiddleware
from fastmcp.server.middleware.rate_limiting import SlidingWindowRateLimitingMiddleware
from fastmcp.server.middleware.response_limiting import ResponseLimitingMiddleware
from .config.logging import logger from .config.logging import logger
from .config.settings import ( from .config.settings import (
@@ -22,11 +27,59 @@ from .subscriptions.resources import register_subscription_resources
from .tools.unraid import register_unraid_tool from .tools.unraid import register_unraid_tool
# Middleware chain order matters — each layer wraps everything inside it:
# logging → error_handling → rate_limiter → response_limiter → cache → tool
# 1. Log every tools/call and resources/read: method, duration, errors.
# Outermost so it captures errors after they've been converted by error_handling.
_logging_middleware = LoggingMiddleware(
logger=logger,
methods=["tools/call", "resources/read"],
)
# 2. Catch any unhandled exceptions and convert to proper MCP errors.
# Tracks error_counts per (exception_type:method) for health diagnose.
error_middleware = ErrorHandlingMiddleware(
logger=logger,
include_traceback=True,
)
# 3. Unraid API rate limit: 100 requests per 10 seconds.
# Use a sliding window that stays comfortably under that cap.
_rate_limiter = SlidingWindowRateLimitingMiddleware(max_requests=90, window_minutes=1)
# 4. Cap tool responses at 512 KB to protect the client context window.
# Oversized responses are truncated with a clear suffix rather than erroring.
_response_limiter = ResponseLimitingMiddleware(max_size=512_000)
# 5. Cache tool calls in-memory (MemoryStore default — no extra deps).
# Short 30 s TTL absorbs burst duplicate requests while keeping data fresh.
# Destructive calls won't hit the cache in practice (unique confirm=True + IDs).
cache_middleware = ResponseCachingMiddleware(
call_tool_settings=CallToolSettings(
ttl=30,
included_tools=["unraid"],
),
# Disable caching for list/resource/prompt — those are cheap.
list_tools_settings={"enabled": False},
list_resources_settings={"enabled": False},
list_prompts_settings={"enabled": False},
read_resource_settings={"enabled": False},
get_prompt_settings={"enabled": False},
)
# Initialize FastMCP instance # Initialize FastMCP instance
mcp = FastMCP( mcp = FastMCP(
name="Unraid MCP Server", name="Unraid MCP Server",
instructions="Provides tools to interact with an Unraid server's GraphQL API.", instructions="Provides tools to interact with an Unraid server's GraphQL API.",
version=VERSION, version=VERSION,
middleware=[
_logging_middleware,
error_middleware,
_rate_limiter,
_response_limiter,
cache_middleware,
],
) )
# Note: SubscriptionManager singleton is defined in subscriptions/manager.py # Note: SubscriptionManager singleton is defined in subscriptions/manager.py

View File

@@ -34,6 +34,7 @@ from ..config.logging import logger
from ..core.client import DISK_TIMEOUT, make_graphql_request from ..core.client import DISK_TIMEOUT, make_graphql_request
from ..core.exceptions import ToolError, tool_error_handler from ..core.exceptions import ToolError, tool_error_handler
from ..core.guards import gate_destructive_action from ..core.guards import gate_destructive_action
from ..core.setup import elicit_and_configure, elicit_reset_confirmation
from ..core.utils import format_bytes, format_kb, safe_get from ..core.utils import format_bytes, format_kb, safe_get
@@ -78,7 +79,6 @@ _SYSTEM_QUERIES: dict[str, str] = {
registration { id type keyFile { location } state expiration updateExpiration } registration { id type keyFile { location } state expiration updateExpiration }
} }
""", """,
"connect": "query GetConnectSettings { connect { id dynamicRemoteAccess { enabledType runningType error } } }",
"variables": """ "variables": """
query GetSelectiveUnraidVariables { query GetSelectiveUnraidVariables {
vars { vars {
@@ -150,10 +150,6 @@ async def _handle_system(subaction: str, device_id: str | None) -> dict[str, Any
f"Invalid subaction '{subaction}' for system. Must be one of: {sorted(_SYSTEM_SUBACTIONS)}" f"Invalid subaction '{subaction}' for system. Must be one of: {sorted(_SYSTEM_SUBACTIONS)}"
) )
if subaction == "connect":
raise ToolError(
"The 'connect' query is not available on this Unraid API version. Use 'settings' instead."
)
if subaction == "ups_device" and not device_id: if subaction == "ups_device" and not device_id:
raise ToolError("device_id is required for system/ups_device") raise ToolError("device_id is required for system/ups_device")
@@ -302,14 +298,13 @@ async def _handle_health(subaction: str, ctx: Context | None) -> dict[str, Any]
CREDENTIALS_ENV_PATH, CREDENTIALS_ENV_PATH,
UNRAID_API_URL, UNRAID_API_URL,
) )
from ..core.setup import elicit_and_configure, elicit_reset_confirmation
from ..core.utils import safe_display_url from ..core.utils import safe_display_url
from ..subscriptions.utils import _analyze_subscription_status from ..subscriptions.utils import _analyze_subscription_status
if subaction == "setup": if subaction == "setup":
if CREDENTIALS_ENV_PATH.exists(): if CREDENTIALS_ENV_PATH.exists():
try: try:
await make_graphql_request("query { online }") await make_graphql_request(_SYSTEM_QUERIES["online"])
connection_ok = True connection_ok = True
except Exception: except Exception:
connection_ok = False connection_ok = False
@@ -343,7 +338,7 @@ async def _handle_health(subaction: str, ctx: Context | None) -> dict[str, Any]
if subaction == "test_connection": if subaction == "test_connection":
start = time.time() start = time.time()
data = await make_graphql_request("query { online }") data = await make_graphql_request(_SYSTEM_QUERIES["online"])
latency = round((time.time() - start) * 1000, 2) latency = round((time.time() - start) * 1000, 2)
return {"status": "connected", "online": data.get("online"), "latency_ms": latency} return {"status": "connected", "online": data.get("online"), "latency_ms": latency}
@@ -351,12 +346,14 @@ async def _handle_health(subaction: str, ctx: Context | None) -> dict[str, Any]
return await _comprehensive_health_check() return await _comprehensive_health_check()
if subaction == "diagnose": if subaction == "diagnose":
from ..server import cache_middleware, error_middleware
from ..subscriptions.manager import subscription_manager from ..subscriptions.manager import subscription_manager
from ..subscriptions.resources import ensure_subscriptions_started from ..subscriptions.resources import ensure_subscriptions_started
await ensure_subscriptions_started() await ensure_subscriptions_started()
status = await subscription_manager.get_subscription_status() status = await subscription_manager.get_subscription_status()
error_count, connection_issues = _analyze_subscription_status(status) error_count, connection_issues = _analyze_subscription_status(status)
cache_stats = cache_middleware.statistics()
return { return {
"timestamp": datetime.datetime.now(datetime.UTC).isoformat(), "timestamp": datetime.datetime.now(datetime.UTC).isoformat(),
"environment": { "environment": {
@@ -372,6 +369,16 @@ async def _handle_health(subaction: str, ctx: Context | None) -> dict[str, Any]
"in_error_state": error_count, "in_error_state": error_count,
"connection_issues": connection_issues, "connection_issues": connection_issues,
}, },
"cache": {
"call_tool": {
"hits": cache_stats.call_tool.get.hit,
"misses": cache_stats.call_tool.get.miss,
"puts": cache_stats.call_tool.put.total,
}
if cache_stats.call_tool
else {"hits": 0, "misses": 0, "puts": 0},
},
"errors": error_middleware.get_error_stats(),
} }
raise ToolError(f"Unhandled health subaction '{subaction}' — this is a bug") raise ToolError(f"Unhandled health subaction '{subaction}' — this is a bug")
@@ -731,6 +738,7 @@ _DOCKER_QUERIES: dict[str, str] = {
"details": "query GetContainerDetails { docker { containers(skipCache: false) { id names image imageId command created ports { ip privatePort publicPort type } sizeRootFs labels state status hostConfig { networkMode } networkSettings mounts autoStart } } }", "details": "query GetContainerDetails { docker { containers(skipCache: false) { id names image imageId command created ports { ip privatePort publicPort type } sizeRootFs labels state status hostConfig { networkMode } networkSettings mounts autoStart } } }",
"networks": "query GetDockerNetworks { docker { networks { id name driver scope } } }", "networks": "query GetDockerNetworks { docker { networks { id name driver scope } } }",
"network_details": "query GetDockerNetwork { docker { networks { id name driver scope enableIPv6 internal attachable containers options labels } } }", "network_details": "query GetDockerNetwork { docker { networks { id name driver scope enableIPv6 internal attachable containers options labels } } }",
"_resolve": "query ResolveContainerID { docker { containers(skipCache: true) { id names } } }",
} }
_DOCKER_MUTATIONS: dict[str, str] = { _DOCKER_MUTATIONS: dict[str, str] = {
@@ -767,9 +775,7 @@ def _find_container(
async def _resolve_container_id(container_id: str, *, strict: bool = False) -> str: async def _resolve_container_id(container_id: str, *, strict: bool = False) -> str:
if _DOCKER_ID_PATTERN.match(container_id): if _DOCKER_ID_PATTERN.match(container_id):
return container_id return container_id
data = await make_graphql_request( data = await make_graphql_request(_DOCKER_QUERIES["_resolve"])
"query { docker { containers(skipCache: true) { id names } } }"
)
containers = safe_get(data, "docker", "containers", default=[]) containers = safe_get(data, "docker", "containers", default=[])
if _DOCKER_SHORT_ID_PATTERN.match(container_id): if _DOCKER_SHORT_ID_PATTERN.match(container_id):
id_lower = container_id.lower() id_lower = container_id.lower()

138
uv.lock generated
View File

@@ -187,59 +187,75 @@ wheels = [
[[package]] [[package]]
name = "charset-normalizer" name = "charset-normalizer"
version = "3.4.5" version = "3.4.6"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1d/35/02daf95b9cd686320bb622eb148792655c9412dbb9b67abb5694e5910a24/charset_normalizer-3.4.5.tar.gz", hash = "sha256:95adae7b6c42a6c5b5b559b1a99149f090a57128155daeea91732c8d970d8644", size = 134804, upload-time = "2026-03-06T06:03:19.46Z" } sdist = { url = "https://files.pythonhosted.org/packages/7b/60/e3bec1881450851b087e301bedc3daa9377a4d45f1c26aa90b0b235e38aa/charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", size = 143363, upload-time = "2026-03-15T18:53:25.478Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/9c/b6/9ee9c1a608916ca5feae81a344dffbaa53b26b90be58cc2159e3332d44ec/charset_normalizer-3.4.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ed97c282ee4f994ef814042423a529df9497e3c666dca19be1d4cd1129dc7ade", size = 280976, upload-time = "2026-03-06T06:01:15.276Z" }, { url = "https://files.pythonhosted.org/packages/e5/62/c0815c992c9545347aeea7859b50dc9044d147e2e7278329c6e02ac9a616/charset_normalizer-3.4.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab", size = 295154, upload-time = "2026-03-15T18:50:50.88Z" },
{ url = "https://files.pythonhosted.org/packages/f8/d8/a54f7c0b96f1df3563e9190f04daf981e365a9b397eedfdfb5dbef7e5c6c/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0294916d6ccf2d069727d65973c3a1ca477d68708db25fd758dd28b0827cff54", size = 189356, upload-time = "2026-03-06T06:01:16.511Z" }, { url = "https://files.pythonhosted.org/packages/a8/37/bdca6613c2e3c58c7421891d80cc3efa1d32e882f7c4a7ee6039c3fc951a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21", size = 199191, upload-time = "2026-03-15T18:50:52.658Z" },
{ url = "https://files.pythonhosted.org/packages/42/69/2bf7f76ce1446759a5787cb87d38f6a61eb47dbbdf035cfebf6347292a65/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dc57a0baa3eeedd99fafaef7511b5a6ef4581494e8168ee086031744e2679467", size = 206369, upload-time = "2026-03-06T06:01:17.853Z" }, { url = "https://files.pythonhosted.org/packages/6c/92/9934d1bbd69f7f398b38c5dae1cbf9cc672e7c34a4adf7b17c0a9c17d15d/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2", size = 218674, upload-time = "2026-03-15T18:50:54.102Z" },
{ url = "https://files.pythonhosted.org/packages/10/9c/949d1a46dab56b959d9a87272482195f1840b515a3380e39986989a893ae/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ed1a9a204f317ef879b32f9af507d47e49cd5e7f8e8d5d96358c98373314fc60", size = 203285, upload-time = "2026-03-06T06:01:19.473Z" }, { url = "https://files.pythonhosted.org/packages/af/90/25f6ab406659286be929fd89ab0e78e38aa183fc374e03aa3c12d730af8a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff", size = 215259, upload-time = "2026-03-15T18:50:55.616Z" },
{ url = "https://files.pythonhosted.org/packages/67/5c/ae30362a88b4da237d71ea214a8c7eb915db3eec941adda511729ac25fa2/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ad83b8f9379176c841f8865884f3514d905bcd2a9a3b210eaa446e7d2223e4d", size = 196274, upload-time = "2026-03-06T06:01:20.728Z" }, { url = "https://files.pythonhosted.org/packages/4e/ef/79a463eb0fff7f96afa04c1d4c51f8fc85426f918db467854bfb6a569ce3/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5", size = 207276, upload-time = "2026-03-15T18:50:57.054Z" },
{ url = "https://files.pythonhosted.org/packages/b2/07/c9f2cb0e46cb6d64fdcc4f95953747b843bb2181bda678dc4e699b8f0f9a/charset_normalizer-3.4.5-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:a118e2e0b5ae6b0120d5efa5f866e58f2bb826067a646431da4d6a2bdae7950e", size = 184715, upload-time = "2026-03-06T06:01:22.194Z" }, { url = "https://files.pythonhosted.org/packages/f7/72/d0426afec4b71dc159fa6b4e68f868cd5a3ecd918fec5813a15d292a7d10/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0", size = 195161, upload-time = "2026-03-15T18:50:58.686Z" },
{ url = "https://files.pythonhosted.org/packages/36/64/6b0ca95c44fddf692cd06d642b28f63009d0ce325fad6e9b2b4d0ef86a52/charset_normalizer-3.4.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:754f96058e61a5e22e91483f823e07df16416ce76afa4ebf306f8e1d1296d43f", size = 193426, upload-time = "2026-03-06T06:01:23.795Z" }, { url = "https://files.pythonhosted.org/packages/bf/18/c82b06a68bfcb6ce55e508225d210c7e6a4ea122bfc0748892f3dc4e8e11/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a", size = 203452, upload-time = "2026-03-15T18:51:00.196Z" },
{ url = "https://files.pythonhosted.org/packages/50/bc/a730690d726403743795ca3f5bb2baf67838c5fea78236098f324b965e40/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0c300cefd9b0970381a46394902cd18eaf2aa00163f999590ace991989dcd0fc", size = 191780, upload-time = "2026-03-06T06:01:25.053Z" }, { url = "https://files.pythonhosted.org/packages/44/d6/0c25979b92f8adafdbb946160348d8d44aa60ce99afdc27df524379875cb/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2", size = 202272, upload-time = "2026-03-15T18:51:01.703Z" },
{ url = "https://files.pythonhosted.org/packages/97/4f/6c0bc9af68222b22951552d73df4532b5be6447cee32d58e7e8c74ecbb7b/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c108f8619e504140569ee7de3f97d234f0fbae338a7f9f360455071ef9855a95", size = 185805, upload-time = "2026-03-06T06:01:26.294Z" }, { url = "https://files.pythonhosted.org/packages/2e/3d/7fea3e8fe84136bebbac715dd1221cc25c173c57a699c030ab9b8900cbb7/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5", size = 195622, upload-time = "2026-03-15T18:51:03.526Z" },
{ url = "https://files.pythonhosted.org/packages/dd/b9/a523fb9b0ee90814b503452b2600e4cbc118cd68714d57041564886e7325/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:d1028de43596a315e2720a9849ee79007ab742c06ad8b45a50db8cdb7ed4a82a", size = 208342, upload-time = "2026-03-06T06:01:27.55Z" }, { url = "https://files.pythonhosted.org/packages/57/8a/d6f7fd5cb96c58ef2f681424fbca01264461336d2a7fc875e4446b1f1346/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6", size = 220056, upload-time = "2026-03-15T18:51:05.269Z" },
{ url = "https://files.pythonhosted.org/packages/4d/61/c59e761dee4464050713e50e27b58266cc8e209e518c0b378c1580c959ba/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:19092dde50335accf365cce21998a1c6dd8eafd42c7b226eb54b2747cdce2fac", size = 193661, upload-time = "2026-03-06T06:01:29.051Z" }, { url = "https://files.pythonhosted.org/packages/16/50/478cdda782c8c9c3fb5da3cc72dd7f331f031e7f1363a893cdd6ca0f8de0/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d", size = 203751, upload-time = "2026-03-15T18:51:06.858Z" },
{ url = "https://files.pythonhosted.org/packages/1c/43/729fa30aad69783f755c5ad8649da17ee095311ca42024742701e202dc59/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4354e401eb6dab9aed3c7b4030514328a6c748d05e1c3e19175008ca7de84fb1", size = 204819, upload-time = "2026-03-06T06:01:30.298Z" }, { url = "https://files.pythonhosted.org/packages/75/fc/cc2fcac943939c8e4d8791abfa139f685e5150cae9f94b60f12520feaa9b/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2", size = 216563, upload-time = "2026-03-15T18:51:08.564Z" },
{ url = "https://files.pythonhosted.org/packages/87/33/d9b442ce5a91b96fc0840455a9e49a611bbadae6122778d0a6a79683dd31/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a68766a3c58fde7f9aaa22b3786276f62ab2f594efb02d0a1421b6282e852e98", size = 198080, upload-time = "2026-03-06T06:01:31.478Z" }, { url = "https://files.pythonhosted.org/packages/a8/b7/a4add1d9a5f68f3d037261aecca83abdb0ab15960a3591d340e829b37298/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923", size = 209265, upload-time = "2026-03-15T18:51:10.312Z" },
{ url = "https://files.pythonhosted.org/packages/56/5a/b8b5a23134978ee9885cee2d6995f4c27cc41f9baded0a9685eabc5338f0/charset_normalizer-3.4.5-cp312-cp312-win32.whl", hash = "sha256:1827734a5b308b65ac54e86a618de66f935a4f63a8a462ff1e19a6788d6c2262", size = 132630, upload-time = "2026-03-06T06:01:33.056Z" }, { url = "https://files.pythonhosted.org/packages/6c/18/c094561b5d64a24277707698e54b7f67bd17a4f857bbfbb1072bba07c8bf/charset_normalizer-3.4.6-cp312-cp312-win32.whl", hash = "sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4", size = 144229, upload-time = "2026-03-15T18:51:11.694Z" },
{ url = "https://files.pythonhosted.org/packages/70/53/e44a4c07e8904500aec95865dc3f6464dc3586a039ef0df606eb3ac38e35/charset_normalizer-3.4.5-cp312-cp312-win_amd64.whl", hash = "sha256:728c6a963dfab66ef865f49286e45239384249672cd598576765acc2a640a636", size = 142856, upload-time = "2026-03-06T06:01:34.489Z" }, { url = "https://files.pythonhosted.org/packages/ab/20/0567efb3a8fd481b8f34f739ebddc098ed062a59fed41a8d193a61939e8f/charset_normalizer-3.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb", size = 154277, upload-time = "2026-03-15T18:51:13.004Z" },
{ url = "https://files.pythonhosted.org/packages/ea/aa/c5628f7cad591b1cf45790b7a61483c3e36cf41349c98af7813c483fd6e8/charset_normalizer-3.4.5-cp312-cp312-win_arm64.whl", hash = "sha256:75dfd1afe0b1647449e852f4fb428195a7ed0588947218f7ba929f6538487f02", size = 132982, upload-time = "2026-03-06T06:01:35.641Z" }, { url = "https://files.pythonhosted.org/packages/15/57/28d79b44b51933119e21f65479d0864a8d5893e494cf5daab15df0247c17/charset_normalizer-3.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4", size = 142817, upload-time = "2026-03-15T18:51:14.408Z" },
{ url = "https://files.pythonhosted.org/packages/f5/48/9f34ec4bb24aa3fdba1890c1bddb97c8a4be1bd84ef5c42ac2352563ad05/charset_normalizer-3.4.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac59c15e3f1465f722607800c68713f9fbc2f672b9eb649fe831da4019ae9b23", size = 280788, upload-time = "2026-03-06T06:01:37.126Z" }, { url = "https://files.pythonhosted.org/packages/1e/1d/4fdabeef4e231153b6ed7567602f3b68265ec4e5b76d6024cf647d43d981/charset_normalizer-3.4.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f", size = 294823, upload-time = "2026-03-15T18:51:15.755Z" },
{ url = "https://files.pythonhosted.org/packages/0e/09/6003e7ffeb90cc0560da893e3208396a44c210c5ee42efff539639def59b/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:165c7b21d19365464e8f70e5ce5e12524c58b48c78c1f5a57524603c1ab003f8", size = 188890, upload-time = "2026-03-06T06:01:38.73Z" }, { url = "https://files.pythonhosted.org/packages/47/7b/20e809b89c69d37be748d98e84dce6820bf663cf19cf6b942c951a3e8f41/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843", size = 198527, upload-time = "2026-03-15T18:51:17.177Z" },
{ url = "https://files.pythonhosted.org/packages/42/1e/02706edf19e390680daa694d17e2b8eab4b5f7ac285e2a51168b4b22ee6b/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:28269983f25a4da0425743d0d257a2d6921ea7d9b83599d4039486ec5b9f911d", size = 206136, upload-time = "2026-03-06T06:01:40.016Z" }, { url = "https://files.pythonhosted.org/packages/37/a6/4f8d27527d59c039dce6f7622593cdcd3d70a8504d87d09eb11e9fdc6062/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf", size = 218388, upload-time = "2026-03-15T18:51:18.934Z" },
{ url = "https://files.pythonhosted.org/packages/c7/87/942c3def1b37baf3cf786bad01249190f3ca3d5e63a84f831e704977de1f/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d27ce22ec453564770d29d03a9506d449efbb9fa13c00842262b2f6801c48cce", size = 202551, upload-time = "2026-03-06T06:01:41.522Z" }, { url = "https://files.pythonhosted.org/packages/f6/9b/4770ccb3e491a9bacf1c46cc8b812214fe367c86a96353ccc6daf87b01ec/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8", size = 214563, upload-time = "2026-03-15T18:51:20.374Z" },
{ url = "https://files.pythonhosted.org/packages/94/0a/af49691938dfe175d71b8a929bd7e4ace2809c0c5134e28bc535660d5262/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0625665e4ebdddb553ab185de5db7054393af8879fb0c87bd5690d14379d6819", size = 195572, upload-time = "2026-03-06T06:01:43.208Z" }, { url = "https://files.pythonhosted.org/packages/2b/58/a199d245894b12db0b957d627516c78e055adc3a0d978bc7f65ddaf7c399/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9", size = 206587, upload-time = "2026-03-15T18:51:21.807Z" },
{ url = "https://files.pythonhosted.org/packages/20/ea/dfb1792a8050a8e694cfbde1570ff97ff74e48afd874152d38163d1df9ae/charset_normalizer-3.4.5-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:c23eb3263356d94858655b3e63f85ac5d50970c6e8febcdde7830209139cc37d", size = 184438, upload-time = "2026-03-06T06:01:44.755Z" }, { url = "https://files.pythonhosted.org/packages/7e/70/3def227f1ec56f5c69dfc8392b8bd63b11a18ca8178d9211d7cc5e5e4f27/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88", size = 194724, upload-time = "2026-03-15T18:51:23.508Z" },
{ url = "https://files.pythonhosted.org/packages/72/12/c281e2067466e3ddd0595bfaea58a6946765ace5c72dfa3edc2f5f118026/charset_normalizer-3.4.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e6302ca4ae283deb0af68d2fbf467474b8b6aedcd3dab4db187e07f94c109763", size = 193035, upload-time = "2026-03-06T06:01:46.051Z" }, { url = "https://files.pythonhosted.org/packages/58/ab/9318352e220c05efd31c2779a23b50969dc94b985a2efa643ed9077bfca5/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84", size = 202956, upload-time = "2026-03-15T18:51:25.239Z" },
{ url = "https://files.pythonhosted.org/packages/ba/4f/3792c056e7708e10464bad0438a44708886fb8f92e3c3d29ec5e2d964d42/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e51ae7d81c825761d941962450f50d041db028b7278e7b08930b4541b3e45cb9", size = 191340, upload-time = "2026-03-06T06:01:47.547Z" }, { url = "https://files.pythonhosted.org/packages/75/13/f3550a3ac25b70f87ac98c40d3199a8503676c2f1620efbf8d42095cfc40/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd", size = 201923, upload-time = "2026-03-15T18:51:26.682Z" },
{ url = "https://files.pythonhosted.org/packages/e7/86/80ddba897127b5c7a9bccc481b0cd36c8fefa485d113262f0fe4332f0bf4/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:597d10dec876923e5c59e48dbd366e852eacb2b806029491d307daea6b917d7c", size = 185464, upload-time = "2026-03-06T06:01:48.764Z" }, { url = "https://files.pythonhosted.org/packages/1b/db/c5c643b912740b45e8eec21de1bbab8e7fc085944d37e1e709d3dcd9d72f/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c", size = 195366, upload-time = "2026-03-15T18:51:28.129Z" },
{ url = "https://files.pythonhosted.org/packages/4d/00/b5eff85ba198faacab83e0e4b6f0648155f072278e3b392a82478f8b988b/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5cffde4032a197bd3b42fd0b9509ec60fb70918d6970e4cc773f20fc9180ca67", size = 208014, upload-time = "2026-03-06T06:01:50.371Z" }, { url = "https://files.pythonhosted.org/packages/5a/67/3b1c62744f9b2448443e0eb160d8b001c849ec3fef591e012eda6484787c/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194", size = 219752, upload-time = "2026-03-15T18:51:29.556Z" },
{ url = "https://files.pythonhosted.org/packages/c8/11/d36f70be01597fd30850dde8a1269ebc8efadd23ba5785808454f2389bde/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2da4eedcb6338e2321e831a0165759c0c620e37f8cd044a263ff67493be8ffb3", size = 193297, upload-time = "2026-03-06T06:01:51.933Z" }, { url = "https://files.pythonhosted.org/packages/f6/98/32ffbaf7f0366ffb0445930b87d103f6b406bc2c271563644bde8a2b1093/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc", size = 203296, upload-time = "2026-03-15T18:51:30.921Z" },
{ url = "https://files.pythonhosted.org/packages/1a/1d/259eb0a53d4910536c7c2abb9cb25f4153548efb42800c6a9456764649c0/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:65a126fb4b070d05340a84fc709dd9e7c75d9b063b610ece8a60197a291d0adf", size = 204321, upload-time = "2026-03-06T06:01:53.887Z" }, { url = "https://files.pythonhosted.org/packages/41/12/5d308c1bbe60cabb0c5ef511574a647067e2a1f631bc8634fcafaccd8293/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f", size = 215956, upload-time = "2026-03-15T18:51:32.399Z" },
{ url = "https://files.pythonhosted.org/packages/84/31/faa6c5b9d3688715e1ed1bb9d124c384fe2fc1633a409e503ffe1c6398c1/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7a80a9242963416bd81f99349d5f3fce1843c303bd404f204918b6d75a75fd6", size = 197509, upload-time = "2026-03-06T06:01:56.439Z" }, { url = "https://files.pythonhosted.org/packages/53/e9/5f85f6c5e20669dbe56b165c67b0260547dea97dba7e187938833d791687/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2", size = 208652, upload-time = "2026-03-15T18:51:34.214Z" },
{ url = "https://files.pythonhosted.org/packages/fd/a5/c7d9dd1503ffc08950b3260f5d39ec2366dd08254f0900ecbcf3a6197c7c/charset_normalizer-3.4.5-cp313-cp313-win32.whl", hash = "sha256:f1d725b754e967e648046f00c4facc42d414840f5ccc670c5670f59f83693e4f", size = 132284, upload-time = "2026-03-06T06:01:57.812Z" }, { url = "https://files.pythonhosted.org/packages/f1/11/897052ea6af56df3eef3ca94edafee410ca699ca0c7b87960ad19932c55e/charset_normalizer-3.4.6-cp313-cp313-win32.whl", hash = "sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d", size = 143940, upload-time = "2026-03-15T18:51:36.15Z" },
{ url = "https://files.pythonhosted.org/packages/b9/0f/57072b253af40c8aa6636e6de7d75985624c1eb392815b2f934199340a89/charset_normalizer-3.4.5-cp313-cp313-win_amd64.whl", hash = "sha256:e37bd100d2c5d3ba35db9c7c5ba5a9228cbcffe5c4778dc824b164e5257813d7", size = 142630, upload-time = "2026-03-06T06:01:59.062Z" }, { url = "https://files.pythonhosted.org/packages/a1/5c/724b6b363603e419829f561c854b87ed7c7e31231a7908708ac086cdf3e2/charset_normalizer-3.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389", size = 154101, upload-time = "2026-03-15T18:51:37.876Z" },
{ url = "https://files.pythonhosted.org/packages/31/41/1c4b7cc9f13bd9d369ce3bc993e13d374ce25fa38a2663644283ecf422c1/charset_normalizer-3.4.5-cp313-cp313-win_arm64.whl", hash = "sha256:93b3b2cc5cf1b8743660ce77a4f45f3f6d1172068207c1defc779a36eea6bb36", size = 133254, upload-time = "2026-03-06T06:02:00.281Z" }, { url = "https://files.pythonhosted.org/packages/01/a5/7abf15b4c0968e47020f9ca0935fb3274deb87cb288cd187cad92e8cdffd/charset_normalizer-3.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f", size = 143109, upload-time = "2026-03-15T18:51:39.565Z" },
{ url = "https://files.pythonhosted.org/packages/43/be/0f0fd9bb4a7fa4fb5067fb7d9ac693d4e928d306f80a0d02bde43a7c4aee/charset_normalizer-3.4.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8197abe5ca1ffb7d91e78360f915eef5addff270f8a71c1fc5be24a56f3e4873", size = 280232, upload-time = "2026-03-06T06:02:01.508Z" }, { url = "https://files.pythonhosted.org/packages/25/6f/ffe1e1259f384594063ea1869bfb6be5cdb8bc81020fc36c3636bc8302a1/charset_normalizer-3.4.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8", size = 294458, upload-time = "2026-03-15T18:51:41.134Z" },
{ url = "https://files.pythonhosted.org/packages/28/02/983b5445e4bef49cd8c9da73a8e029f0825f39b74a06d201bfaa2e55142a/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2aecdb364b8a1802afdc7f9327d55dad5366bc97d8502d0f5854e50712dbc5f", size = 189688, upload-time = "2026-03-06T06:02:02.857Z" }, { url = "https://files.pythonhosted.org/packages/56/60/09bb6c13a8c1016c2ed5c6a6488e4ffef506461aa5161662bd7636936fb1/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421", size = 199277, upload-time = "2026-03-15T18:51:42.953Z" },
{ url = "https://files.pythonhosted.org/packages/d0/88/152745c5166437687028027dc080e2daed6fe11cfa95a22f4602591c42db/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a66aa5022bf81ab4b1bebfb009db4fd68e0c6d4307a1ce5ef6a26e5878dfc9e4", size = 206833, upload-time = "2026-03-06T06:02:05.127Z" }, { url = "https://files.pythonhosted.org/packages/00/50/dcfbb72a5138bbefdc3332e8d81a23494bf67998b4b100703fd15fa52d81/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2", size = 218758, upload-time = "2026-03-15T18:51:44.339Z" },
{ url = "https://files.pythonhosted.org/packages/cb/0f/ebc15c8b02af2f19be9678d6eed115feeeccc45ce1f4b098d986c13e8769/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d77f97e515688bd615c1d1f795d540f32542d514242067adcb8ef532504cb9ee", size = 202879, upload-time = "2026-03-06T06:02:06.446Z" }, { url = "https://files.pythonhosted.org/packages/03/b3/d79a9a191bb75f5aa81f3aaaa387ef29ce7cb7a9e5074ba8ea095cc073c2/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30", size = 215299, upload-time = "2026-03-15T18:51:45.871Z" },
{ url = "https://files.pythonhosted.org/packages/38/9c/71336bff6934418dc8d1e8a1644176ac9088068bc571da612767619c97b3/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01a1ed54b953303ca7e310fafe0fe347aab348bd81834a0bcd602eb538f89d66", size = 195764, upload-time = "2026-03-06T06:02:08.763Z" }, { url = "https://files.pythonhosted.org/packages/76/7e/bc8911719f7084f72fd545f647601ea3532363927f807d296a8c88a62c0d/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db", size = 206811, upload-time = "2026-03-15T18:51:47.308Z" },
{ url = "https://files.pythonhosted.org/packages/b7/95/ce92fde4f98615661871bc282a856cf9b8a15f686ba0af012984660d480b/charset_normalizer-3.4.5-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:b2d37d78297b39a9eb9eb92c0f6df98c706467282055419df141389b23f93362", size = 183728, upload-time = "2026-03-06T06:02:10.137Z" }, { url = "https://files.pythonhosted.org/packages/e2/40/c430b969d41dda0c465aa36cc7c2c068afb67177bef50905ac371b28ccc7/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8", size = 193706, upload-time = "2026-03-15T18:51:48.849Z" },
{ url = "https://files.pythonhosted.org/packages/1c/e7/f5b4588d94e747ce45ae680f0f242bc2d98dbd4eccfab73e6160b6893893/charset_normalizer-3.4.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e71bbb595973622b817c042bd943c3f3667e9c9983ce3d205f973f486fec98a7", size = 192937, upload-time = "2026-03-06T06:02:11.663Z" }, { url = "https://files.pythonhosted.org/packages/48/15/e35e0590af254f7df984de1323640ef375df5761f615b6225ba8deb9799a/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815", size = 202706, upload-time = "2026-03-15T18:51:50.257Z" },
{ url = "https://files.pythonhosted.org/packages/f9/29/9d94ed6b929bf9f48bf6ede6e7474576499f07c4c5e878fb186083622716/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4cd966c2559f501c6fd69294d082c2934c8dd4719deb32c22961a5ac6db0df1d", size = 192040, upload-time = "2026-03-06T06:02:13.489Z" }, { url = "https://files.pythonhosted.org/packages/5e/bd/f736f7b9cc5e93a18b794a50346bb16fbfd6b37f99e8f306f7951d27c17c/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a", size = 202497, upload-time = "2026-03-15T18:51:52.012Z" },
{ url = "https://files.pythonhosted.org/packages/15/d2/1a093a1cf827957f9445f2fe7298bcc16f8fc5e05c1ed2ad1af0b239035e/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d5e52d127045d6ae01a1e821acfad2f3a1866c54d0e837828538fabe8d9d1bd6", size = 184107, upload-time = "2026-03-06T06:02:14.83Z" }, { url = "https://files.pythonhosted.org/packages/9d/ba/2cc9e3e7dfdf7760a6ed8da7446d22536f3d0ce114ac63dee2a5a3599e62/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43", size = 193511, upload-time = "2026-03-15T18:51:53.723Z" },
{ url = "https://files.pythonhosted.org/packages/0f/7d/82068ce16bd36135df7b97f6333c5d808b94e01d4599a682e2337ed5fd14/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:30a2b1a48478c3428d047ed9690d57c23038dac838a87ad624c85c0a78ebeb39", size = 208310, upload-time = "2026-03-06T06:02:16.165Z" }, { url = "https://files.pythonhosted.org/packages/9e/cb/5be49b5f776e5613be07298c80e1b02a2d900f7a7de807230595c85a8b2e/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0", size = 220133, upload-time = "2026-03-15T18:51:55.333Z" },
{ url = "https://files.pythonhosted.org/packages/84/4e/4dfb52307bb6af4a5c9e73e482d171b81d36f522b21ccd28a49656baa680/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d8ed79b8f6372ca4254955005830fd61c1ccdd8c0fac6603e2c145c61dd95db6", size = 192918, upload-time = "2026-03-06T06:02:18.144Z" }, { url = "https://files.pythonhosted.org/packages/83/43/99f1b5dad345accb322c80c7821071554f791a95ee50c1c90041c157ae99/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1", size = 203035, upload-time = "2026-03-15T18:51:56.736Z" },
{ url = "https://files.pythonhosted.org/packages/08/a4/159ff7da662cf7201502ca89980b8f06acf3e887b278956646a8aeb178ab/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c5af897b45fa606b12464ccbe0014bbf8c09191e0a66aab6aa9d5cf6e77e0c94", size = 204615, upload-time = "2026-03-06T06:02:19.821Z" }, { url = "https://files.pythonhosted.org/packages/87/9a/62c2cb6a531483b55dddff1a68b3d891a8b498f3ca555fbcf2978e804d9d/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f", size = 216321, upload-time = "2026-03-15T18:51:58.17Z" },
{ url = "https://files.pythonhosted.org/packages/d6/62/0dd6172203cb6b429ffffc9935001fde42e5250d57f07b0c28c6046deb6b/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1088345bcc93c58d8d8f3d783eca4a6e7a7752bbff26c3eee7e73c597c191c2e", size = 197784, upload-time = "2026-03-06T06:02:21.86Z" }, { url = "https://files.pythonhosted.org/packages/6e/79/94a010ff81e3aec7c293eb82c28f930918e517bc144c9906a060844462eb/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815", size = 208973, upload-time = "2026-03-15T18:51:59.998Z" },
{ url = "https://files.pythonhosted.org/packages/c7/5e/1aab5cb737039b9c59e63627dc8bbc0d02562a14f831cc450e5f91d84ce1/charset_normalizer-3.4.5-cp314-cp314-win32.whl", hash = "sha256:ee57b926940ba00bca7ba7041e665cc956e55ef482f851b9b65acb20d867e7a2", size = 133009, upload-time = "2026-03-06T06:02:23.289Z" }, { url = "https://files.pythonhosted.org/packages/2a/57/4ecff6d4ec8585342f0c71bc03efaa99cb7468f7c91a57b105bcd561cea8/charset_normalizer-3.4.6-cp314-cp314-win32.whl", hash = "sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d", size = 144610, upload-time = "2026-03-15T18:52:02.213Z" },
{ url = "https://files.pythonhosted.org/packages/40/65/e7c6c77d7aaa4c0d7974f2e403e17f0ed2cb0fc135f77d686b916bf1eead/charset_normalizer-3.4.5-cp314-cp314-win_amd64.whl", hash = "sha256:4481e6da1830c8a1cc0b746b47f603b653dadb690bcd851d039ffaefe70533aa", size = 143511, upload-time = "2026-03-06T06:02:26.195Z" }, { url = "https://files.pythonhosted.org/packages/80/94/8434a02d9d7f168c25767c64671fead8d599744a05d6a6c877144c754246/charset_normalizer-3.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f", size = 154962, upload-time = "2026-03-15T18:52:03.658Z" },
{ url = "https://files.pythonhosted.org/packages/ba/91/52b0841c71f152f563b8e072896c14e3d83b195c188b338d3cc2e582d1d4/charset_normalizer-3.4.5-cp314-cp314-win_arm64.whl", hash = "sha256:97ab7787092eb9b50fb47fa04f24c75b768a606af1bcba1957f07f128a7219e4", size = 133775, upload-time = "2026-03-06T06:02:27.473Z" }, { url = "https://files.pythonhosted.org/packages/46/4c/48f2cdbfd923026503dfd67ccea45c94fd8fe988d9056b468579c66ed62b/charset_normalizer-3.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e", size = 143595, upload-time = "2026-03-15T18:52:05.123Z" },
{ url = "https://files.pythonhosted.org/packages/c5/60/3a621758945513adfd4db86827a5bafcc615f913dbd0b4c2ed64a65731be/charset_normalizer-3.4.5-py3-none-any.whl", hash = "sha256:9db5e3fcdcee89a78c04dffb3fe33c79f77bd741a624946db2591c81b2fc85b0", size = 55455, upload-time = "2026-03-06T06:03:17.827Z" }, { url = "https://files.pythonhosted.org/packages/31/93/8878be7569f87b14f1d52032946131bcb6ebbd8af3e20446bc04053dc3f1/charset_normalizer-3.4.6-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866", size = 314828, upload-time = "2026-03-15T18:52:06.831Z" },
{ url = "https://files.pythonhosted.org/packages/06/b6/fae511ca98aac69ecc35cde828b0a3d146325dd03d99655ad38fc2cc3293/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc", size = 208138, upload-time = "2026-03-15T18:52:08.239Z" },
{ url = "https://files.pythonhosted.org/packages/54/57/64caf6e1bf07274a1e0b7c160a55ee9e8c9ec32c46846ce59b9c333f7008/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e", size = 224679, upload-time = "2026-03-15T18:52:10.043Z" },
{ url = "https://files.pythonhosted.org/packages/aa/cb/9ff5a25b9273ef160861b41f6937f86fae18b0792fe0a8e75e06acb08f1d/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077", size = 223475, upload-time = "2026-03-15T18:52:11.854Z" },
{ url = "https://files.pythonhosted.org/packages/fc/97/440635fc093b8d7347502a377031f9605a1039c958f3cd18dcacffb37743/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f", size = 215230, upload-time = "2026-03-15T18:52:13.325Z" },
{ url = "https://files.pythonhosted.org/packages/cd/24/afff630feb571a13f07c8539fbb502d2ab494019492aaffc78ef41f1d1d0/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e", size = 199045, upload-time = "2026-03-15T18:52:14.752Z" },
{ url = "https://files.pythonhosted.org/packages/e5/17/d1399ecdaf7e0498c327433e7eefdd862b41236a7e484355b8e0e5ebd64b/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484", size = 211658, upload-time = "2026-03-15T18:52:16.278Z" },
{ url = "https://files.pythonhosted.org/packages/b5/38/16baa0affb957b3d880e5ac2144caf3f9d7de7bc4a91842e447fbb5e8b67/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7", size = 210769, upload-time = "2026-03-15T18:52:17.782Z" },
{ url = "https://files.pythonhosted.org/packages/05/34/c531bc6ac4c21da9ddfddb3107be2287188b3ea4b53b70fc58f2a77ac8d8/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff", size = 201328, upload-time = "2026-03-15T18:52:19.553Z" },
{ url = "https://files.pythonhosted.org/packages/fa/73/a5a1e9ca5f234519c1953608a03fe109c306b97fdfb25f09182babad51a7/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e", size = 225302, upload-time = "2026-03-15T18:52:21.043Z" },
{ url = "https://files.pythonhosted.org/packages/ba/f6/cd782923d112d296294dea4bcc7af5a7ae0f86ab79f8fefbda5526b6cfc0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659", size = 211127, upload-time = "2026-03-15T18:52:22.491Z" },
{ url = "https://files.pythonhosted.org/packages/0e/c5/0b6898950627af7d6103a449b22320372c24c6feda91aa24e201a478d161/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602", size = 222840, upload-time = "2026-03-15T18:52:24.113Z" },
{ url = "https://files.pythonhosted.org/packages/7d/25/c4bba773bef442cbdc06111d40daa3de5050a676fa26e85090fc54dd12f0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407", size = 216890, upload-time = "2026-03-15T18:52:25.541Z" },
{ url = "https://files.pythonhosted.org/packages/35/1a/05dacadb0978da72ee287b0143097db12f2e7e8d3ffc4647da07a383b0b7/charset_normalizer-3.4.6-cp314-cp314t-win32.whl", hash = "sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579", size = 155379, upload-time = "2026-03-15T18:52:27.05Z" },
{ url = "https://files.pythonhosted.org/packages/5d/7a/d269d834cb3a76291651256f3b9a5945e81d0a49ab9f4a498964e83c0416/charset_normalizer-3.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4", size = 169043, upload-time = "2026-03-15T18:52:28.502Z" },
{ url = "https://files.pythonhosted.org/packages/23/06/28b29fba521a37a8932c6a84192175c34d49f84a6d4773fa63d05f9aff22/charset_normalizer-3.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c", size = 148523, upload-time = "2026-03-15T18:52:29.956Z" },
{ url = "https://files.pythonhosted.org/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", size = 61455, upload-time = "2026-03-15T18:53:23.833Z" },
] ]
[[package]] [[package]]
@@ -402,7 +418,7 @@ wheels = [
[[package]] [[package]]
name = "cyclopts" name = "cyclopts"
version = "4.9.0" version = "4.10.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "attrs" }, { name = "attrs" },
@@ -410,9 +426,9 @@ dependencies = [
{ name = "rich" }, { name = "rich" },
{ name = "rich-rst" }, { name = "rich-rst" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/75/de/75598ddea1f47589ccecdb23a560715a5a8ec2b3e34396b5628ba98d70e4/cyclopts-4.9.0.tar.gz", hash = "sha256:f292868e4be33a3e622d8cf95d89f49222e987b1ccdbf40caf6514e19dd99a63", size = 166300, upload-time = "2026-03-13T13:43:40.38Z" } sdist = { url = "https://files.pythonhosted.org/packages/2c/e7/3e26855c046ac527cf94d890f6698e703980337f22ea7097e02b35b910f9/cyclopts-4.10.0.tar.gz", hash = "sha256:0ae04a53274e200ef3477c8b54de63b019bc6cd0162d75c718bf40c9c3fb5268", size = 166394, upload-time = "2026-03-14T14:09:31.043Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/b2/2e342a876e5b78ce99ecf65ce391f5b2935144a0528c9989c437b8578a54/cyclopts-4.9.0-py3-none-any.whl", hash = "sha256:583ea4090a040c92f9303bc0da26bca7b681c81bcea34097ace279e1acef22c1", size = 203999, upload-time = "2026-03-13T13:43:38.553Z" }, { url = "https://files.pythonhosted.org/packages/06/06/d68a5d5d292c2ad2bc6a02e5ca2cb1bb9c15e941ab02f004a06a342d7f0f/cyclopts-4.10.0-py3-none-any.whl", hash = "sha256:50f333382a60df8d40ec14aa2e627316b361c4f478598ada1f4169d959bf9ea7", size = 204097, upload-time = "2026-03-14T14:09:32.504Z" },
] ]
[[package]] [[package]]
@@ -485,7 +501,7 @@ wheels = [
[[package]] [[package]]
name = "fastmcp" name = "fastmcp"
version = "3.1.0" version = "3.1.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "authlib" }, { name = "authlib" },
@@ -510,9 +526,9 @@ dependencies = [
{ name = "watchfiles" }, { name = "watchfiles" },
{ name = "websockets" }, { name = "websockets" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/0a/70/862026c4589441f86ad3108f05bfb2f781c6b322ad60a982f40b303b47d7/fastmcp-3.1.0.tar.gz", hash = "sha256:e25264794c734b9977502a51466961eeecff92a0c2f3b49c40c070993628d6d0", size = 17347083, upload-time = "2026-03-03T02:43:11.283Z" } sdist = { url = "https://files.pythonhosted.org/packages/25/83/c95d3bf717698a693eccb43e137a32939d2549876e884e246028bff6ecce/fastmcp-3.1.1.tar.gz", hash = "sha256:db184b5391a31199323766a3abf3a8bfbb8010479f77eca84c0e554f18655c48", size = 17347644, upload-time = "2026-03-14T19:12:20.235Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/17/07/516f5b20d88932e5a466c2216b628e5358a71b3a9f522215607c3281de05/fastmcp-3.1.0-py3-none-any.whl", hash = "sha256:b1f73b56fd3b0cb2bd9e2a144fc650d5cc31587ed129d996db7710e464ae8010", size = 633749, upload-time = "2026-03-03T02:43:09.06Z" }, { url = "https://files.pythonhosted.org/packages/70/ea/570122de7e24f72138d006f799768e14cc1ccf7fcb22b7750b2bd276c711/fastmcp-3.1.1-py3-none-any.whl", hash = "sha256:8132ba069d89f14566b3266919d6d72e2ec23dd45d8944622dca407e9beda7eb", size = 633754, upload-time = "2026-03-14T19:12:22.736Z" },
] ]
[[package]] [[package]]
@@ -1585,7 +1601,7 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "fastapi", specifier = ">=0.115.0" }, { name = "fastapi", specifier = ">=0.115.0" },
{ name = "fastmcp", specifier = ">=2.14.5" }, { name = "fastmcp", specifier = ">=3.0.0" },
{ name = "httpx", specifier = ">=0.28.1" }, { name = "httpx", specifier = ">=0.28.1" },
{ name = "python-dotenv", specifier = ">=1.1.1" }, { name = "python-dotenv", specifier = ">=1.1.1" },
{ name = "rich", specifier = ">=14.1.0" }, { name = "rich", specifier = ">=14.1.0" },
@@ -1618,15 +1634,15 @@ wheels = [
[[package]] [[package]]
name = "uvicorn" name = "uvicorn"
version = "0.41.0" version = "0.42.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "click" }, { name = "click" },
{ name = "h11" }, { name = "h11" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/32/ce/eeb58ae4ac36fe09e3842eb02e0eb676bf2c53ae062b98f1b2531673efdd/uvicorn-0.41.0.tar.gz", hash = "sha256:09d11cf7008da33113824ee5a1c6422d89fbc2ff476540d69a34c87fab8b571a", size = 82633, upload-time = "2026-02-16T23:07:24.1Z" } sdist = { url = "https://files.pythonhosted.org/packages/e3/ad/4a96c425be6fb67e0621e62d86c402b4a17ab2be7f7c055d9bd2f638b9e2/uvicorn-0.42.0.tar.gz", hash = "sha256:9b1f190ce15a2dd22e7758651d9b6d12df09a13d51ba5bf4fc33c383a48e1775", size = 85393, upload-time = "2026-03-16T06:19:50.077Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl", hash = "sha256:29e35b1d2c36a04b9e180d4007ede3bcb32a85fbdfd6c6aeb3f26839de088187", size = 68783, upload-time = "2026-02-16T23:07:22.357Z" }, { url = "https://files.pythonhosted.org/packages/0a/89/f8827ccff89c1586027a105e5630ff6139a64da2515e24dafe860bd9ae4d/uvicorn-0.42.0-py3-none-any.whl", hash = "sha256:96c30f5c7abe6f74ae8900a70e92b85ad6613b745d4879eb9b16ccad15645359", size = 68830, upload-time = "2026-03-16T06:19:48.325Z" },
] ]
[package.optional-dependencies] [package.optional-dependencies]