mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 07:09:11 +02:00
Enable more type checking rules (#1775)
This commit is contained in:
parent
3dd7373680
commit
fe4f31c2c7
36 changed files with 404 additions and 177 deletions
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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__
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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']}"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
),
|
||||
]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
40
uv.lock
generated
|
|
@ -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]]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue