Add pyright

This commit is contained in:
Jeremiah Lowin 2024-12-03 12:27:02 -05:00
commit f1abe01b57
11 changed files with 76 additions and 28 deletions

View file

@ -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

View file

@ -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"

View file

@ -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)

View file

@ -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:

View file

@ -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,

View file

@ -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 [])
],
)

View file

@ -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,

View file

@ -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)

View file

@ -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):

19
uv.lock generated
View file

@ -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"