From f1abe01b57474c08c6b5fdf031d8f8c66d6dcd26 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 3 Dec 2024 12:27:02 -0500 Subject: [PATCH] Add pyright --- .../workflows/{lint.yml => run-static.yml} | 0 .pre-commit-config.yaml | 5 +++++ pyproject.toml | 13 +++++++++++++ src/fastmcp/cli/cli.py | 7 ++++++- src/fastmcp/resources/base.py | 18 +++++------------- src/fastmcp/resources/templates.py | 2 +- src/fastmcp/server.py | 13 +++++++------ src/fastmcp/utilities/func_metadata.py | 11 ++++++++--- src/fastmcp/utilities/types.py | 4 +++- tests/test_cli.py | 12 ++++++++++-- uv.lock | 19 ++++++++++++++++++- 11 files changed, 76 insertions(+), 28 deletions(-) rename .github/workflows/{lint.yml => run-static.yml} (100%) diff --git a/.github/workflows/lint.yml b/.github/workflows/run-static.yml similarity index 100% rename from .github/workflows/lint.yml rename to .github/workflows/run-static.yml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9d6e1982b..306bc023e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -18,3 +18,8 @@ repos: - id: ruff-format - id: ruff args: [--fix, --exit-non-zero-on-fix] + + - repo: https://github.com/RobertCraigie/pyright-python + rev: v1.1.352 + hooks: + - id: pyright diff --git a/pyproject.toml b/pyproject.toml index 15f0848ae..3bf8fac9c 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"] +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/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/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/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/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"