diff --git a/.github/workflows/lint.yml b/.github/workflows/run-static.yml similarity index 74% rename from .github/workflows/lint.yml rename to .github/workflows/run-static.yml index b837b5d14..0eae35b2b 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/run-static.yml @@ -28,3 +28,9 @@ jobs: python-version: "3.12" - name: Run pre-commit uses: pre-commit/action@v3.0.1 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install ".[tests]" + - name: Run pyright + run: pyright src tests diff --git a/pyproject.toml b/pyproject.toml index 15f0848ae..48036c64e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ build-backend = "hatchling.build" [project.optional-dependencies] tests = [ "pre-commit", + "pyright>=1.1.389", "pytest>=8.3.3", "pytest-asyncio>=0.23.5", "pytest-flakefinder", @@ -39,3 +40,15 @@ asyncio_default_fixture_loop_scope = "session" [tool.hatch.version] source = "vcs" + +[tool.pyright] +include = ["src", "tests"] +exclude = ["**/node_modules", "**/__pycache__", ".venv", ".git", "dist"] +pythonVersion = "3.10" +pythonPlatform = "Darwin" +typeCheckingMode = "basic" +reportMissingImports = true +reportMissingTypeStubs = false +useLibraryCodeForTypes = true +venvPath = "." +venv = ".venv" diff --git a/src/fastmcp/cli/cli.py b/src/fastmcp/cli/cli.py index a73530300..5921afa75 100644 --- a/src/fastmcp/cli/cli.py +++ b/src/fastmcp/cli/cli.py @@ -2,6 +2,7 @@ import importlib.metadata import importlib.util +import os import subprocess import sys from pathlib import Path @@ -242,6 +243,7 @@ def dev( [npx_cmd, "@modelcontextprotocol/inspector"] + uv_cmd, check=True, shell=shell, + env=dict(os.environ.items()), # Convert to list of tuples for env update ) sys.exit(process.returncode) except subprocess.CalledProcessError as e: @@ -423,7 +425,10 @@ def install( # Load from .env file if specified if env_file: try: - env_dict.update(dotenv.dotenv_values(env_file)) + env_values = dotenv.dotenv_values(env_file) + env_dict.update( + (k, str(v)) for k, v in env_values.items() if v is not None + ) except Exception as e: logger.error(f"Failed to load .env file: {e}") sys.exit(1) diff --git a/src/fastmcp/prompts/base.py b/src/fastmcp/prompts/base.py index 0cbc3c840..d44fc1823 100644 --- a/src/fastmcp/prompts/base.py +++ b/src/fastmcp/prompts/base.py @@ -1,43 +1,52 @@ """Base classes for FastMCP prompts.""" import json -from typing import Any, Callable, Dict, Literal, Optional, Sequence, Union +from typing import Any, Callable, Dict, Literal, Optional, Sequence, Awaitable import inspect -from pydantic import BaseModel, Field, TypeAdapter, field_validator, validate_call +from pydantic import BaseModel, Field, TypeAdapter, validate_call from mcp.types import TextContent, ImageContent, EmbeddedResource import pydantic_core +CONTENT_TYPES = TextContent | ImageContent | EmbeddedResource + class Message(BaseModel): """Base class for all prompt messages.""" role: Literal["user", "assistant"] - content: Union[TextContent, ImageContent, EmbeddedResource] + content: CONTENT_TYPES - def __init__(self, content, **kwargs): + def __init__(self, content: str | CONTENT_TYPES, **kwargs): + if isinstance(content, str): + content = TextContent(type="text", text=content) super().__init__(content=content, **kwargs) - @field_validator("content", mode="before") - def validate_content(cls, v): - if isinstance(v, str): - return TextContent(type="text", text=v) - return v - class UserMessage(Message): """A message from the user.""" role: Literal["user"] = "user" + def __init__(self, content: str | CONTENT_TYPES, **kwargs): + super().__init__(content=content, **kwargs) + class AssistantMessage(Message): """A message from the assistant.""" role: Literal["assistant"] = "assistant" + def __init__(self, content: str | CONTENT_TYPES, **kwargs): + super().__init__(content=content, **kwargs) -message_validator = TypeAdapter(Union[UserMessage, AssistantMessage]) + +message_validator = TypeAdapter(UserMessage | AssistantMessage) + +SyncPromptResult = ( + str | Message | dict[str, Any] | Sequence[str | Message | dict[str, Any]] +) +PromptResult = SyncPromptResult | Awaitable[SyncPromptResult] class PromptArgument(BaseModel): @@ -67,11 +76,18 @@ class Prompt(BaseModel): @classmethod def from_function( cls, - fn: Callable[..., Sequence[Message]], + fn: Callable[..., PromptResult], name: Optional[str] = None, description: Optional[str] = None, ) -> "Prompt": - """Create a Prompt from a function.""" + """Create a Prompt from a function. + + The function can return: + - A string (converted to a message) + - A Message object + - A dict (converted to a message) + - A sequence of any of the above + """ func_name = name or fn.__name__ if func_name == "": diff --git a/src/fastmcp/resources/base.py b/src/fastmcp/resources/base.py index 28b0834d5..cf9c72b1b 100644 --- a/src/fastmcp/resources/base.py +++ b/src/fastmcp/resources/base.py @@ -1,14 +1,14 @@ """Base classes and interfaces for FastMCP resources.""" import abc -from typing import Union +from typing import Union, Annotated from pydantic import ( AnyUrl, BaseModel, ConfigDict, Field, - FileUrl, + UrlConstraints, ValidationInfo, field_validator, ) @@ -19,8 +19,9 @@ class Resource(BaseModel, abc.ABC): model_config = ConfigDict(validate_default=True) - # uri: Annotated[AnyUrl, BeforeValidator(maybe_cast_str_to_any_url)] = Field( - uri: AnyUrl = Field(default=..., description="URI of the resource") + uri: Annotated[AnyUrl, UrlConstraints(host_required=False)] = Field( + default=..., description="URI of the resource" + ) name: str | None = Field(description="Name of the resource", default=None) description: str | None = Field( description="Description of the resource", default=None @@ -31,15 +32,6 @@ class Resource(BaseModel, abc.ABC): pattern=r"^[a-zA-Z0-9]+/[a-zA-Z0-9\-+.]+$", ) - @field_validator("uri", mode="before") - def validate_uri(cls, uri: AnyUrl | str) -> AnyUrl: - if isinstance(uri, str): - # AnyUrl doesn't support triple-slashes, but files do ("file:///absolute/path") - if uri.startswith("file://"): - return FileUrl(uri) - return AnyUrl(uri) - return uri - @field_validator("name", mode="before") @classmethod def set_default_name(cls, name: str | None, info: ValidationInfo) -> str: diff --git a/src/fastmcp/resources/templates.py b/src/fastmcp/resources/templates.py index 77c28e7dd..dc83730c8 100644 --- a/src/fastmcp/resources/templates.py +++ b/src/fastmcp/resources/templates.py @@ -70,7 +70,7 @@ class ResourceTemplate(BaseModel): result = await result return FunctionResource( - uri=uri, + uri=uri, # type: ignore name=self.name, description=self.description, mime_type=self.mime_type, diff --git a/src/fastmcp/resources/types.py b/src/fastmcp/resources/types.py index c5363a6d3..0b13816ec 100644 --- a/src/fastmcp/resources/types.py +++ b/src/fastmcp/resources/types.py @@ -8,7 +8,7 @@ from typing import Any, Callable, Union import httpx import pydantic.json import pydantic_core -from pydantic import Field +from pydantic import Field, ValidationInfo from fastmcp.resources.base import Resource @@ -91,6 +91,15 @@ class FileResource(Resource): raise ValueError("Path must be absolute") return path + @pydantic.field_validator("is_binary") + @classmethod + def set_binary_from_mime_type(cls, is_binary: bool, info: ValidationInfo) -> bool: + """Set is_binary based on mime_type if not explicitly set.""" + if is_binary: + return True + mime_type = info.data.get("mime_type", "text/plain") + return not mime_type.startswith("text/") + async def read(self) -> Union[str, bytes]: """Read the file content.""" try: diff --git a/src/fastmcp/server.py b/src/fastmcp/server.py index a215a9a82..d8988534f 100644 --- a/src/fastmcp/server.py +++ b/src/fastmcp/server.py @@ -23,6 +23,7 @@ from mcp.types import ( ) from mcp.types import ( Prompt as MCPPrompt, + PromptArgument as MCPPromptArgument, ) from mcp.types import ( Resource as MCPResource, @@ -159,7 +160,7 @@ class FastMCP: async def call_tool( self, name: str, arguments: dict - ) -> Sequence[TextContent | ImageContent]: + ) -> Sequence[TextContent | ImageContent | EmbeddedResource]: """Call a tool by name with arguments.""" context = self.get_context() result = await self._tool_manager.call_tool(name, arguments, context=context) @@ -462,11 +463,11 @@ class FastMCP: name=prompt.name, description=prompt.description, arguments=[ - { - "name": arg.name, - "description": arg.description, - "required": arg.required, - } + MCPPromptArgument( + name=arg.name, + description=arg.description, + required=arg.required, + ) for arg in (prompt.arguments or []) ], ) diff --git a/src/fastmcp/utilities/func_metadata.py b/src/fastmcp/utilities/func_metadata.py index 25c3baa10..9bd49214d 100644 --- a/src/fastmcp/utilities/func_metadata.py +++ b/src/fastmcp/utilities/func_metadata.py @@ -47,7 +47,7 @@ class FuncMetadata(BaseModel): async def call_fn_with_arg_validation( self, - fn: Callable | Awaitable, + fn: Callable[..., Any] | Awaitable[Any], fn_is_async: bool, arguments_to_validate: dict[str, Any], arguments_to_pass_directly: dict[str, Any] | None, @@ -64,8 +64,12 @@ class FuncMetadata(BaseModel): arguments_parsed_dict |= arguments_to_pass_directly or {} if fn_is_async: + if isinstance(fn, Awaitable): + return await fn return await fn(**arguments_parsed_dict) - return fn(**arguments_parsed_dict) + if isinstance(fn, Callable): + return fn(**arguments_parsed_dict) + raise TypeError("fn must be either Callable or Awaitable") def pre_parse_json(self, data: dict[str, Any]) -> dict[str, Any]: """Pre-parse data from JSON. @@ -123,6 +127,7 @@ def func_metadata(func: Callable, skip_names: Sequence[str] = ()) -> FuncMetadat sig = _get_typed_signature(func) params = sig.parameters dynamic_pydantic_model_params: dict[str, Any] = {} + globalns = getattr(func, "__globals__", {}) for param in params.values(): if param.name.startswith("_"): raise InvalidSignature( @@ -153,7 +158,7 @@ def func_metadata(func: Callable, skip_names: Sequence[str] = ()) -> FuncMetadat ] field_info = FieldInfo.from_annotated_attribute( - annotation, + _get_typed_annotation(annotation, globalns), param.default if param.default is not inspect.Parameter.empty else PydanticUndefined, diff --git a/src/fastmcp/utilities/types.py b/src/fastmcp/utilities/types.py index 60bbf15b7..b93d244f0 100644 --- a/src/fastmcp/utilities/types.py +++ b/src/fastmcp/utilities/types.py @@ -47,7 +47,9 @@ class Image: if self.path: with open(self.path, "rb") as f: data = base64.b64encode(f.read()).decode() - else: + elif self.data is not None: data = base64.b64encode(self.data).decode() + else: + raise ValueError("No image data available") return ImageContent(type="image", data=data, mimeType=self._mime_type) diff --git a/tests/prompts/test_base.py b/tests/prompts/test_base.py index 78418e81d..4dca37d6d 100644 --- a/tests/prompts/test_base.py +++ b/tests/prompts/test_base.py @@ -1,3 +1,4 @@ +from pydantic import FileUrl import pytest from fastmcp.prompts.base import ( Prompt, @@ -102,7 +103,7 @@ class TestRenderPrompt: content=EmbeddedResource( type="resource", resource=TextResourceContents( - uri="file://file.txt", + uri=FileUrl("file://file.txt"), text="File contents", mimeType="text/plain", ), @@ -115,7 +116,7 @@ class TestRenderPrompt: content=EmbeddedResource( type="resource", resource=TextResourceContents( - uri="file://file.txt", + uri=FileUrl("file://file.txt"), text="File contents", mimeType="text/plain", ), @@ -133,7 +134,7 @@ class TestRenderPrompt: content=EmbeddedResource( type="resource", resource=TextResourceContents( - uri="file://file.txt", + uri=FileUrl("file://file.txt"), text="File contents", mimeType="text/plain", ), @@ -151,7 +152,7 @@ class TestRenderPrompt: content=EmbeddedResource( type="resource", resource=TextResourceContents( - uri="file://file.txt", + uri=FileUrl("file://file.txt"), text="File contents", mimeType="text/plain", ), @@ -171,7 +172,7 @@ class TestRenderPrompt: "content": { "type": "resource", "resource": { - "uri": "file://file.txt", + "uri": FileUrl("file://file.txt"), "text": "File contents", "mimeType": "text/plain", }, @@ -184,7 +185,7 @@ class TestRenderPrompt: content=EmbeddedResource( type="resource", resource=TextResourceContents( - uri="file://file.txt", + uri=FileUrl("file://file.txt"), text="File contents", mimeType="text/plain", ), diff --git a/tests/resources/test_file_resources.py b/tests/resources/test_file_resources.py index 15ddd057b..83c8897a2 100644 --- a/tests/resources/test_file_resources.py +++ b/tests/resources/test_file_resources.py @@ -3,6 +3,7 @@ import os import pytest from pathlib import Path from tempfile import NamedTemporaryFile +from pydantic import FileUrl from fastmcp.resources import FileResource @@ -30,7 +31,7 @@ class TestFileResource: def test_file_resource_creation(self, temp_file: Path): """Test creating a FileResource.""" resource = FileResource( - uri=temp_file.as_uri(), + uri=FileUrl(temp_file.as_uri()), name="test", description="test file", path=temp_file, @@ -45,9 +46,9 @@ class TestFileResource: def test_file_resource_str_path_conversion(self, temp_file: Path): """Test FileResource handles string paths.""" resource = FileResource( - uri=f"file://{temp_file}", + uri=FileUrl(f"file://{temp_file}"), name="test", - path=str(temp_file), + path=Path(str(temp_file)), ) assert isinstance(resource.path, Path) assert resource.path.is_absolute() @@ -55,7 +56,7 @@ class TestFileResource: async def test_read_text_file(self, temp_file: Path): """Test reading a text file.""" resource = FileResource( - uri=f"file://{temp_file}", + uri=FileUrl(f"file://{temp_file}"), name="test", path=temp_file, ) @@ -66,7 +67,7 @@ class TestFileResource: async def test_read_binary_file(self, temp_file: Path): """Test reading a file as binary.""" resource = FileResource( - uri=f"file://{temp_file}", + uri=FileUrl(f"file://{temp_file}"), name="test", path=temp_file, is_binary=True, @@ -79,7 +80,7 @@ class TestFileResource: """Test error on relative path.""" with pytest.raises(ValueError, match="Path must be absolute"): FileResource( - uri="file:///test.txt", + uri=FileUrl("file:///test.txt"), name="test", path=Path("test.txt"), ) @@ -89,7 +90,7 @@ class TestFileResource: # Create path to non-existent file missing = temp_file.parent / "missing.txt" resource = FileResource( - uri="file:///missing.txt", + uri=FileUrl("file:///missing.txt"), name="test", path=missing, ) @@ -104,7 +105,7 @@ class TestFileResource: temp_file.chmod(0o000) # Remove all permissions try: resource = FileResource( - uri=temp_file.as_uri(), + uri=FileUrl(temp_file.as_uri()), name="test", path=temp_file, ) diff --git a/tests/resources/test_function_resources.py b/tests/resources/test_function_resources.py index f124d398d..3a2d5e5e2 100644 --- a/tests/resources/test_function_resources.py +++ b/tests/resources/test_function_resources.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel +from pydantic import BaseModel, AnyUrl import pytest from fastmcp.resources import FunctionResource @@ -13,7 +13,7 @@ class TestFunctionResource: return "test content" resource = FunctionResource( - uri="fn://test", + uri=AnyUrl("fn://test"), name="test", description="test function", fn=my_func, @@ -31,7 +31,7 @@ class TestFunctionResource: return "Hello, world!" resource = FunctionResource( - uri="function://test", + uri=AnyUrl("function://test"), name="test", fn=get_data, ) @@ -46,7 +46,7 @@ class TestFunctionResource: return b"Hello, world!" resource = FunctionResource( - uri="function://test", + uri=AnyUrl("function://test"), name="test", fn=get_data, ) @@ -60,11 +60,12 @@ class TestFunctionResource: return {"key": "value"} resource = FunctionResource( - uri="function://test", + uri=AnyUrl("function://test"), name="test", fn=get_data, ) content = await resource.read() + assert isinstance(content, str) assert '"key": "value"' in content async def test_error_handling(self): @@ -74,7 +75,7 @@ class TestFunctionResource: raise ValueError("Test error") resource = FunctionResource( - uri="function://test", + uri=AnyUrl("function://test"), name="test", fn=failing_func, ) @@ -88,7 +89,7 @@ class TestFunctionResource: name: str resource = FunctionResource( - uri="function://test", + uri=AnyUrl("function://test"), name="test", fn=lambda: MyModel(name="test"), ) @@ -106,7 +107,7 @@ class TestFunctionResource: return CustomData() resource = FunctionResource( - uri="function://test", + uri=AnyUrl("function://test"), name="test", fn=get_data, ) diff --git a/tests/resources/test_resource_manager.py b/tests/resources/test_resource_manager.py index 0422cebae..87061d911 100644 --- a/tests/resources/test_resource_manager.py +++ b/tests/resources/test_resource_manager.py @@ -1,6 +1,7 @@ import pytest from pathlib import Path from tempfile import NamedTemporaryFile +from pydantic import AnyUrl, FileUrl from fastmcp.resources import ( FileResource, @@ -34,7 +35,7 @@ class TestResourceManager: """Test adding a resource.""" manager = ResourceManager() resource = FileResource( - uri=f"file://{temp_file}", + uri=FileUrl(f"file://{temp_file}"), name="test", path=temp_file, ) @@ -46,7 +47,7 @@ class TestResourceManager: """Test adding the same resource twice.""" manager = ResourceManager() resource = FileResource( - uri=f"file://{temp_file}", + uri=FileUrl(f"file://{temp_file}"), name="test", path=temp_file, ) @@ -59,7 +60,7 @@ class TestResourceManager: """Test warning on duplicate resources.""" manager = ResourceManager() resource = FileResource( - uri=f"file://{temp_file}", + uri=FileUrl(f"file://{temp_file}"), name="test", path=temp_file, ) @@ -71,7 +72,7 @@ class TestResourceManager: """Test disabling warning on duplicate resources.""" manager = ResourceManager(warn_on_duplicate_resources=False) resource = FileResource( - uri=f"file://{temp_file}", + uri=FileUrl(f"file://{temp_file}"), name="test", path=temp_file, ) @@ -83,7 +84,7 @@ class TestResourceManager: """Test getting a resource by URI.""" manager = ResourceManager() resource = FileResource( - uri=f"file://{temp_file}", + uri=FileUrl(f"file://{temp_file}"), name="test", path=temp_file, ) @@ -105,7 +106,7 @@ class TestResourceManager: ) manager._templates[template.uri_template] = template - resource = await manager.get_resource("greet://world") + resource = await manager.get_resource(AnyUrl("greet://world")) assert isinstance(resource, FunctionResource) content = await resource.read() assert content == "Hello, world!" @@ -114,18 +115,18 @@ class TestResourceManager: """Test getting a non-existent resource.""" manager = ResourceManager() with pytest.raises(ValueError, match="Unknown resource"): - await manager.get_resource("unknown://test") + await manager.get_resource(AnyUrl("unknown://test")) def test_list_resources(self, temp_file: Path): """Test listing all resources.""" manager = ResourceManager() resource1 = FileResource( - uri=f"file://{temp_file}", + uri=FileUrl(f"file://{temp_file}"), name="test1", path=temp_file, ) resource2 = FileResource( - uri=f"file://{temp_file}2", + uri=FileUrl(f"file://{temp_file}2"), name="test2", path=temp_file, ) diff --git a/tests/resources/test_resource_template.py b/tests/resources/test_resource_template.py index 08faf2e25..9b459d7b1 100644 --- a/tests/resources/test_resource_template.py +++ b/tests/resources/test_resource_template.py @@ -1,121 +1,72 @@ +import json import pytest -from fastmcp.resources import ResourceTemplate, FunctionResource +from pydantic import BaseModel + +from fastmcp.resources import FunctionResource, ResourceTemplate class TestResourceTemplate: """Test ResourceTemplate functionality.""" - def test_template_from_function(self): + def test_template_creation(self): """Test creating a template from a function.""" - def weather(city: str, units: str = "metric") -> str: - return f"Weather in {city} ({units})" + def my_func(key: str, value: int) -> dict: + return {"key": key, "value": value} template = ResourceTemplate.from_function( - fn=weather, - uri_template="weather://{city}/current", - name="weather", - description="Get current weather", + fn=my_func, + uri_template="test://{key}/{value}", + name="test", ) - - assert template.name == "weather" - assert template.uri_template == "weather://{city}/current" - assert template.mime_type == "text/plain" - assert "city" in template.parameters["properties"] - - def test_template_from_lambda_error(self): - """Test error when creating template from lambda without name.""" - with pytest.raises( - ValueError, match="You must provide a name for lambda functions" - ): - ResourceTemplate.from_function( - fn=lambda x: x, - uri_template="test://{x}", - ) + 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) def test_template_matches(self): - """Test URI matching against template.""" + """Test matching URIs against a template.""" - def dummy(x: str) -> str: - return x + def my_func(key: str, value: int) -> dict: + return {"key": key, "value": value} template = ResourceTemplate.from_function( - fn=dummy, - uri_template="test://{x}/value", + fn=my_func, + uri_template="test://{key}/{value}", name="test", ) - # Test matching URI - params = template.matches("test://hello/value") - assert params == {"x": "hello"} + # Valid match + params = template.matches("test://foo/123") + assert params == {"key": "foo", "value": "123"} - # Test non-matching URI - params = template.matches("test://hello/wrong") - assert params is None + # No match + assert template.matches("test://foo") is None + assert template.matches("other://foo/123") is None - async def test_create_text_resource(self): - """Test creating a text resource from template.""" + async def test_create_resource(self): + """Test creating a resource from a template.""" - def greet(name: str) -> str: - return f"Hello, {name}!" + def my_func(key: str, value: int) -> dict: + return {"key": key, "value": value} template = ResourceTemplate.from_function( - fn=greet, - uri_template="greet://{name}", - name="greeter", + fn=my_func, + uri_template="test://{key}/{value}", + name="test", ) resource = await template.create_resource( - "greet://world", - {"name": "world"}, + "test://foo/123", + {"key": "foo", "value": 123}, ) assert isinstance(resource, FunctionResource) content = await resource.read() - assert content == "Hello, world!" - - async def test_create_binary_resource(self): - """Test creating a binary resource from template.""" - - def get_bytes(value: str) -> bytes: - return value.encode() - - template = ResourceTemplate.from_function( - fn=get_bytes, - uri_template="bytes://{value}", - name="bytes", - ) - - resource = await template.create_resource( - "bytes://test", - {"value": "test"}, - ) - - assert isinstance(resource, FunctionResource) - content = await resource.read() - assert content == b"test" - - async def test_json_conversion(self): - """Test automatic JSON conversion of non-string/bytes results.""" - - def get_data(key: str) -> dict: - return {"key": key, "value": 123} - - template = ResourceTemplate.from_function( - fn=get_data, - uri_template="data://{key}", - name="data", - ) - - resource = await template.create_resource( - "data://test", - {"key": "test"}, - ) - - assert isinstance(resource, FunctionResource) - content = await resource.read() - assert '"key": "test"' in content - assert '"value": 123' in content + assert isinstance(content, str) + data = json.loads(content) + assert data == {"key": "foo", "value": 123} async def test_template_error(self): """Test error handling in template resource creation.""" @@ -174,65 +125,57 @@ class TestResourceTemplate: content = await resource.read() assert content == b"test" - async def test_async_json_conversion(self): - """Test automatic JSON conversion of async results.""" + async def test_basemodel_conversion(self): + """Test handling of BaseModel types.""" - async def get_data(key: str) -> dict: - return {"key": key, "value": 123} + class MyModel(BaseModel): + key: str + value: int + + def get_data(key: str, value: int) -> MyModel: + return MyModel(key=key, value=value) template = ResourceTemplate.from_function( fn=get_data, - uri_template="data://{key}", - name="data", + uri_template="test://{key}/{value}", + name="test", ) resource = await template.create_resource( - "data://test", - {"key": "test"}, + "test://foo/123", + {"key": "foo", "value": 123}, ) assert isinstance(resource, FunctionResource) content = await resource.read() - assert '"key": "test"' in content - assert '"value": 123' in content + assert isinstance(content, str) + data = json.loads(content) + assert data == {"key": "foo", "value": 123} - async def test_async_error(self): - """Test error handling in async template.""" + async def test_custom_type_conversion(self): + """Test handling of custom types.""" - async def failing_func(x: str) -> str: - raise ValueError("Test error") + class CustomData: + def __init__(self, value: str): + self.value = value + + def __str__(self) -> str: + return self.value + + def get_data(value: str) -> CustomData: + return CustomData(value) template = ResourceTemplate.from_function( - fn=failing_func, - uri_template="fail://{x}", - name="fail", - ) - - with pytest.raises( - ValueError, match="Error creating resource from template: Test error" - ): - await template.create_resource("fail://test", {"x": "test"}) - - async def test_sync_returning_coroutine(self): - """Test sync function that returns a coroutine.""" - - async def async_helper(name: str) -> str: - return f"Hello, {name}!" - - def get_greeting(name: str) -> str: - return async_helper(name) # Returns coroutine - - template = ResourceTemplate.from_function( - fn=get_greeting, - uri_template="greet://{name}", - name="greeter", + fn=get_data, + uri_template="test://{value}", + name="test", ) resource = await template.create_resource( - "greet://world", - {"name": "world"}, + "test://hello", + {"value": "hello"}, ) assert isinstance(resource, FunctionResource) content = await resource.read() - assert content == "Hello, world!" + assert content == "hello" diff --git a/tests/resources/test_resources.py b/tests/resources/test_resources.py index 0dc5a1b49..9eb3d3721 100644 --- a/tests/resources/test_resources.py +++ b/tests/resources/test_resources.py @@ -1,4 +1,5 @@ import pytest +from pydantic import AnyUrl from fastmcp.resources import FunctionResource, Resource @@ -14,7 +15,7 @@ class TestResourceValidation: # Valid URI resource = FunctionResource( - uri="http://example.com/data", + uri=AnyUrl("http://example.com/data"), name="test", fn=dummy_func, ) @@ -23,7 +24,7 @@ class TestResourceValidation: # Missing protocol with pytest.raises(ValueError, match="Input should be a valid URL"): FunctionResource( - uri="invalid", + uri=AnyUrl("invalid"), name="test", fn=dummy_func, ) @@ -31,7 +32,7 @@ class TestResourceValidation: # Missing host with pytest.raises(ValueError, match="Input should be a valid URL"): FunctionResource( - uri="http://", + uri=AnyUrl("http://"), name="test", fn=dummy_func, ) @@ -43,7 +44,7 @@ class TestResourceValidation: return "data" resource = FunctionResource( - uri="resource://my-resource", + uri=AnyUrl("resource://my-resource"), fn=dummy_func, ) assert resource.name == "resource://my-resource" @@ -62,7 +63,7 @@ class TestResourceValidation: # Explicit name takes precedence over URI resource = FunctionResource( - uri="resource://uri-name", + uri=AnyUrl("resource://uri-name"), name="explicit-name", fn=dummy_func, ) @@ -76,14 +77,14 @@ class TestResourceValidation: # Default mime type resource = FunctionResource( - uri="resource://test", + uri=AnyUrl("resource://test"), fn=dummy_func, ) assert resource.mime_type == "text/plain" # Custom mime type resource = FunctionResource( - uri="resource://test", + uri=AnyUrl("resource://test"), fn=dummy_func, mime_type="application/json", ) @@ -96,4 +97,4 @@ class TestResourceValidation: pass with pytest.raises(TypeError, match="abstract method"): - ConcreteResource(uri="test://test", name="test") # type: ignore + ConcreteResource(uri=AnyUrl("test://test"), name="test") # type: ignore diff --git a/tests/servers/test_file_server.py b/tests/servers/test_file_server.py index d8f53a388..1eb750eee 100644 --- a/tests/servers/test_file_server.py +++ b/tests/servers/test_file_server.py @@ -25,7 +25,7 @@ def mcp() -> FastMCP: @pytest.fixture(autouse=True) -def resources(mcp: FastMCP, test_dir: Path) -> None: +def resources(mcp: FastMCP, test_dir: Path) -> FastMCP: @mcp.resource("dir://test_dir") def list_test_dir() -> list[str]: """List the files in the test directory""" @@ -59,7 +59,7 @@ def resources(mcp: FastMCP, test_dir: Path) -> None: @pytest.fixture(autouse=True) -def tools(mcp: FastMCP, test_dir: Path) -> None: +def tools(mcp: FastMCP, test_dir: Path) -> FastMCP: @mcp.tool() def delete_file(path: str) -> bool: # ensure path is in test_dir @@ -68,6 +68,8 @@ def tools(mcp: FastMCP, test_dir: Path) -> None: Path(path).unlink() return True + return mcp + async def test_list_resources(mcp: FastMCP): resources = await mcp.list_resources() diff --git a/tests/test_cli.py b/tests/test_cli.py index a26874a59..fefd6ca20 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -320,7 +320,11 @@ mcp = FastMCP("test", dependencies=["pandas", "numpy"]) x in deps_section for x in ["--with", "numpy", "--with", "pandas"] ) - assert mock_run.call_args_list[1][1] == {"check": True, "shell": True} + # Verify subprocess call kwargs, allowing for environment variables + call_kwargs = mock_run.call_args_list[1][1] + assert call_kwargs["check"] is True + assert call_kwargs["shell"] is True + assert isinstance(call_kwargs["env"], dict) else: # same verification for unix, just with different command prefix actual_cmd = mock_run.call_args_list[0][0][0] @@ -342,7 +346,11 @@ mcp = FastMCP("test", dependencies=["pandas", "numpy"]) x in deps_section for x in ["--with", "numpy", "--with", "pandas"] ) - assert mock_run.call_args_list[0][1] == {"check": True, "shell": False} + # Verify subprocess call kwargs, allowing for environment variables + call_kwargs = mock_run.call_args_list[0][1] + assert call_kwargs["check"] is True + assert call_kwargs["shell"] is False + assert isinstance(call_kwargs["env"], dict) def test_run_with_dependencies(mock_config, server_file): diff --git a/tests/test_func_metadata.py b/tests/test_func_metadata.py index 980d2c503..d0429a12c 100644 --- a/tests/test_func_metadata.py +++ b/tests/test_func_metadata.py @@ -192,9 +192,9 @@ def test_skip_names(): assert "also_skip" not in meta.arg_model.model_fields # Validate that we can call with only non-skipped parameters - model = meta.arg_model.model_validate({"keep_this": 1, "also_keep": 2.5}) - assert model.keep_this == 1 - assert model.also_keep == 2.5 + model: BaseModel = meta.arg_model.model_validate({"keep_this": 1, "also_keep": 2.5}) # type: ignore + assert model.keep_this == 1 # type: ignore + assert model.also_keep == 2.5 # type: ignore async def test_lambda_function(): diff --git a/tests/test_server.py b/tests/test_server.py index 950cf2246..6303cbaea 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -7,7 +7,13 @@ from mcp.shared.exceptions import McpError from mcp.shared.memory import ( create_connected_server_and_client_session as client_session, ) -from mcp.types import ImageContent, TextContent +from mcp.types import ( + ImageContent, + TextContent, + TextResourceContents, + BlobResourceContents, +) +from pydantic import AnyUrl from fastmcp import Context, FastMCP from fastmcp.prompts.base import EmbeddedResource, Message, UserMessage @@ -100,7 +106,7 @@ class TestServerTools: mcp.add_tool(tool_fn) async with client_session(mcp._mcp_server) as client: result = await client.call_tool("my_tool", {"arg1": "value"}) - assert "error" not in result + assert not hasattr(result, "error") assert len(result.content) > 0 async def test_tool_exception_handling(self): @@ -109,29 +115,43 @@ class TestServerTools: async with client_session(mcp._mcp_server) as client: result = await client.call_tool("error_tool_fn", {}) assert len(result.content) == 1 - assert result.content[0].type == "text" - assert "Test error" in result.content[0].text + content = result.content[0] + assert isinstance(content, TextContent) + assert "Test error" in content.text assert result.isError is True - async def test_tool_exception_content(self): + async def test_tool_error_handling(self): + mcp = FastMCP() + mcp.add_tool(error_tool_fn) + async with client_session(mcp._mcp_server) as client: + result = await client.call_tool("error_tool_fn", {}) + assert len(result.content) == 1 + content = result.content[0] + assert isinstance(content, TextContent) + assert "Test error" in content.text + assert result.isError is True + + async def test_tool_error_details(self): """Test that exception details are properly formatted in the response""" mcp = FastMCP() mcp.add_tool(error_tool_fn) async with client_session(mcp._mcp_server) as client: result = await client.call_tool("error_tool_fn", {}) - assert result.content[0].type == "text" - assert isinstance(result.content[0].text, str) - assert "Test error" in result.content[0].text + content = result.content[0] + assert isinstance(content, TextContent) + assert isinstance(content.text, str) + assert "Test error" in content.text assert result.isError is True - async def test_tool_text_conversion(self): + async def test_tool_return_value_conversion(self): mcp = FastMCP() mcp.add_tool(tool_fn) async with client_session(mcp._mcp_server) as client: result = await client.call_tool("tool_fn", {"x": 1, "y": 2}) assert len(result.content) == 1 - assert result.content[0].type == "text" - assert result.content[0].text == "3" + content = result.content[0] + assert isinstance(content, TextContent) + assert content.text == "3" async def test_tool_image_helper(self, tmp_path: Path): # Create a test image @@ -143,10 +163,12 @@ class TestServerTools: async with client_session(mcp._mcp_server) as client: result = await client.call_tool("image_tool_fn", {"path": str(image_path)}) assert len(result.content) == 1 - assert result.content[0].type == "image" - assert result.content[0].mimeType == "image/png" + content = result.content[0] + assert isinstance(content, ImageContent) + assert content.type == "image" + assert content.mimeType == "image/png" # Verify base64 encoding - decoded = base64.b64decode(result.content[0].data) + decoded = base64.b64decode(content.data) assert decoded == b"fake png data" async def test_tool_mixed_content(self): @@ -155,11 +177,13 @@ class TestServerTools: async with client_session(mcp._mcp_server) as client: result = await client.call_tool("mixed_content_tool_fn", {}) assert len(result.content) == 2 - assert result.content[0].type == "text" - assert result.content[0].text == "Hello" - assert result.content[1].type == "image" - assert result.content[1].mimeType == "image/png" - assert result.content[1].data == "abc" + content1 = result.content[0] + content2 = result.content[1] + assert isinstance(content1, TextContent) + assert content1.text == "Hello" + assert isinstance(content2, ImageContent) + assert content2.mimeType == "image/png" + assert content2.data == "abc" async def test_tool_mixed_list_with_image(self, tmp_path: Path): """Test that lists containing Image objects and other types are handled correctly""" @@ -181,18 +205,22 @@ class TestServerTools: result = await client.call_tool("mixed_list_fn", {}) assert len(result.content) == 4 # Check text conversion - assert result.content[0].type == "text" - assert "text message" in result.content[0].text + content1 = result.content[0] + assert isinstance(content1, TextContent) + assert content1.text == "text message" # Check image conversion - assert result.content[1].type == "image" - assert result.content[1].mimeType == "image/png" - assert base64.b64decode(result.content[1].data) == b"test image data" + content2 = result.content[1] + assert isinstance(content2, ImageContent) + assert content2.mimeType == "image/png" + assert base64.b64decode(content2.data) == b"test image data" # Check dict conversion - assert result.content[2].type == "text" - assert '"key": "value"' in result.content[2].text + content3 = result.content[2] + assert isinstance(content3, TextContent) + assert '"key": "value"' in content3.text # Check direct TextContent - assert result.content[3].type == "text" - assert result.content[3].text == "direct content" + content4 = result.content[3] + assert isinstance(content4, TextContent) + assert content4.text == "direct content" class TestServerResources: @@ -202,11 +230,14 @@ class TestServerResources: def get_text(): return "Hello, world!" - resource = FunctionResource(uri="resource://test", name="test", fn=get_text) + resource = FunctionResource( + uri=AnyUrl("resource://test"), name="test", fn=get_text + ) mcp.add_resource(resource) async with client_session(mcp._mcp_server) as client: - result = await client.read_resource("resource://test") + result = await client.read_resource(AnyUrl("resource://test")) + assert isinstance(result.contents[0], TextResourceContents) assert result.contents[0].text == "Hello, world!" async def test_binary_resource(self): @@ -216,16 +247,16 @@ class TestServerResources: return b"Binary data" resource = FunctionResource( - uri="resource://binary", + uri=AnyUrl("resource://binary"), name="binary", fn=get_binary, - is_binary=True, mime_type="application/octet-stream", ) mcp.add_resource(resource) async with client_session(mcp._mcp_server) as client: - result = await client.read_resource("resource://binary") + result = await client.read_resource(AnyUrl("resource://binary")) + assert isinstance(result.contents[0], BlobResourceContents) assert result.contents[0].blob == base64.b64encode(b"Binary data").decode() async def test_file_resource_text(self, tmp_path: Path): @@ -235,11 +266,14 @@ class TestServerResources: text_file = tmp_path / "test.txt" text_file.write_text("Hello from file!") - resource = FileResource(uri="file://test.txt", name="test.txt", path=text_file) + resource = FileResource( + uri=AnyUrl("file://test.txt"), name="test.txt", path=text_file + ) mcp.add_resource(resource) async with client_session(mcp._mcp_server) as client: - result = await client.read_resource("file://test.txt") + result = await client.read_resource(AnyUrl("file://test.txt")) + assert isinstance(result.contents[0], TextResourceContents) assert result.contents[0].text == "Hello from file!" async def test_file_resource_binary(self, tmp_path: Path): @@ -250,16 +284,16 @@ class TestServerResources: binary_file.write_bytes(b"Binary file data") resource = FileResource( - uri="file://test.bin", + uri=AnyUrl("file://test.bin"), name="test.bin", path=binary_file, - is_binary=True, mime_type="application/octet-stream", ) mcp.add_resource(resource) async with client_session(mcp._mcp_server) as client: - result = await client.read_resource("file://test.bin") + result = await client.read_resource(AnyUrl("file://test.bin")) + assert isinstance(result.contents[0], BlobResourceContents) assert ( result.contents[0].blob == base64.b64encode(b"Binary file data").decode() @@ -275,7 +309,7 @@ class TestServerResourceTemplates: with pytest.raises(ValueError, match="Mismatch between URI parameters"): @mcp.resource("resource://data") - def get_data(param: str) -> str: + def get_data_fn(param: str) -> str: return f"Data: {param}" async def test_resource_with_uri_params(self): @@ -305,7 +339,8 @@ class TestServerResourceTemplates: return f"Data for {name}" async with client_session(mcp._mcp_server) as client: - result = await client.read_resource("resource://test/data") + result = await client.read_resource(AnyUrl("resource://test/data")) + assert isinstance(result.contents[0], TextResourceContents) assert result.contents[0].text == "Data for test" async def test_resource_mismatched_params(self): @@ -327,7 +362,10 @@ class TestServerResourceTemplates: return f"Data for {org}/{repo}" async with client_session(mcp._mcp_server) as client: - result = await client.read_resource("resource://cursor/fastmcp/data") + result = await client.read_resource( + AnyUrl("resource://cursor/fastmcp/data") + ) + assert isinstance(result.contents[0], TextResourceContents) assert result.contents[0].text == "Data for cursor/fastmcp" async def test_resource_multiple_mismatched_params(self): @@ -337,18 +375,19 @@ class TestServerResourceTemplates: with pytest.raises(ValueError, match="Mismatch between URI parameters"): @mcp.resource("resource://{org}/{repo}/data") - def get_data(org: str, repo_2: str) -> str: + def get_data_mismatched(org: str, repo_2: str) -> str: return f"Data for {org}" """Test that a resource with no parameters works as a regular resource""" mcp = FastMCP() @mcp.resource("resource://static") - def get_data() -> str: + def get_static_data() -> str: return "Static data" async with client_session(mcp._mcp_server) as client: - result = await client.read_resource("resource://static") + result = await client.read_resource(AnyUrl("resource://static")) + assert isinstance(result.contents[0], TextResourceContents) assert result.contents[0].text == "Static data" async def test_template_to_resource_conversion(self): @@ -395,8 +434,10 @@ class TestContextInjection: async with client_session(mcp._mcp_server) as client: result = await client.call_tool("tool_with_context", {"x": 42}) assert len(result.content) == 1 - assert "Request" in result.content[0].text - assert "42" in result.content[0].text + content = result.content[0] + assert isinstance(content, TextContent) + assert "Request" in content.text + assert "42" in content.text async def test_async_context(self): """Test that context works in async functions.""" @@ -410,8 +451,10 @@ class TestContextInjection: async with client_session(mcp._mcp_server) as client: result = await client.call_tool("async_tool", {"x": 42}) assert len(result.content) == 1 - assert "Async request" in result.content[0].text - assert "42" in result.content[0].text + content = result.content[0] + assert isinstance(content, TextContent) + assert "Async request" in content.text + assert "42" in content.text async def test_context_logging(self): """Test that context logging methods work.""" @@ -428,7 +471,9 @@ class TestContextInjection: async with client_session(mcp._mcp_server) as client: result = await client.call_tool("logging_tool", {"msg": "test"}) assert len(result.content) == 1 - assert "Logged messages for test" in result.content[0].text + content = result.content[0] + assert isinstance(content, TextContent) + assert "Logged messages for test" in content.text async def test_optional_context(self): """Test that context is optional.""" @@ -441,7 +486,9 @@ class TestContextInjection: async with client_session(mcp._mcp_server) as client: result = await client.call_tool("no_context", {"x": 21}) assert len(result.content) == 1 - assert result.content[0].text == "42" + content = result.content[0] + assert isinstance(content, TextContent) + assert content.text == "42" async def test_context_resource_access(self): """Test that context can access resources.""" @@ -459,7 +506,9 @@ class TestContextInjection: async with client_session(mcp._mcp_server) as client: result = await client.call_tool("tool_with_resource", {}) assert len(result.content) == 1 - assert "Read resource: resource data" in result.content[0].text + content = result.content[0] + assert isinstance(content, TextContent) + assert "Read resource: resource data" in content.text class TestServerPrompts: @@ -477,23 +526,26 @@ class TestServerPrompts: assert len(prompts) == 1 assert prompts[0].name == "fn" # Don't compare functions directly since validate_call wraps them - assert await prompts[0].render() == [ - UserMessage(content=TextContent(type="text", text="Hello, world!")) - ] + content = await prompts[0].render() + assert isinstance(content[0].content, TextContent) + assert content[0].content.text == "Hello, world!" - def test_prompt_decorator_with_name(self): + async def test_prompt_decorator_with_name(self): """Test prompt decorator with custom name.""" mcp = FastMCP() - @mcp.prompt(name="custom") + @mcp.prompt(name="custom_name") def fn() -> str: return "Hello, world!" prompts = mcp._prompt_manager.list_prompts() assert len(prompts) == 1 - assert prompts[0].name == "custom" + assert prompts[0].name == "custom_name" + content = await prompts[0].render() + assert isinstance(content[0].content, TextContent) + assert content[0].content.text == "Hello, world!" - def test_prompt_decorator_with_description(self): + async def test_prompt_decorator_with_description(self): """Test prompt decorator with custom description.""" mcp = FastMCP() @@ -504,13 +556,16 @@ class TestServerPrompts: prompts = mcp._prompt_manager.list_prompts() assert len(prompts) == 1 assert prompts[0].description == "A custom description" + content = await prompts[0].render() + assert isinstance(content[0].content, TextContent) + assert content[0].content.text == "Hello, world!" def test_prompt_decorator_error(self): """Test error when decorator is used incorrectly.""" mcp = FastMCP() with pytest.raises(TypeError, match="decorator was used incorrectly"): - @mcp.prompt + @mcp.prompt # type: ignore def fn() -> str: return "Hello, world!" @@ -524,13 +579,16 @@ class TestServerPrompts: async with client_session(mcp._mcp_server) as client: result = await client.list_prompts() + assert result.prompts is not None assert len(result.prompts) == 1 - assert result.prompts[0].name == "fn" - assert len(result.prompts[0].arguments) == 2 - assert result.prompts[0].arguments[0].name == "name" - assert result.prompts[0].arguments[0].required is True - assert result.prompts[0].arguments[1].name == "optional" - assert result.prompts[0].arguments[1].required is False + prompt = result.prompts[0] + assert prompt.name == "fn" + assert prompt.arguments is not None + assert len(prompt.arguments) == 2 + assert prompt.arguments[0].name == "name" + assert prompt.arguments[0].required is True + assert prompt.arguments[1].name == "optional" + assert prompt.arguments[1].required is False async def test_get_prompt(self): """Test getting a prompt through MCP protocol.""" @@ -543,9 +601,11 @@ class TestServerPrompts: async with client_session(mcp._mcp_server) as client: result = await client.get_prompt("fn", {"name": "World"}) assert len(result.messages) == 1 - assert result.messages[0].role == "user" - assert result.messages[0].content.type == "text" - assert result.messages[0].content.text == "Hello, World!" + message = result.messages[0] + assert message.role == "user" + content = message.content + assert isinstance(content, TextContent) + assert content.text == "Hello, World!" async def test_get_prompt_with_resource(self): """Test getting a prompt that returns resource content.""" @@ -556,22 +616,25 @@ class TestServerPrompts: return UserMessage( content=EmbeddedResource( type="resource", - resource={ - "uri": "file://test.txt", - "text": "File contents", - "mimeType": "text/plain", - }, + resource=TextResourceContents( + uri=AnyUrl("file://file.txt"), + text="File contents", + mimeType="text/plain", + ), ) ) async with client_session(mcp._mcp_server) as client: result = await client.get_prompt("fn") assert len(result.messages) == 1 - assert result.messages[0].role == "user" - assert result.messages[0].content.type == "resource" - assert str(result.messages[0].content.resource.uri) == "file://test.txt/" - assert result.messages[0].content.resource.text == "File contents" - assert result.messages[0].content.resource.mimeType == "text/plain" + message = result.messages[0] + assert message.role == "user" + content = message.content + assert isinstance(content, EmbeddedResource) + resource = content.resource + assert isinstance(resource, TextResourceContents) + assert resource.text == "File contents" + assert resource.mimeType == "text/plain" async def test_get_unknown_prompt(self): """Test error when getting unknown prompt.""" @@ -585,9 +648,9 @@ class TestServerPrompts: mcp = FastMCP() @mcp.prompt() - def fn(name: str) -> str: + def prompt_fn(name: str) -> str: return f"Hello, {name}!" async with client_session(mcp._mcp_server) as client: with pytest.raises(McpError, match="Missing required arguments"): - await client.get_prompt("fn") + await client.get_prompt("prompt_fn") diff --git a/uv.lock b/uv.lock index 748660de0..31fdfa73c 100644 --- a/uv.lock +++ b/uv.lock @@ -228,7 +228,7 @@ wheels = [ [[package]] name = "fastmcp" -version = "0.3.6.dev0+gf03184b.d20241203" +version = "0.3.6.dev5+g6a13ab9.d20241203" source = { editable = "." } dependencies = [ { name = "httpx" }, @@ -245,6 +245,7 @@ dev = [ { name = "ipython" }, { name = "pdbpp" }, { name = "pre-commit" }, + { name = "pyright" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-flakefinder" }, @@ -253,6 +254,7 @@ dev = [ ] tests = [ { name = "pre-commit" }, + { name = "pyright" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-flakefinder" }, @@ -271,6 +273,8 @@ requires-dist = [ { name = "pre-commit", marker = "extra == 'tests'" }, { name = "pydantic", specifier = ">=2.5.3,<3.0.0" }, { name = "pydantic-settings", specifier = ">=2.6.1" }, + { name = "pyright", marker = "extra == 'dev'", specifier = ">=1.1.389" }, + { name = "pyright", marker = "extra == 'tests'", specifier = ">=1.1.389" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.3" }, { name = "pytest", marker = "extra == 'tests'", specifier = ">=8.3.3" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.5" }, @@ -730,6 +734,19 @@ version = "0.9.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/05/1b/ea40363be0056080454cdbabe880773c3c5bd66d7b13f0c8b8b8c8da1e0c/pyrepl-0.9.0.tar.gz", hash = "sha256:292570f34b5502e871bbb966d639474f2b57fbfcd3373c2d6a2f3d56e681a775", size = 48744 } +[[package]] +name = "pyright" +version = "1.1.389" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/4e/9a5ab8745e7606b88c2c7ca223449ac9d82a71fd5e31df47b453f2cb39a1/pyright-1.1.389.tar.gz", hash = "sha256:716bf8cc174ab8b4dcf6828c3298cac05c5ed775dda9910106a5dcfe4c7fe220", size = 21940 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/26/c288cabf8cfc5a27e1aa9e5029b7682c0f920b8074f45d22bf844314d66a/pyright-1.1.389-py3-none-any.whl", hash = "sha256:41e9620bba9254406dc1f621a88ceab5a88af4c826feb4f614d95691ed243a60", size = 18581 }, +] + [[package]] name = "pytest" version = "8.3.3"