Enable more type checking rules (#1775)

This commit is contained in:
William Easton 2025-09-07 10:11:44 -05:00 committed by GitHub
commit fe4f31c2c7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 404 additions and 177 deletions

View file

@ -129,11 +129,9 @@ python-version = "3.10"
# Rules with too many errors to fix right now (40+ each)
no-matching-overload = "ignore" # 126 errors
unknown-argument = "ignore" # 61 errors
unresolved-attribute = "ignore" # 60 errors
# Rules with moderate errors that need more investigation
call-non-callable = "ignore" # 7 errors
missing-argument = "ignore" # 23 errors
[tool.ruff.lint]
extend-select = ["I", "UP"]

View file

@ -505,7 +505,7 @@ class Client(Generic[ClientTransportT]):
) -> None:
"""Send a cancellation notification for an in-progress request."""
notification = mcp.types.ClientNotification(
mcp.types.CancelledNotification(
root=mcp.types.CancelledNotification(
method="notifications/cancelled",
params=mcp.types.CancelledNotificationParams(
requestId=request_id,
@ -743,13 +743,13 @@ class Client(Generic[ClientTransportT]):
async def complete_mcp(
self,
ref: mcp.types.ResourceReference | mcp.types.PromptReference,
ref: mcp.types.ResourceTemplateReference | mcp.types.PromptReference,
argument: dict[str, str],
) -> mcp.types.CompleteResult:
"""Send a completion request and return the complete MCP protocol result.
Args:
ref (mcp.types.ResourceReference | mcp.types.PromptReference): The reference to complete.
ref (mcp.types.ResourceTemplateReference | mcp.types.PromptReference): The reference to complete.
argument (dict[str, str]): Arguments to pass to the completion request.
Returns:
@ -766,13 +766,13 @@ class Client(Generic[ClientTransportT]):
async def complete(
self,
ref: mcp.types.ResourceReference | mcp.types.PromptReference,
ref: mcp.types.ResourceTemplateReference | mcp.types.PromptReference,
argument: dict[str, str],
) -> mcp.types.Completion:
"""Send a completion request to the server.
Args:
ref (mcp.types.ResourceReference | mcp.types.PromptReference): The reference to complete.
ref (mcp.types.ResourceTemplateReference | mcp.types.PromptReference): The reference to complete.
argument (dict[str, str]): Arguments to pass to the completion request.
Returns:

View file

@ -59,7 +59,12 @@ def create_elicitation_callback(
"Elicitation responses must be serializable as a JSON object (dict). Received: "
f"{result.content!r}"
)
return MCPElicitResult(**result.model_dump() | {"content": content})
return MCPElicitResult(
_meta=result.meta,
action=result.action,
content=content,
)
except Exception as e:
return mcp.types.ErrorData(
code=mcp.types.INTERNAL_ERROR,

View file

@ -8,6 +8,7 @@ from mcp.types import ToolAnnotations
from fastmcp.prompts.prompt import Prompt
from fastmcp.resources.resource import Resource
from fastmcp.tools.tool import Tool
from fastmcp.utilities.types import get_fn_name
if TYPE_CHECKING:
from fastmcp.server import FastMCP
@ -34,7 +35,7 @@ def mcp_tool(
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
call_args = {
"name": name or func.__name__,
"name": name or get_fn_name(func),
"description": description,
"tags": tags,
"annotations": annotations,
@ -63,7 +64,7 @@ def mcp_resource(
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
call_args = {
"uri": uri,
"name": name or func.__name__,
"name": name or get_fn_name(func),
"description": description,
"mime_type": mime_type,
"tags": tags,
@ -88,7 +89,7 @@ def mcp_prompt(
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
call_args = {
"name": name or func.__name__,
"name": name or get_fn_name(func),
"description": description,
"tags": tags,
"enabled": enabled,
@ -146,7 +147,21 @@ class MCPMixin:
registration_info["name"] = (
f"{prefix}{separator}{registration_info['name']}"
)
tool = Tool.from_function(fn=method, **registration_info)
tool = Tool.from_function(
fn=method,
name=registration_info.get("name"),
title=registration_info.get("title"),
description=registration_info.get("description"),
tags=registration_info.get("tags"),
annotations=registration_info.get("annotations"),
exclude_args=registration_info.get("exclude_args"),
serializer=registration_info.get("serializer"),
output_schema=registration_info.get("output_schema"),
meta=registration_info.get("meta"),
enabled=registration_info.get("enabled"),
)
mcp_server.add_tool(tool)
def register_resources(
@ -175,7 +190,19 @@ class MCPMixin:
registration_info["uri"] = (
f"{prefix}{separator}{registration_info['uri']}"
)
resource = Resource.from_function(fn=method, **registration_info)
resource = Resource.from_function(
fn=method,
uri=registration_info["uri"],
name=registration_info.get("name"),
description=registration_info.get("description"),
mime_type=registration_info.get("mime_type"),
tags=registration_info.get("tags"),
enabled=registration_info.get("enabled"),
annotations=registration_info.get("annotations"),
meta=registration_info.get("meta"),
)
mcp_server.add_resource(resource)
def register_prompts(
@ -200,7 +227,15 @@ class MCPMixin:
registration_info["name"] = (
f"{prefix}{separator}{registration_info['name']}"
)
prompt = Prompt.from_function(fn=method, **registration_info)
prompt = Prompt.from_function(
fn=method,
name=registration_info.get("name"),
title=registration_info.get("title"),
description=registration_info.get("description"),
tags=registration_info.get("tags"),
enabled=registration_info.get("enabled"),
meta=registration_info.get("meta"),
)
mcp_server.add_prompt(prompt)
def register_all(

View file

@ -69,7 +69,14 @@ class RequestDirector:
request_data["content"] = body
# Step 5: Create httpx.Request
return httpx.Request(**{k: v for k, v in request_data.items() if v is not None})
return httpx.Request(
method=request_data["method"],
url=request_data["url"],
params=request_data.get("params"),
headers=request_data.get("headers"),
json=request_data.get("json"),
content=request_data.get("content"),
)
def _unflatten_arguments(
self, route: HTTPRoute, flat_args: dict[str, Any]

View file

@ -100,14 +100,16 @@ class Prompt(FastMCPComponent, ABC):
)
for arg in self.arguments or []
]
kwargs = {
"name": self.name,
"description": self.description,
"arguments": arguments,
"title": self.title,
"_meta": self.get_meta(include_fastmcp_meta=include_fastmcp_meta),
}
return MCPPrompt(**kwargs | overrides)
return MCPPrompt(
name=overrides.get("name", self.name),
description=overrides.get("description", self.description),
arguments=arguments,
title=overrides.get("title", self.title),
_meta=overrides.get(
"_meta", self.get_meta(include_fastmcp_meta=include_fastmcp_meta)
),
)
@staticmethod
def from_function(

View file

@ -24,6 +24,7 @@ from fastmcp.server.dependencies import get_context
from fastmcp.utilities.components import FastMCPComponent
from fastmcp.utilities.types import (
find_kwarg_by_type,
get_fn_name,
)
if TYPE_CHECKING:
@ -122,16 +123,18 @@ class Resource(FastMCPComponent, abc.ABC):
**overrides: Any,
) -> MCPResource:
"""Convert the resource to an MCPResource."""
kwargs = {
"uri": self.uri,
"name": self.name,
"description": self.description,
"mimeType": self.mime_type,
"title": self.title,
"annotations": self.annotations,
"_meta": self.get_meta(include_fastmcp_meta=include_fastmcp_meta),
}
return MCPResource(**kwargs | overrides)
return MCPResource(
name=overrides.get("name", self.name),
uri=overrides.get("uri", self.uri),
description=overrides.get("description", self.description),
mimeType=overrides.get("mimeType", self.mime_type),
title=overrides.get("title", self.title),
annotations=overrides.get("annotations", self.annotations),
_meta=overrides.get(
"_meta", self.get_meta(include_fastmcp_meta=include_fastmcp_meta)
),
)
def __repr__(self) -> str:
return f"{self.__class__.__name__}(uri={self.uri!r}, name={self.name!r}, description={self.description!r}, tags={self.tags})"
@ -182,7 +185,7 @@ class FunctionResource(Resource):
return cls(
fn=fn,
uri=uri,
name=name or fn.__name__,
name=name or get_fn_name(fn),
title=title,
description=description or inspect.getdoc(fn),
mime_type=mime_type or "text/plain",

View file

@ -154,16 +154,18 @@ class ResourceTemplate(FastMCPComponent):
**overrides: Any,
) -> MCPResourceTemplate:
"""Convert the resource template to an MCPResourceTemplate."""
kwargs = {
"uriTemplate": self.uri_template,
"name": self.name,
"description": self.description,
"mimeType": self.mime_type,
"title": self.title,
"annotations": self.annotations,
"_meta": self.get_meta(include_fastmcp_meta=include_fastmcp_meta),
}
return MCPResourceTemplate(**kwargs | overrides)
return MCPResourceTemplate(
name=overrides.get("name", self.name),
uriTemplate=overrides.get("uriTemplate", self.uri_template),
description=overrides.get("description", self.description),
mimeType=overrides.get("mimeType", self.mime_type),
title=overrides.get("title", self.title),
annotations=overrides.get("annotations", self.annotations),
_meta=overrides.get(
"_meta", self.get_meta(include_fastmcp_meta=include_fastmcp_meta)
),
)
@classmethod
def from_mcp_template(cls, mcp_template: MCPResourceTemplate) -> ResourceTemplate:

View file

@ -5,6 +5,9 @@ from typing import TYPE_CHECKING
from mcp.server.auth.middleware.auth_context import (
get_access_token as _sdk_get_access_token,
)
from mcp.server.auth.provider import (
AccessToken as _SDKAccessToken,
)
from starlette.requests import Request
from fastmcp.server.auth import AccessToken
@ -107,17 +110,27 @@ def get_access_token() -> AccessToken | None:
The access token if an authenticated user is available, None otherwise.
"""
#
obj = _sdk_get_access_token()
if obj is None or isinstance(obj, AccessToken):
return obj
access_token: _SDKAccessToken | None = _sdk_get_access_token()
if access_token is None or isinstance(access_token, AccessToken):
return access_token
# If the object is not a FastMCP AccessToken, convert it to one if the fields are compatible
# This is a workaround for the case where the SDK returns a different type
# If it fails, it will raise a TypeError
try:
return AccessToken(**obj.model_dump())
access_token_as_dict = access_token.model_dump()
return AccessToken(
token=access_token_as_dict["token"],
client_id=access_token_as_dict["client_id"],
scopes=access_token_as_dict["scopes"],
# Optional fields
expires_at=access_token_as_dict.get("expires_at"),
resource_owner=access_token_as_dict.get("resource_owner"),
claims=access_token_as_dict.get("claims"),
)
except Exception as e:
raise TypeError(
f"Expected fastmcp.server.auth.auth.AccessToken, got {type(obj).__name__}. "
f"Expected fastmcp.server.auth.auth.AccessToken, got {type(access_token).__name__}. "
"Ensure the SDK is using the correct AccessToken type."
) from e

View file

@ -812,6 +812,8 @@ class FastMCP(Generic[LifespanResultT]):
Delegates to _get_prompt, which should be overridden by FastMCP subclasses.
"""
import fastmcp.server.context
logger.debug(
f"[{self.name}] Handler called: get_prompt %s with %s", name, arguments
)

View file

@ -138,23 +138,25 @@ class Tool(FastMCPComponent):
include_fastmcp_meta: bool | None = None,
**overrides: Any,
) -> MCPTool:
"""Convert the FastMCP tool to an MCP tool."""
title = None
if self.title:
title = self.title
elif self.annotations and self.annotations.title:
title = self.annotations.title
else:
title = None
kwargs = {
"name": self.name,
"description": self.description,
"inputSchema": self.parameters,
"outputSchema": self.output_schema,
"annotations": self.annotations,
"title": title,
"_meta": self.get_meta(include_fastmcp_meta=include_fastmcp_meta),
}
return MCPTool(**kwargs | overrides)
return MCPTool(
name=overrides.get("name", self.name),
title=overrides.get("title", title),
description=overrides.get("description", self.description),
inputSchema=overrides.get("inputSchema", self.parameters),
outputSchema=overrides.get("outputSchema", self.output_schema),
annotations=overrides.get("annotations", self.annotations),
_meta=overrides.get(
"_meta", self.get_meta(include_fastmcp_meta=include_fastmcp_meta)
),
)
@staticmethod
def from_function(

View file

@ -31,6 +31,10 @@ NotSet = ...
NotSetT: TypeAlias = EllipsisType
def get_fn_name(fn: Callable[..., Any]) -> str:
return fn.__name__ # ty: ignore[unresolved-attribute]
class FastMCPBaseModel(BaseModel):
"""Base model for FastMCP models."""
@ -80,11 +84,11 @@ def get_cached_typeadapter(cls: T) -> TypeAdapter[T]:
# Handle both functions and methods
if inspect.ismethod(cls):
actual_func = cls.__func__
code = actual_func.__code__
globals_dict = actual_func.__globals__
name = actual_func.__name__
defaults = actual_func.__defaults__
closure = actual_func.__closure__
code = actual_func.__code__ # ty: ignore[unresolved-attribute]
globals_dict = actual_func.__globals__ # ty: ignore[unresolved-attribute]
name = actual_func.__name__ # ty: ignore[unresolved-attribute]
defaults = actual_func.__defaults__ # ty: ignore[unresolved-attribute]
closure = actual_func.__closure__ # ty: ignore[unresolved-attribute]
else:
code = cls.__code__
globals_dict = cls.__globals__

View file

@ -32,6 +32,7 @@ class TestMainCLI:
"""Test parsing invalid environment variables exits."""
with pytest.raises(SystemExit) as exc_info:
_parse_env_var("INVALID_FORMAT")
assert isinstance(exc_info.value, SystemExit)
assert exc_info.value.code == 1
@ -47,14 +48,14 @@ class TestVersionCommand:
def test_version_command_parsing(self):
"""Test that the version command parses arguments correctly."""
command, bound, _ = app.parse_args(["version"])
assert command.__name__ == "version"
assert command.__name__ == "version" # type: ignore[attr-defined]
# Default arguments aren't included in bound.arguments
assert bound.arguments == {}
def test_version_command_with_copy_flag(self):
"""Test that the version command parses --copy flag correctly."""
command, bound, _ = app.parse_args(["version", "--copy"])
assert command.__name__ == "version"
assert command.__name__ == "version" # type: ignore[attr-defined]
assert bound.arguments == {"copy": True}
@patch("fastmcp.cli.cli.pyperclip.copy")

View file

@ -353,4 +353,5 @@ class TestCursorCommand:
with pytest.raises(SystemExit) as exc_info:
await cursor_command("server.py")
assert isinstance(exc_info.value, SystemExit)
assert exc_info.value.code == 1

View file

@ -103,10 +103,13 @@ class TestConfigWithClient:
spec = importlib.util.spec_from_file_location("test_server", str(source_path))
if spec is None or spec.loader is None:
raise RuntimeError(f"Could not load module from {source_path}")
module = importlib.util.module_from_spec(spec)
sys.modules["test_server"] = module
spec.loader.exec_module(module)
assert hasattr(module, "mcp")
server = module.mcp
# Connect client to server

View file

@ -257,6 +257,7 @@ class TestProjectPrepareCommand:
with patch("fastmcp.cli.cli.logger.error") as mock_error:
await prepare(config_path=None, output_dir=None)
assert isinstance(exc_info.value, SystemExit)
assert exc_info.value.code == 1
mock_error.assert_called()
error_msg = mock_error.call_args[0][0]
@ -275,6 +276,7 @@ class TestProjectPrepareCommand:
with patch("fastmcp.cli.cli.logger.error") as mock_error:
await prepare(config_path="missing.json", output_dir=None)
assert isinstance(exc_info.value, SystemExit)
assert exc_info.value.code == 1
mock_error.assert_called()
error_msg = mock_error.call_args[0][0]
@ -297,6 +299,7 @@ class TestProjectPrepareCommand:
with patch("fastmcp.cli.cli.console.print") as mock_print:
await prepare(config_path="config.json", output_dir="./test-env")
assert isinstance(exc_info.value, SystemExit)
assert exc_info.value.code == 1
# Should print error message
error_call = mock_print.call_args_list[-1][0][0]

View file

@ -81,6 +81,7 @@ class TestFileSystemSource:
source = FileSystemSource(path="nonexistent.py")
with pytest.raises(SystemExit) as exc_info:
await source.load_server()
assert isinstance(exc_info.value, SystemExit)
assert exc_info.value.code == 1
async def test_load_server_directory(self, tmp_path):
@ -88,6 +89,7 @@ class TestFileSystemSource:
source = FileSystemSource(path=str(tmp_path))
with pytest.raises(SystemExit) as exc_info:
await source.load_server()
assert isinstance(exc_info.value, SystemExit)
assert exc_info.value.code == 1
@ -252,6 +254,7 @@ other_name = fastmcp.FastMCP("OtherServer")
source = FileSystemSource(path=str(test_file))
with pytest.raises(SystemExit) as exc_info:
await source.load_server()
assert isinstance(exc_info.value, SystemExit)
assert exc_info.value.code == 1
async def test_import_server_nonexistent_object_fails(self, tmp_path):
@ -266,6 +269,8 @@ mcp = fastmcp.FastMCP("TestServer")
source = FileSystemSource(path=f"{test_file}:nonexistent")
with pytest.raises(SystemExit) as exc_info:
await source.load_server()
assert isinstance(exc_info.value, SystemExit)
assert exc_info.value.code == 1

View file

@ -20,6 +20,7 @@ class TestRunWithUv:
with pytest.raises(SystemExit) as exc_info:
run_with_uv("server.py")
assert isinstance(exc_info.value, SystemExit)
assert exc_info.value.code == 0
# Check the command that was called
@ -45,6 +46,7 @@ class TestRunWithUv:
with pytest.raises(SystemExit) as exc_info:
run_with_uv("server.py", python_version="3.11")
assert isinstance(exc_info.value, SystemExit)
assert exc_info.value.code == 0
cmd = mock_run.call_args[0][0]
@ -69,6 +71,7 @@ class TestRunWithUv:
with pytest.raises(SystemExit) as exc_info:
run_with_uv("server.py", project=project_path)
assert isinstance(exc_info.value, SystemExit)
assert exc_info.value.code == 0
cmd = mock_run.call_args[0][0]
@ -91,6 +94,7 @@ class TestRunWithUv:
with pytest.raises(SystemExit) as exc_info:
run_with_uv("server.py", with_packages=["pandas", "numpy"])
assert isinstance(exc_info.value, SystemExit)
assert exc_info.value.code == 0
cmd = mock_run.call_args[0][0]
@ -116,6 +120,7 @@ class TestRunWithUv:
with pytest.raises(SystemExit) as exc_info:
run_with_uv("server.py", with_requirements=req_path)
assert isinstance(exc_info.value, SystemExit)
assert exc_info.value.code == 0
cmd = mock_run.call_args[0][0]
@ -146,6 +151,7 @@ class TestRunWithUv:
show_banner=False,
)
assert isinstance(exc_info.value, SystemExit)
assert exc_info.value.code == 0
cmd = mock_run.call_args[0][0]
@ -188,6 +194,7 @@ class TestRunWithUv:
show_banner=False,
)
assert isinstance(exc_info.value, SystemExit)
assert exc_info.value.code == 0
cmd = mock_run.call_args[0][0]
@ -231,6 +238,7 @@ class TestRunWithUv:
with pytest.raises(SystemExit) as exc_info:
run_with_uv("server.py")
assert isinstance(exc_info.value, SystemExit)
assert exc_info.value.code == 1
@patch("fastmcp.cli.run.logger")

View file

@ -36,7 +36,7 @@ def fastmcp_server():
response_type=Person,
)
if result.action == "accept":
return f"Hello, {result.data.name}!"
return f"Hello, {result.data.name}!" # type: ignore[attr-defined]
else:
return "No name provided."
@ -128,7 +128,7 @@ async def test_elicitation_cancel_action():
if result.action == "cancel":
return "Request was canceled"
elif result.action == "accept":
return f"Age: {result.data}"
return f"Age: {result.data}" # type: ignore[attr-defined]
else:
return "No response provided"
@ -322,6 +322,9 @@ async def test_elicitation_handler_error():
async def failing_elicit(context: Context) -> str:
try:
result = await context.elicit(message="This will fail", response_type=str)
assert isinstance(result, AcceptedElicitation)
assert result.action == "accept"
return f"Got: {result.data}"
except Exception as e:
@ -345,11 +348,17 @@ async def test_elicitation_multiple_calls():
name_result = await context.elicit(
message="What's your name?", response_type=str
)
assert isinstance(name_result, AcceptedElicitation)
if name_result.action != "accept":
return "Form abandoned"
# Second question
age_result = await context.elicit(message="What's your age?", response_type=int)
assert isinstance(age_result, AcceptedElicitation)
if age_result.action != "accept":
return f"Hello {name_result.data}, form incomplete"
@ -403,6 +412,9 @@ async def test_structured_response_type(
result = await context.elicit(
message="Please provide your information", response_type=structured_type
)
assert isinstance(result, AcceptedElicitation)
if result.action == "accept":
if isinstance(result.data, dict):
return f"User: {result.data['name']}, age: {result.data['age']}"

View file

@ -32,8 +32,9 @@ def fastmcp_server():
async def elicit(ctx: Context) -> str:
"""Elicit a response from the user."""
result = await ctx.elicit("What is your name?", response_type=str)
if result.action == "accept":
return f"You said your name was: {result.data}!"
return f"You said your name was: {result.data}!" # ty: ignore[possibly-unbound-attribute]
else:
return "No name provided"

View file

@ -1,3 +1,6 @@
from collections.abc import Callable
from typing import Any
import pytest
@ -15,3 +18,7 @@ def import_rich_rule():
import rich.rule # noqa: F401
yield
def get_fn_name(fn: Callable[..., Any]) -> str:
return fn.__name__ # ty: ignore[unresolved-attribute]

View file

@ -1,6 +1,7 @@
from typing import Any
import pytest
from inline_snapshot import snapshot
from mcp.types import TextContent
from fastmcp import FastMCP
@ -91,73 +92,116 @@ NO_RETURN_TOOL_NAME = "no_return_tool"
async def test_call_tool_bulk_single_success(bulk_caller_live: BulkToolCaller):
"""Test single successful call via call_tool_bulk using echo_tool."""
tool_arguments = [{"arg1": "value1"}]
expected_result = echo_tool_result_factory(**tool_arguments[0])
results = await bulk_caller_live.call_tool_bulk(ECHO_TOOL_NAME, tool_arguments)
results = await bulk_caller_live.call_tool_bulk(
ECHO_TOOL_NAME, [{"arg1": "value1"}]
)
assert len(results) == 1
result = results[0]
assert result == expected_result
assert results == snapshot(
[
CallToolRequestResult(
content=[TextContent(type="text", text="value1")],
tool="echo_tool",
arguments={"arg1": "value1"},
)
]
)
async def test_call_tool_bulk_multiple_success(bulk_caller_live: BulkToolCaller):
"""Test multiple successful calls via call_tool_bulk using echo_tool."""
tool_arguments = [{"arg1": "value1"}, {"arg1": "value2"}]
expected_results = [echo_tool_result_factory(**args) for args in tool_arguments]
results = await bulk_caller_live.call_tool_bulk(
ECHO_TOOL_NAME, [{"arg1": "value1"}, {"arg1": "value2"}]
)
results = await bulk_caller_live.call_tool_bulk(ECHO_TOOL_NAME, tool_arguments)
assert len(results) == 2
assert results == expected_results
assert results == snapshot(
[
CallToolRequestResult(
content=[TextContent(type="text", text="value1")],
tool="echo_tool",
arguments={"arg1": "value1"},
),
CallToolRequestResult(
content=[TextContent(type="text", text="value2")],
tool="echo_tool",
arguments={"arg1": "value2"},
),
]
)
async def test_call_tool_bulk_error_stops(bulk_caller_live: BulkToolCaller):
"""Test call_tool_bulk stops on first error using error_tool."""
tool_arguments = [{"arg1": "error_value"}, {"arg1": "value2"}]
expected_result = error_tool_result_factory(**tool_arguments[0])
results = await bulk_caller_live.call_tool_bulk(
ERROR_TOOL_NAME, tool_arguments, continue_on_error=False
ERROR_TOOL_NAME,
[{"arg1": "error_value"}, {"arg1": "value2"}],
continue_on_error=False,
)
assert len(results) == 1
result = results[0]
assert result == expected_result
assert results == snapshot(
[
CallToolRequestResult(
content=[
TextContent(
type="text",
text="Error calling tool 'error_tool': Error in tool with arg1: error_value",
)
],
isError=True,
tool="error_tool",
arguments={"arg1": "error_value"},
)
]
)
async def test_call_tool_bulk_error_continues(bulk_caller_live: BulkToolCaller):
"""Test call_tool_bulk continues on error using error_tool and echo_tool."""
tool_arguments = [{"arg1": "error_value"}, {"arg1": "success_value"}]
expected_error_result = error_tool_result_factory(**tool_arguments[0])
expected_success_result = echo_tool_result_factory(**tool_arguments[1])
tool_calls = [
CallToolRequest(tool=ERROR_TOOL_NAME, arguments=tool_arguments[0]),
CallToolRequest(tool=ECHO_TOOL_NAME, arguments=tool_arguments[1]),
CallToolRequest(tool=ERROR_TOOL_NAME, arguments={"arg1": "error_value"}),
CallToolRequest(tool=ECHO_TOOL_NAME, arguments={"arg1": "success_value"}),
]
results = await bulk_caller_live.call_tools_bulk(tool_calls, continue_on_error=True)
assert len(results) == 2
error_result = results[0]
assert error_result == expected_error_result
success_result = results[1]
assert success_result == expected_success_result
assert results == snapshot(
[
CallToolRequestResult(
content=[
TextContent(
type="text",
text="Error calling tool 'error_tool': Error in tool with arg1: error_value",
)
],
isError=True,
tool="error_tool",
arguments={"arg1": "error_value"},
),
CallToolRequestResult(
content=[TextContent(type="text", text="success_value")],
tool="echo_tool",
arguments={"arg1": "success_value"},
),
]
)
async def test_call_tools_bulk_single_success(bulk_caller_live: BulkToolCaller):
"""Test single successful call via call_tools_bulk using echo_tool."""
tool_calls = [CallToolRequest(tool=ECHO_TOOL_NAME, arguments={"arg1": "value1"})]
expected_result = echo_tool_result_factory(**tool_calls[0].arguments)
results = await bulk_caller_live.call_tools_bulk(tool_calls)
assert len(results) == 1
result = results[0]
assert result == expected_result
assert results == snapshot(
[
CallToolRequestResult(
content=[TextContent(type="text", text="value1")],
tool="echo_tool",
arguments={"arg1": "value1"},
)
]
)
async def test_call_tools_bulk_multiple_success(bulk_caller_live: BulkToolCaller):
@ -168,15 +212,21 @@ async def test_call_tools_bulk_multiple_success(bulk_caller_live: BulkToolCaller
tool=NO_RETURN_TOOL_NAME, arguments={"arg1": "no_return_value"}
),
]
expected_results = [
echo_tool_result_factory(**tool_calls[0].arguments),
no_return_tool_result_factory(**tool_calls[1].arguments),
]
results = await bulk_caller_live.call_tools_bulk(tool_calls)
assert len(results) == 2
assert results == expected_results
assert results == snapshot(
[
CallToolRequestResult(
content=[TextContent(type="text", text="echo_value")],
tool="echo_tool",
arguments={"arg1": "echo_value"},
),
CallToolRequestResult(
content=[], tool="no_return_tool", arguments={"arg1": "no_return_value"}
),
]
)
async def test_call_tools_bulk_error_stops(bulk_caller_live: BulkToolCaller):
@ -185,15 +235,26 @@ async def test_call_tools_bulk_error_stops(bulk_caller_live: BulkToolCaller):
CallToolRequest(tool=ERROR_TOOL_NAME, arguments={"arg1": "error_value"}),
CallToolRequest(tool=ECHO_TOOL_NAME, arguments={"arg1": "skipped_value"}),
]
expected_result = error_tool_result_factory(**tool_calls[0].arguments)
results = await bulk_caller_live.call_tools_bulk(
tool_calls, continue_on_error=False
)
assert len(results) == 1
result = results[0]
assert result == expected_result
assert results == snapshot(
[
CallToolRequestResult(
content=[
TextContent(
type="text",
text="Error calling tool 'error_tool': Error in tool with arg1: error_value",
)
],
isError=True,
tool="error_tool",
arguments={"arg1": "error_value"},
)
]
)
async def test_call_tools_bulk_error_continues(bulk_caller_live: BulkToolCaller):
@ -202,15 +263,26 @@ async def test_call_tools_bulk_error_continues(bulk_caller_live: BulkToolCaller)
CallToolRequest(tool=ERROR_TOOL_NAME, arguments={"arg1": "error_value"}),
CallToolRequest(tool=ECHO_TOOL_NAME, arguments={"arg1": "success_value"}),
]
expected_error_result = error_tool_result_factory(**tool_calls[0].arguments)
expected_success_result = echo_tool_result_factory(**tool_calls[1].arguments)
results = await bulk_caller_live.call_tools_bulk(tool_calls, continue_on_error=True)
assert len(results) == 2
error_result = results[0]
assert error_result == expected_error_result
success_result = results[1]
assert success_result == expected_success_result
assert results == snapshot(
[
CallToolRequestResult(
content=[
TextContent(
type="text",
text="Error calling tool 'error_tool': Error in tool with arg1: error_value",
)
],
isError=True,
tool="error_tool",
arguments={"arg1": "error_value"},
),
CallToolRequestResult(
content=[TextContent(type="text", text="success_value")],
tool="echo_tool",
arguments={"arg1": "success_value"},
),
]
)

View file

@ -1,6 +1,7 @@
"""Unit tests for OpenAPI models."""
import pytest
from inline_snapshot import snapshot
from fastmcp.experimental.utilities.openapi.models import (
HTTPRoute,
@ -447,7 +448,27 @@ class TestModelSerialization:
# Serialize and reconstruct using by_alias
data = original_param.model_dump(by_alias=True)
reconstructed_param = ParameterInfo(**data)
data == snapshot(
{
"name": "test",
"location": "query",
"required": False,
"schema": {"type": "string"},
"description": "Test parameter",
"explode": None,
"style": None,
}
)
reconstructed_param = ParameterInfo(
name=data["name"],
location=data["location"],
schema=data["schema"],
description=data["description"],
explode=data["explode"],
style=data["style"],
)
assert reconstructed_param.name == original_param.name
assert reconstructed_param.location == original_param.location

View file

@ -8,6 +8,7 @@ from fastmcp.prompts import Prompt
from fastmcp.prompts.prompt import FunctionPrompt, PromptMessage, TextContent
from fastmcp.prompts.prompt_manager import PromptManager
from fastmcp.utilities.tests import caplog_for_fastmcp
from tests.conftest import get_fn_name
class TestPromptManager:
@ -104,7 +105,7 @@ class TestPromptManager:
prompt = await manager.get_prompt("test_prompt")
assert prompt is not None
assert isinstance(prompt, FunctionPrompt)
assert prompt.fn.__name__ == "replacement_fn"
assert get_fn_name(prompt.fn) == "replacement_fn"
async def test_ignore_duplicate_prompts(self):
"""Test ignoring duplicate prompts."""
@ -126,10 +127,10 @@ class TestPromptManager:
prompt = await manager.get_prompt("test_prompt")
assert prompt is not None
assert isinstance(prompt, FunctionPrompt)
assert prompt.fn.__name__ == "original_fn"
assert get_fn_name(prompt.fn) == "original_fn"
# Result should be the original prompt
assert isinstance(result, FunctionPrompt)
assert result.fn.__name__ == "original_fn"
assert get_fn_name(result.fn) == "original_fn"
async def test_get_prompts(self):
"""Test retrieving all prompts."""

View file

@ -27,8 +27,8 @@ class TestResourceTemplate:
assert template.uri_template == "test://{key}/{value}"
assert template.name == "test"
assert template.mime_type == "text/plain" # default
test_input = {"key": "test", "value": 42}
assert template.fn(**test_input) == my_func(**test_input)
assert template.fn(key="test", value=42) == my_func(key="test", value=42)
def test_template_matches(self):
"""Test matching URIs against a template."""

View file

@ -195,6 +195,8 @@ class TestAuthKitProvider:
with pytest.raises(httpx.HTTPStatusError) as exc_info:
async with Client(mcp_server_url) as client:
tools = await client.list_tools() # noqa: F841
assert isinstance(exc_info.value, httpx.HTTPStatusError)
assert exc_info.value.response.status_code == 401
assert "tools" not in locals()

View file

@ -978,6 +978,7 @@ class TestFastMCPBearerAuth:
with pytest.raises(httpx.HTTPStatusError) as exc_info:
async with Client(mcp_server_url) as client:
tools = await client.list_tools() # noqa: F841
assert isinstance(exc_info.value, httpx.HTTPStatusError)
assert exc_info.value.response.status_code == 401
assert "tools" not in locals()
@ -990,6 +991,7 @@ class TestFastMCPBearerAuth:
with pytest.raises(httpx.HTTPStatusError) as exc_info:
async with Client(mcp_server_url, auth=BearerAuth("invalid")) as client:
tools = await client.list_tools() # noqa: F841
assert isinstance(exc_info.value, httpx.HTTPStatusError)
assert exc_info.value.response.status_code == 401
assert "tools" not in locals()
@ -1004,6 +1006,7 @@ class TestFastMCPBearerAuth:
with pytest.raises(httpx.HTTPStatusError) as exc_info:
async with Client(mcp_server_url, auth=BearerAuth(token)) as client:
tools = await client.list_tools() # noqa: F841
assert isinstance(exc_info.value, httpx.HTTPStatusError)
assert exc_info.value.response.status_code == 401
assert "tools" not in locals()
@ -1014,6 +1017,7 @@ class TestFastMCPBearerAuth:
with pytest.raises(httpx.HTTPStatusError) as exc_info:
async with Client(mcp_server_url, auth=BearerAuth(token)) as client:
tools = await client.list_tools() # noqa: F841
assert isinstance(exc_info.value, httpx.HTTPStatusError)
assert exc_info.value.response.status_code == 401
assert "tools" not in locals()
@ -1040,6 +1044,7 @@ class TestFastMCPBearerAuth:
# JWTVerifier returns 401 when verify_token returns None (invalid token)
# This is correct behavior - when TokenVerifier.verify_token returns None,
# it indicates the token is invalid (not just insufficient permissions)
assert isinstance(exc_info.value, httpx.HTTPStatusError)
assert exc_info.value.response.status_code == 401
assert "tools" not in locals()

View file

@ -365,6 +365,7 @@ class TestOAuthProxyInitialization:
assert proxy._redirect_path == "/custom/callback"
assert proxy._forward_pkce is False
assert proxy._token_endpoint_auth_method == "client_secret_post"
assert proxy.client_registration_options is not None
assert proxy.client_registration_options.valid_scopes == ["custom", "scopes"]
def test_redirect_path_normalization(self, jwt_verifier):

View file

@ -193,6 +193,7 @@ class TestErrorHandlingMiddleware:
with pytest.raises(McpError) as exc_info:
await middleware.on_message(mock_context, mock_call_next)
assert isinstance(exc_info.value, McpError)
assert exc_info.value.error.code == -32602
assert "Invalid params: test error" in exc_info.value.error.message
assert "Error in test_method: ValueError: test error" in caplog.text

View file

@ -7,6 +7,7 @@ from typing import Any, Literal, TypeVar
from unittest.mock import AsyncMock, MagicMock
import mcp
import mcp.types
import pytest
from inline_snapshot import snapshot
from pydantic import AnyUrl
@ -56,7 +57,7 @@ def mock_context():
@pytest.fixture
def mock_call_next():
def mock_call_next() -> AsyncMock:
"""Create a mock call_next function."""
return AsyncMock(return_value="test_result")
@ -118,11 +119,11 @@ class TestLoggingMiddleware:
async def test_on_message_success(
self,
mock_context: MiddlewareContext[Any],
mock_call_next: CallNext[Any, Any],
caplog: pytest.LogCaptureFixture,
):
"""Test logging successful messages."""
middleware = LoggingMiddleware()
mock_call_next = AsyncMock(return_value="test_result")
with caplog.at_level(logging.INFO):
result = await middleware.on_message(mock_context, mock_call_next)

View file

@ -88,7 +88,7 @@ def fastapi_app(users_db: dict[int, User]) -> FastAPI:
async def create_user(user: UserCreate) -> User:
"""Create a new user."""
user_id = max(users_db.keys()) + 1
new_user = User(id=user_id, **user.model_dump())
new_user = User(id=user_id, name=user.name, active=user.active)
users_db[user_id] = new_user
return new_user

View file

@ -51,8 +51,9 @@ def fastmcp_server():
message="What is your name?",
response_type=Person,
)
if result.action == "accept":
return f"Hello, {result.data.name}!"
return f"Hello, {result.data.name}!" # type: ignore[attr-defined]
else:
return "No name provided."
@ -362,7 +363,7 @@ class TestProxyClient:
)
if result.action == "accept":
return f"Content: {result.data.content}, Acknowledge: {result.data.acknowledge}"
return f"Content: {result.data.content}, Acknowledge: {result.data.acknowledge}" # type: ignore[attr-defined]
else:
return f"Elicitation {result.action}"

View file

@ -4,6 +4,7 @@ from urllib.parse import quote
from fastmcp.client.client import Client
from fastmcp.server.server import FastMCP
from fastmcp.tools.tool import FunctionTool, Tool
from tests.conftest import get_fn_name
async def test_import_basic_functionality():
@ -208,7 +209,7 @@ async def test_tool_custom_name_preserved_when_imported():
# Check that the function name is preserved
assert isinstance(tool, FunctionTool)
assert tool.fn.__name__ == "fetch_data"
assert get_fn_name(tool.fn) == "fetch_data"
async def test_call_imported_custom_named_tool():
@ -242,7 +243,7 @@ async def test_first_level_importing_with_custom_name():
tool = await service_app._tool_manager.get_tool("provider_compute")
assert tool is not None
assert isinstance(tool, FunctionTool)
assert tool.fn.__name__ == "calculate_value"
assert get_fn_name(tool.fn) == "calculate_value"
async def test_nested_importing_preserves_prefixes():

View file

@ -15,6 +15,7 @@ from fastmcp.tools.tool import Tool
from fastmcp.tools.tool_transform import ArgTransformConfig, ToolTransformConfig
from fastmcp.utilities.tests import caplog_for_fastmcp, temporary_settings
from fastmcp.utilities.types import Image
from tests.conftest import get_fn_name
class TestAddTools:
@ -236,7 +237,7 @@ class TestAddTools:
tool = await manager.get_tool("test_tool")
assert tool is not None
assert isinstance(tool, FunctionTool)
assert tool.fn.__name__ == "replacement_fn"
assert get_fn_name(tool.fn) == "replacement_fn"
async def test_ignore_duplicate_tools(self):
"""Test ignoring duplicate tools."""
@ -257,10 +258,10 @@ class TestAddTools:
tool = await manager.get_tool("test_tool")
assert tool is not None
assert isinstance(tool, FunctionTool)
assert tool.fn.__name__ == "original_fn"
assert get_fn_name(tool.fn) == "original_fn"
# Result should be the original tool
assert isinstance(result, FunctionTool)
assert result.fn.__name__ == "replacement_fn"
assert get_fn_name(result.fn) == "replacement_fn"
class TestListTools:
@ -824,7 +825,7 @@ class TestCustomToolNames:
assert await manager.get_tool("custom_name") is not None
assert tool.name == "custom_name"
assert isinstance(tool, FunctionTool)
assert tool.fn.__name__ == "original_fn"
assert get_fn_name(tool.fn) == "original_fn"
# The tool should not be accessible via its original function name
with pytest.raises(NotFoundError, match="Tool 'original_fn' not found"):
await manager.get_tool("original_fn")
@ -901,7 +902,7 @@ class TestCustomToolNames:
# But the function is different
assert isinstance(stored_tool, FunctionTool)
assert stored_tool.fn.__name__ == "replacement_fn"
assert get_fn_name(stored_tool.fn) == "replacement_fn"
class TestToolErrorHandling:

View file

@ -1,4 +1,4 @@
from dataclasses import dataclass
from dataclasses import Field, dataclass
from datetime import datetime
from enum import Enum
from typing import Any, Literal, Union
@ -13,6 +13,10 @@ from fastmcp.utilities.json_schema_type import (
)
def get_dataclass_field(type: type, field_name: str) -> Field:
return type.__dataclass_fields__[field_name] # ty: ignore[unresolved-attribute]
class TestSimpleTypes:
"""Test suite for basic type validation."""
@ -990,8 +994,8 @@ class TestSchemaCaching:
# Both main classes and their nested classes should be identical
assert class1 is class2
assert (
class1.__dataclass_fields__["nested"].type
is class2.__dataclass_fields__["nested"].type
get_dataclass_field(class1, "nested").type
is get_dataclass_field(class2, "nested").type
)
@ -1220,9 +1224,10 @@ class TestNameHandling:
}
Type = json_schema_to_type(schema)
assert Type.__name__ == "Parent"
assert Type.__dataclass_fields__["child"].type.__origin__ is Union
assert Type.__dataclass_fields__["child"].type.__args__[0].__name__ == "Child"
assert Type.__dataclass_fields__["child"].type.__args__[1] is type(None)
child_field_type = get_dataclass_field(Type, "child").type
assert child_field_type.__origin__ is Union # ty: ignore[possibly-unbound-attribute]
assert child_field_type.__args__[0].__name__ == "Child" # ty: ignore[possibly-unbound-attribute]
assert child_field_type.__args__[1] is type(None) # ty: ignore[possibly-unbound-attribute]
def test_recursive_schema_naming(self):
schema = {
@ -1232,11 +1237,12 @@ class TestNameHandling:
}
Type = json_schema_to_type(schema)
assert Type.__name__ == "Node"
assert Type.__dataclass_fields__["next"].type.__origin__ is Union
assert (
Type.__dataclass_fields__["next"].type.__args__[0].__forward_arg__ == "Node"
)
assert Type.__dataclass_fields__["next"].type.__args__[1] is type(None)
next_field_type = get_dataclass_field(Type, "next").type
assert next_field_type.__origin__ is Union # ty: ignore[possibly-unbound-attribute]
assert next_field_type.__args__[0].__forward_arg__ == "Node" # ty: ignore[possibly-unbound-attribute]
assert next_field_type.__args__[1] is type(None) # ty: ignore[possibly-unbound-attribute]
def test_name_caching_with_different_titles(self):
"""Ensure schemas with different titles create different cached classes"""

40
uv.lock generated
View file

@ -1,5 +1,5 @@
version = 1
revision = 3
revision = 2
requires-python = ">=3.10"
resolution-markers = [
"python_full_version >= '3.11'",
@ -2045,27 +2045,27 @@ wheels = [
[[package]]
name = "ty"
version = "0.0.1a19"
version = "0.0.1a20"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c0/04/281c1a3c9c53dae5826b9d01a3412de653e3caf1ca50ce1265da66e06d73/ty-0.0.1a19.tar.gz", hash = "sha256:894f6a13a43989c8ef891ae079b3b60a0c0eae00244abbfbbe498a3840a235ac", size = 4098412, upload-time = "2025-08-19T13:29:58.559Z" }
sdist = { url = "https://files.pythonhosted.org/packages/7a/82/a5e3b4bc5280ec49c4b0b43d0ff727d58c7df128752c9c6f97ad0b5f575f/ty-0.0.1a20.tar.gz", hash = "sha256:933b65a152f277aa0e23ba9027e5df2c2cc09e18293e87f2a918658634db5f15", size = 4194773, upload-time = "2025-09-03T12:35:46.775Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3e/65/a61cfcc7248b0257a3110bf98d3d910a4729c1063abdbfdcd1cad9012323/ty-0.0.1a19-py3-none-linux_armv6l.whl", hash = "sha256:e0e7762f040f4bab1b37c57cb1b43cc3bc5afb703fa5d916dfcafa2ef885190e", size = 8143744, upload-time = "2025-08-19T13:29:13.88Z" },
{ url = "https://files.pythonhosted.org/packages/02/d9/232afef97d9afa2274d23a4c49a3ad690282ca9696e1b6bbb6e4e9a1b072/ty-0.0.1a19-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:cd0a67ac875f49f34d9a0b42dcabf4724194558a5dd36867209d5695c67768f7", size = 8305799, upload-time = "2025-08-19T13:29:17.322Z" },
{ url = "https://files.pythonhosted.org/packages/20/14/099d268da7a9cccc6ba38dfc124f6742a1d669bc91f2c61a3465672b4f71/ty-0.0.1a19-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ff8b1c0b85137333c39eccd96c42603af8ba7234d6e2ed0877f66a4a26750dd4", size = 7901431, upload-time = "2025-08-19T13:29:21.635Z" },
{ url = "https://files.pythonhosted.org/packages/c2/cd/3f1ca6e1d7f77cc4d08910a3fc4826313c031c0aae72286ae859e737670c/ty-0.0.1a19-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fef34a29f4b97d78aa30e60adbbb12137cf52b8b2b0f1a408dd0feb0466908a", size = 8051501, upload-time = "2025-08-19T13:29:23.741Z" },
{ url = "https://files.pythonhosted.org/packages/47/72/ddbec39f48ce3f5f6a3fa1f905c8fff2873e59d2030f738814032bd783e3/ty-0.0.1a19-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b0f219cb43c0c50fc1091f8ebd5548d3ef31ee57866517b9521d5174978af9fd", size = 7981234, upload-time = "2025-08-19T13:29:25.839Z" },
{ url = "https://files.pythonhosted.org/packages/f2/0f/58e76b8d4634df066c790d362e8e73b25852279cd6f817f099b42a555a66/ty-0.0.1a19-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:22abb6c1f14c65c1a2fafd38e25dd3c87994b3ab88cb0b323235b51dbad082d9", size = 8916394, upload-time = "2025-08-19T13:29:27.932Z" },
{ url = "https://files.pythonhosted.org/packages/70/30/01bfd93ccde11540b503e2539e55f6a1fc6e12433a229191e248946eb753/ty-0.0.1a19-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:5b49225c349a3866e38dd297cb023a92d084aec0e895ed30ca124704bff600e6", size = 9412024, upload-time = "2025-08-19T13:29:30.942Z" },
{ url = "https://files.pythonhosted.org/packages/a8/a2/2216d752f5f22c5c0995f9b13f18337301220f2a7d952c972b33e6a63583/ty-0.0.1a19-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:88f41728b3b07402e0861e3c34412ca963268e55f6ab1690208f25d37cb9d63c", size = 9032657, upload-time = "2025-08-19T13:29:33.933Z" },
{ url = "https://files.pythonhosted.org/packages/24/c7/e6650b0569be1b69a03869503d07420c9fb3e90c9109b09726c44366ce63/ty-0.0.1a19-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33814a1197ec3e930fcfba6fb80969fe7353957087b42b88059f27a173f7510b", size = 8812775, upload-time = "2025-08-19T13:29:36.505Z" },
{ url = "https://files.pythonhosted.org/packages/35/c6/b8a20e06b97fe8203059d56d8f91cec4f9633e7ba65f413d80f16aa0be04/ty-0.0.1a19-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d71b7f2b674a287258f628acafeecd87691b169522945ff6192cd8a69af15857", size = 8631417, upload-time = "2025-08-19T13:29:38.837Z" },
{ url = "https://files.pythonhosted.org/packages/be/99/821ca1581dcf3d58ffb7bbe1cde7e1644dbdf53db34603a16a459a0b302c/ty-0.0.1a19-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3a7f8ef9ac4c38e8651c18c7380649c5a3fa9adb1a6012c721c11f4bbdc0ce24", size = 7928900, upload-time = "2025-08-19T13:29:41.08Z" },
{ url = "https://files.pythonhosted.org/packages/08/cb/59f74a0522e57565fef99e2287b2bc803ee47ff7dac250af26960636939f/ty-0.0.1a19-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:60f40e72f0fbf4e54aa83d9a6cb1959f551f83de73af96abbb94711c1546bd60", size = 8003310, upload-time = "2025-08-19T13:29:43.165Z" },
{ url = "https://files.pythonhosted.org/packages/4c/b3/1209b9acb5af00a2755114042e48fb0f71decc20d9d77a987bf5b3d1a102/ty-0.0.1a19-py3-none-musllinux_1_2_i686.whl", hash = "sha256:64971e4d3e3f83dc79deb606cc438255146cab1ab74f783f7507f49f9346d89d", size = 8496463, upload-time = "2025-08-19T13:29:46.136Z" },
{ url = "https://files.pythonhosted.org/packages/a2/d6/a4b6ba552d347a08196d83a4d60cb23460404a053dd3596e23a922bce544/ty-0.0.1a19-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9aadbff487e2e1486e83543b4f4c2165557f17432369f419be9ba48dc47625ca", size = 8700633, upload-time = "2025-08-19T13:29:49.351Z" },
{ url = "https://files.pythonhosted.org/packages/96/c5/258f318d68b95685c8d98fb654a38882c9d01ce5d9426bed06124f690f04/ty-0.0.1a19-py3-none-win32.whl", hash = "sha256:00b75b446357ee22bcdeb837cb019dc3bc1dc5e5013ff0f46a22dfe6ce498fe2", size = 7811441, upload-time = "2025-08-19T13:29:52.077Z" },
{ url = "https://files.pythonhosted.org/packages/fb/bb/039227eee3c0c0cddc25f45031eea0f7f10440713f12d333f2f29cf8e934/ty-0.0.1a19-py3-none-win_amd64.whl", hash = "sha256:aaef76b2f44f6379c47adfe58286f0c56041cb2e374fd8462ae8368788634469", size = 8441186, upload-time = "2025-08-19T13:29:54.53Z" },
{ url = "https://files.pythonhosted.org/packages/74/5f/bceb29009670ae6f759340f9cb434121bc5ed84ad0f07bdc6179eaaa3204/ty-0.0.1a19-py3-none-win_arm64.whl", hash = "sha256:893755bb35f30653deb28865707e3b16907375c830546def2741f6ff9a764710", size = 8000810, upload-time = "2025-08-19T13:29:56.796Z" },
{ url = "https://files.pythonhosted.org/packages/45/c8/f7d39392043d5c04936f6cad90e50eb661965ed092ca4bfc01db917d7b8a/ty-0.0.1a20-py3-none-linux_armv6l.whl", hash = "sha256:f73a7aca1f0d38af4d6999b375eb00553f3bfcba102ae976756cc142e14f3450", size = 8443599, upload-time = "2025-09-03T12:35:04.289Z" },
{ url = "https://files.pythonhosted.org/packages/1e/57/5aec78f9b8a677b7439ccded7d66c3361e61247e0f6b14e659b00dd01008/ty-0.0.1a20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:cad12c857ea4b97bf61e02f6796e13061ccca5e41f054cbd657862d80aa43bae", size = 8618102, upload-time = "2025-09-03T12:35:07.448Z" },
{ url = "https://files.pythonhosted.org/packages/15/20/50c9107d93cdb55676473d9dc4e2339af6af606660c9428d3b86a1b2a476/ty-0.0.1a20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f153b65c7fcb6b8b59547ddb6353761b3e8d8bb6f0edd15e3e3ac14405949f7a", size = 8192167, upload-time = "2025-09-03T12:35:09.706Z" },
{ url = "https://files.pythonhosted.org/packages/85/28/018b2f330109cee19e81c5ca9df3dc29f06c5778440eb9af05d4550c4302/ty-0.0.1a20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b8c4336987a6a781d4392a9fd7b3a39edb7e4f3dd4f860e03f46c932b52aefa2", size = 8349256, upload-time = "2025-09-03T12:35:11.76Z" },
{ url = "https://files.pythonhosted.org/packages/cd/c9/2f8797a05587158f52b142278796ffd72c893bc5ad41840fce5aeb65c6f2/ty-0.0.1a20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3ff75cd4c744d09914e8c9db8d99e02f82c9379ad56b0a3fc4c5c9c923cfa84e", size = 8271214, upload-time = "2025-09-03T12:35:13.741Z" },
{ url = "https://files.pythonhosted.org/packages/30/d4/2cac5e5eb9ee51941358cb3139aadadb59520cfaec94e4fcd2b166969748/ty-0.0.1a20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e26437772be7f7808868701f2bf9e14e706a6ec4c7d02dbd377ff94d7ba60c11", size = 9264939, upload-time = "2025-09-03T12:35:16.896Z" },
{ url = "https://files.pythonhosted.org/packages/93/96/a6f2b54e484b2c6a5488f217882237dbdf10f0fdbdb6cd31333d57afe494/ty-0.0.1a20-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:83a7ee12465841619b5eb3ca962ffc7d576bb1c1ac812638681aee241acbfbbe", size = 9743137, upload-time = "2025-09-03T12:35:19.799Z" },
{ url = "https://files.pythonhosted.org/packages/6e/67/95b40dcbec3d222f3af5fe5dd1ce066d42f8a25a2f70d5724490457048e7/ty-0.0.1a20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:726d0738be4459ac7ffae312ba96c5f486d6cbc082723f322555d7cba9397871", size = 9368153, upload-time = "2025-09-03T12:35:22.569Z" },
{ url = "https://files.pythonhosted.org/packages/2c/24/689fa4c4270b9ef9a53dc2b1d6ffade259ba2c4127e451f0629e130ea46a/ty-0.0.1a20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0b481f26513f38543df514189fb16744690bcba8d23afee95a01927d93b46e36", size = 9099637, upload-time = "2025-09-03T12:35:24.94Z" },
{ url = "https://files.pythonhosted.org/packages/a1/5b/913011cbf3ea4030097fb3c4ce751856114c9e1a5e1075561a4c5242af9b/ty-0.0.1a20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7abbe3c02218c12228b1d7c5f98c57240029cc3bcb15b6997b707c19be3908c1", size = 8952000, upload-time = "2025-09-03T12:35:27.288Z" },
{ url = "https://files.pythonhosted.org/packages/df/f9/f5ba2ae455b20c5bb003f9940ef8142a8c4ed9e27de16e8f7472013609db/ty-0.0.1a20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:fff51c75ee3f7cc6d7722f2f15789ef8ffe6fd2af70e7269ac785763c906688e", size = 8217938, upload-time = "2025-09-03T12:35:29.54Z" },
{ url = "https://files.pythonhosted.org/packages/eb/62/17002cf9032f0981cdb8c898d02422c095c30eefd69ca62a8b705d15bd0f/ty-0.0.1a20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b4124ab75e0e6f09fe7bc9df4a77ee43c5e0ef7e61b0c149d7c089d971437cbd", size = 8292369, upload-time = "2025-09-03T12:35:31.748Z" },
{ url = "https://files.pythonhosted.org/packages/28/d6/0879b1fb66afe1d01d45c7658f3849aa641ac4ea10679404094f3b40053e/ty-0.0.1a20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8a138fa4f74e6ed34e9fd14652d132409700c7ff57682c2fed656109ebfba42f", size = 8811973, upload-time = "2025-09-03T12:35:33.997Z" },
{ url = "https://files.pythonhosted.org/packages/60/1e/70bf0348cfe8ba5f7532983f53c508c293ddf5fa9f942ed79a3c4d576df3/ty-0.0.1a20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8eff8871d6b88d150e2a67beba2c57048f20c090c219f38ed02eebaada04c124", size = 9010990, upload-time = "2025-09-03T12:35:36.766Z" },
{ url = "https://files.pythonhosted.org/packages/b7/ca/03d85c7650359247b1ca3f38a0d869a608ef540450151920e7014ed58292/ty-0.0.1a20-py3-none-win32.whl", hash = "sha256:3c2ace3a22fab4bd79f84c74e3dab26e798bfba7006bea4008d6321c1bd6efc6", size = 8100746, upload-time = "2025-09-03T12:35:40.007Z" },
{ url = "https://files.pythonhosted.org/packages/94/53/7a1937b8c7a66d0c8ed7493de49ed454a850396fe137d2ae12ed247e0b2f/ty-0.0.1a20-py3-none-win_amd64.whl", hash = "sha256:f41e77ff118da3385915e13c3f366b3a2f823461de54abd2e0ca72b170ba0f19", size = 8748861, upload-time = "2025-09-03T12:35:42.175Z" },
{ url = "https://files.pythonhosted.org/packages/27/36/5a3a70c5d497d3332f9e63cabc9c6f13484783b832fecc393f4f1c0c4aa8/ty-0.0.1a20-py3-none-win_arm64.whl", hash = "sha256:d8ac1c5a14cda5fad1a8b53959d9a5d979fe16ce1cc2785ea8676fed143ac85f", size = 8269906, upload-time = "2025-09-03T12:35:45.045Z" },
]
[[package]]