Jeremiah Lowin commited on
Commit
ad0696c
·
1 Parent(s): 18f9037

Add pyright

Browse files
.github/workflows/{lint.yml → run-static.yml} RENAMED
File without changes
.pre-commit-config.yaml CHANGED
@@ -18,3 +18,8 @@ repos:
18
  - id: ruff-format
19
  - id: ruff
20
  args: [--fix, --exit-non-zero-on-fix]
 
 
 
 
 
 
18
  - id: ruff-format
19
  - id: ruff
20
  args: [--fix, --exit-non-zero-on-fix]
21
+
22
+ - repo: https://github.com/RobertCraigie/pyright-python
23
+ rev: v1.1.352
24
+ hooks:
25
+ - id: pyright
pyproject.toml CHANGED
@@ -25,6 +25,7 @@ build-backend = "hatchling.build"
25
  [project.optional-dependencies]
26
  tests = [
27
  "pre-commit",
 
28
  "pytest>=8.3.3",
29
  "pytest-asyncio>=0.23.5",
30
  "pytest-flakefinder",
@@ -39,3 +40,15 @@ asyncio_default_fixture_loop_scope = "session"
39
 
40
  [tool.hatch.version]
41
  source = "vcs"
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  [project.optional-dependencies]
26
  tests = [
27
  "pre-commit",
28
+ "pyright>=1.1.389",
29
  "pytest>=8.3.3",
30
  "pytest-asyncio>=0.23.5",
31
  "pytest-flakefinder",
 
40
 
41
  [tool.hatch.version]
42
  source = "vcs"
43
+
44
+ [tool.pyright]
45
+ include = ["src"]
46
+ exclude = ["**/node_modules", "**/__pycache__", ".venv", ".git", "dist"]
47
+ pythonVersion = "3.10"
48
+ pythonPlatform = "Darwin"
49
+ typeCheckingMode = "basic"
50
+ reportMissingImports = true
51
+ reportMissingTypeStubs = false
52
+ useLibraryCodeForTypes = true
53
+ venvPath = "."
54
+ venv = ".venv"
src/fastmcp/cli/cli.py CHANGED
@@ -2,6 +2,7 @@
2
 
3
  import importlib.metadata
4
  import importlib.util
 
5
  import subprocess
6
  import sys
7
  from pathlib import Path
@@ -242,6 +243,7 @@ def dev(
242
  [npx_cmd, "@modelcontextprotocol/inspector"] + uv_cmd,
243
  check=True,
244
  shell=shell,
 
245
  )
246
  sys.exit(process.returncode)
247
  except subprocess.CalledProcessError as e:
@@ -423,7 +425,10 @@ def install(
423
  # Load from .env file if specified
424
  if env_file:
425
  try:
426
- env_dict.update(dotenv.dotenv_values(env_file))
 
 
 
427
  except Exception as e:
428
  logger.error(f"Failed to load .env file: {e}")
429
  sys.exit(1)
 
2
 
3
  import importlib.metadata
4
  import importlib.util
5
+ import os
6
  import subprocess
7
  import sys
8
  from pathlib import Path
 
243
  [npx_cmd, "@modelcontextprotocol/inspector"] + uv_cmd,
244
  check=True,
245
  shell=shell,
246
+ env=dict(os.environ.items()), # Convert to list of tuples for env update
247
  )
248
  sys.exit(process.returncode)
249
  except subprocess.CalledProcessError as e:
 
425
  # Load from .env file if specified
426
  if env_file:
427
  try:
428
+ env_values = dotenv.dotenv_values(env_file)
429
+ env_dict.update(
430
+ (k, str(v)) for k, v in env_values.items() if v is not None
431
+ )
432
  except Exception as e:
433
  logger.error(f"Failed to load .env file: {e}")
434
  sys.exit(1)
src/fastmcp/resources/base.py CHANGED
@@ -1,14 +1,14 @@
1
  """Base classes and interfaces for FastMCP resources."""
2
 
3
  import abc
4
- from typing import Union
5
 
6
  from pydantic import (
7
  AnyUrl,
8
  BaseModel,
9
  ConfigDict,
10
  Field,
11
- FileUrl,
12
  ValidationInfo,
13
  field_validator,
14
  )
@@ -19,8 +19,9 @@ class Resource(BaseModel, abc.ABC):
19
 
20
  model_config = ConfigDict(validate_default=True)
21
 
22
- # uri: Annotated[AnyUrl, BeforeValidator(maybe_cast_str_to_any_url)] = Field(
23
- uri: AnyUrl = Field(default=..., description="URI of the resource")
 
24
  name: str | None = Field(description="Name of the resource", default=None)
25
  description: str | None = Field(
26
  description="Description of the resource", default=None
@@ -31,15 +32,6 @@ class Resource(BaseModel, abc.ABC):
31
  pattern=r"^[a-zA-Z0-9]+/[a-zA-Z0-9\-+.]+$",
32
  )
33
 
34
- @field_validator("uri", mode="before")
35
- def validate_uri(cls, uri: AnyUrl | str) -> AnyUrl:
36
- if isinstance(uri, str):
37
- # AnyUrl doesn't support triple-slashes, but files do ("file:///absolute/path")
38
- if uri.startswith("file://"):
39
- return FileUrl(uri)
40
- return AnyUrl(uri)
41
- return uri
42
-
43
  @field_validator("name", mode="before")
44
  @classmethod
45
  def set_default_name(cls, name: str | None, info: ValidationInfo) -> str:
 
1
  """Base classes and interfaces for FastMCP resources."""
2
 
3
  import abc
4
+ from typing import Union, Annotated
5
 
6
  from pydantic import (
7
  AnyUrl,
8
  BaseModel,
9
  ConfigDict,
10
  Field,
11
+ UrlConstraints,
12
  ValidationInfo,
13
  field_validator,
14
  )
 
19
 
20
  model_config = ConfigDict(validate_default=True)
21
 
22
+ uri: Annotated[AnyUrl, UrlConstraints(host_required=False)] = Field(
23
+ default=..., description="URI of the resource"
24
+ )
25
  name: str | None = Field(description="Name of the resource", default=None)
26
  description: str | None = Field(
27
  description="Description of the resource", default=None
 
32
  pattern=r"^[a-zA-Z0-9]+/[a-zA-Z0-9\-+.]+$",
33
  )
34
 
 
 
 
 
 
 
 
 
 
35
  @field_validator("name", mode="before")
36
  @classmethod
37
  def set_default_name(cls, name: str | None, info: ValidationInfo) -> str:
src/fastmcp/resources/templates.py CHANGED
@@ -70,7 +70,7 @@ class ResourceTemplate(BaseModel):
70
  result = await result
71
 
72
  return FunctionResource(
73
- uri=uri,
74
  name=self.name,
75
  description=self.description,
76
  mime_type=self.mime_type,
 
70
  result = await result
71
 
72
  return FunctionResource(
73
+ uri=uri, # type: ignore
74
  name=self.name,
75
  description=self.description,
76
  mime_type=self.mime_type,
src/fastmcp/server.py CHANGED
@@ -23,6 +23,7 @@ from mcp.types import (
23
  )
24
  from mcp.types import (
25
  Prompt as MCPPrompt,
 
26
  )
27
  from mcp.types import (
28
  Resource as MCPResource,
@@ -159,7 +160,7 @@ class FastMCP:
159
 
160
  async def call_tool(
161
  self, name: str, arguments: dict
162
- ) -> Sequence[TextContent | ImageContent]:
163
  """Call a tool by name with arguments."""
164
  context = self.get_context()
165
  result = await self._tool_manager.call_tool(name, arguments, context=context)
@@ -462,11 +463,11 @@ class FastMCP:
462
  name=prompt.name,
463
  description=prompt.description,
464
  arguments=[
465
- {
466
- "name": arg.name,
467
- "description": arg.description,
468
- "required": arg.required,
469
- }
470
  for arg in (prompt.arguments or [])
471
  ],
472
  )
 
23
  )
24
  from mcp.types import (
25
  Prompt as MCPPrompt,
26
+ PromptArgument as MCPPromptArgument,
27
  )
28
  from mcp.types import (
29
  Resource as MCPResource,
 
160
 
161
  async def call_tool(
162
  self, name: str, arguments: dict
163
+ ) -> Sequence[TextContent | ImageContent | EmbeddedResource]:
164
  """Call a tool by name with arguments."""
165
  context = self.get_context()
166
  result = await self._tool_manager.call_tool(name, arguments, context=context)
 
463
  name=prompt.name,
464
  description=prompt.description,
465
  arguments=[
466
+ MCPPromptArgument(
467
+ name=arg.name,
468
+ description=arg.description,
469
+ required=arg.required,
470
+ )
471
  for arg in (prompt.arguments or [])
472
  ],
473
  )
src/fastmcp/utilities/func_metadata.py CHANGED
@@ -47,7 +47,7 @@ class FuncMetadata(BaseModel):
47
 
48
  async def call_fn_with_arg_validation(
49
  self,
50
- fn: Callable | Awaitable,
51
  fn_is_async: bool,
52
  arguments_to_validate: dict[str, Any],
53
  arguments_to_pass_directly: dict[str, Any] | None,
@@ -64,8 +64,12 @@ class FuncMetadata(BaseModel):
64
  arguments_parsed_dict |= arguments_to_pass_directly or {}
65
 
66
  if fn_is_async:
 
 
67
  return await fn(**arguments_parsed_dict)
68
- return fn(**arguments_parsed_dict)
 
 
69
 
70
  def pre_parse_json(self, data: dict[str, Any]) -> dict[str, Any]:
71
  """Pre-parse data from JSON.
@@ -123,6 +127,7 @@ def func_metadata(func: Callable, skip_names: Sequence[str] = ()) -> FuncMetadat
123
  sig = _get_typed_signature(func)
124
  params = sig.parameters
125
  dynamic_pydantic_model_params: dict[str, Any] = {}
 
126
  for param in params.values():
127
  if param.name.startswith("_"):
128
  raise InvalidSignature(
@@ -153,7 +158,7 @@ def func_metadata(func: Callable, skip_names: Sequence[str] = ()) -> FuncMetadat
153
  ]
154
 
155
  field_info = FieldInfo.from_annotated_attribute(
156
- annotation,
157
  param.default
158
  if param.default is not inspect.Parameter.empty
159
  else PydanticUndefined,
 
47
 
48
  async def call_fn_with_arg_validation(
49
  self,
50
+ fn: Callable[..., Any] | Awaitable[Any],
51
  fn_is_async: bool,
52
  arguments_to_validate: dict[str, Any],
53
  arguments_to_pass_directly: dict[str, Any] | None,
 
64
  arguments_parsed_dict |= arguments_to_pass_directly or {}
65
 
66
  if fn_is_async:
67
+ if isinstance(fn, Awaitable):
68
+ return await fn
69
  return await fn(**arguments_parsed_dict)
70
+ if isinstance(fn, Callable):
71
+ return fn(**arguments_parsed_dict)
72
+ raise TypeError("fn must be either Callable or Awaitable")
73
 
74
  def pre_parse_json(self, data: dict[str, Any]) -> dict[str, Any]:
75
  """Pre-parse data from JSON.
 
127
  sig = _get_typed_signature(func)
128
  params = sig.parameters
129
  dynamic_pydantic_model_params: dict[str, Any] = {}
130
+ globalns = getattr(func, "__globals__", {})
131
  for param in params.values():
132
  if param.name.startswith("_"):
133
  raise InvalidSignature(
 
158
  ]
159
 
160
  field_info = FieldInfo.from_annotated_attribute(
161
+ _get_typed_annotation(annotation, globalns),
162
  param.default
163
  if param.default is not inspect.Parameter.empty
164
  else PydanticUndefined,
src/fastmcp/utilities/types.py CHANGED
@@ -47,7 +47,9 @@ class Image:
47
  if self.path:
48
  with open(self.path, "rb") as f:
49
  data = base64.b64encode(f.read()).decode()
50
- else:
51
  data = base64.b64encode(self.data).decode()
 
 
52
 
53
  return ImageContent(type="image", data=data, mimeType=self._mime_type)
 
47
  if self.path:
48
  with open(self.path, "rb") as f:
49
  data = base64.b64encode(f.read()).decode()
50
+ elif self.data is not None:
51
  data = base64.b64encode(self.data).decode()
52
+ else:
53
+ raise ValueError("No image data available")
54
 
55
  return ImageContent(type="image", data=data, mimeType=self._mime_type)
tests/test_cli.py CHANGED
@@ -320,7 +320,11 @@ mcp = FastMCP("test", dependencies=["pandas", "numpy"])
320
  x in deps_section for x in ["--with", "numpy", "--with", "pandas"]
321
  )
322
 
323
- assert mock_run.call_args_list[1][1] == {"check": True, "shell": True}
 
 
 
 
324
  else:
325
  # same verification for unix, just with different command prefix
326
  actual_cmd = mock_run.call_args_list[0][0][0]
@@ -342,7 +346,11 @@ mcp = FastMCP("test", dependencies=["pandas", "numpy"])
342
  x in deps_section for x in ["--with", "numpy", "--with", "pandas"]
343
  )
344
 
345
- assert mock_run.call_args_list[0][1] == {"check": True, "shell": False}
 
 
 
 
346
 
347
 
348
  def test_run_with_dependencies(mock_config, server_file):
 
320
  x in deps_section for x in ["--with", "numpy", "--with", "pandas"]
321
  )
322
 
323
+ # Verify subprocess call kwargs, allowing for environment variables
324
+ call_kwargs = mock_run.call_args_list[1][1]
325
+ assert call_kwargs["check"] is True
326
+ assert call_kwargs["shell"] is True
327
+ assert isinstance(call_kwargs["env"], dict)
328
  else:
329
  # same verification for unix, just with different command prefix
330
  actual_cmd = mock_run.call_args_list[0][0][0]
 
346
  x in deps_section for x in ["--with", "numpy", "--with", "pandas"]
347
  )
348
 
349
+ # Verify subprocess call kwargs, allowing for environment variables
350
+ call_kwargs = mock_run.call_args_list[0][1]
351
+ assert call_kwargs["check"] is True
352
+ assert call_kwargs["shell"] is False
353
+ assert isinstance(call_kwargs["env"], dict)
354
 
355
 
356
  def test_run_with_dependencies(mock_config, server_file):
uv.lock CHANGED
@@ -228,7 +228,7 @@ wheels = [
228
 
229
  [[package]]
230
  name = "fastmcp"
231
- version = "0.3.6.dev0+gf03184b.d20241203"
232
  source = { editable = "." }
233
  dependencies = [
234
  { name = "httpx" },
@@ -245,6 +245,7 @@ dev = [
245
  { name = "ipython" },
246
  { name = "pdbpp" },
247
  { name = "pre-commit" },
 
248
  { name = "pytest" },
249
  { name = "pytest-asyncio" },
250
  { name = "pytest-flakefinder" },
@@ -253,6 +254,7 @@ dev = [
253
  ]
254
  tests = [
255
  { name = "pre-commit" },
 
256
  { name = "pytest" },
257
  { name = "pytest-asyncio" },
258
  { name = "pytest-flakefinder" },
@@ -271,6 +273,8 @@ requires-dist = [
271
  { name = "pre-commit", marker = "extra == 'tests'" },
272
  { name = "pydantic", specifier = ">=2.5.3,<3.0.0" },
273
  { name = "pydantic-settings", specifier = ">=2.6.1" },
 
 
274
  { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.3" },
275
  { name = "pytest", marker = "extra == 'tests'", specifier = ">=8.3.3" },
276
  { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.5" },
@@ -730,6 +734,19 @@ version = "0.9.0"
730
  source = { registry = "https://pypi.org/simple" }
731
  sdist = { url = "https://files.pythonhosted.org/packages/05/1b/ea40363be0056080454cdbabe880773c3c5bd66d7b13f0c8b8b8c8da1e0c/pyrepl-0.9.0.tar.gz", hash = "sha256:292570f34b5502e871bbb966d639474f2b57fbfcd3373c2d6a2f3d56e681a775", size = 48744 }
732
 
 
 
 
 
 
 
 
 
 
 
 
 
 
733
  [[package]]
734
  name = "pytest"
735
  version = "8.3.3"
 
228
 
229
  [[package]]
230
  name = "fastmcp"
231
+ version = "0.3.6.dev5+g6a13ab9.d20241203"
232
  source = { editable = "." }
233
  dependencies = [
234
  { name = "httpx" },
 
245
  { name = "ipython" },
246
  { name = "pdbpp" },
247
  { name = "pre-commit" },
248
+ { name = "pyright" },
249
  { name = "pytest" },
250
  { name = "pytest-asyncio" },
251
  { name = "pytest-flakefinder" },
 
254
  ]
255
  tests = [
256
  { name = "pre-commit" },
257
+ { name = "pyright" },
258
  { name = "pytest" },
259
  { name = "pytest-asyncio" },
260
  { name = "pytest-flakefinder" },
 
273
  { name = "pre-commit", marker = "extra == 'tests'" },
274
  { name = "pydantic", specifier = ">=2.5.3,<3.0.0" },
275
  { name = "pydantic-settings", specifier = ">=2.6.1" },
276
+ { name = "pyright", marker = "extra == 'dev'", specifier = ">=1.1.389" },
277
+ { name = "pyright", marker = "extra == 'tests'", specifier = ">=1.1.389" },
278
  { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.3" },
279
  { name = "pytest", marker = "extra == 'tests'", specifier = ">=8.3.3" },
280
  { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.5" },
 
734
  source = { registry = "https://pypi.org/simple" }
735
  sdist = { url = "https://files.pythonhosted.org/packages/05/1b/ea40363be0056080454cdbabe880773c3c5bd66d7b13f0c8b8b8c8da1e0c/pyrepl-0.9.0.tar.gz", hash = "sha256:292570f34b5502e871bbb966d639474f2b57fbfcd3373c2d6a2f3d56e681a775", size = 48744 }
736
 
737
+ [[package]]
738
+ name = "pyright"
739
+ version = "1.1.389"
740
+ source = { registry = "https://pypi.org/simple" }
741
+ dependencies = [
742
+ { name = "nodeenv" },
743
+ { name = "typing-extensions" },
744
+ ]
745
+ sdist = { url = "https://files.pythonhosted.org/packages/72/4e/9a5ab8745e7606b88c2c7ca223449ac9d82a71fd5e31df47b453f2cb39a1/pyright-1.1.389.tar.gz", hash = "sha256:716bf8cc174ab8b4dcf6828c3298cac05c5ed775dda9910106a5dcfe4c7fe220", size = 21940 }
746
+ wheels = [
747
+ { url = "https://files.pythonhosted.org/packages/1b/26/c288cabf8cfc5a27e1aa9e5029b7682c0f920b8074f45d22bf844314d66a/pyright-1.1.389-py3-none-any.whl", hash = "sha256:41e9620bba9254406dc1f621a88ceab5a88af4c826feb4f614d95691ed243a60", size = 18581 },
748
+ ]
749
+
750
  [[package]]
751
  name = "pytest"
752
  version = "8.3.3"