Jeremiah Lowin commited on
Commit
9cdc3fb
·
unverified ·
2 Parent(s): d662e59a84c433

Merge pull request #291 from jlowin/typing

Browse files
src/fastmcp/prompts/prompt.py CHANGED
@@ -13,7 +13,10 @@ from mcp.types import Prompt as MCPPrompt
13
  from mcp.types import PromptArgument as MCPPromptArgument
14
  from pydantic import BaseModel, BeforeValidator, Field, TypeAdapter, validate_call
15
 
16
- from fastmcp.utilities.types import _convert_set_defaults
 
 
 
17
 
18
  if TYPE_CHECKING:
19
  from mcp.server.session import ServerSessionT
@@ -115,7 +118,7 @@ class Prompt(BaseModel):
115
  else:
116
  sig = inspect.signature(fn)
117
  for param_name, param in sig.parameters.items():
118
- if param.annotation is Context:
119
  context_kwarg = param_name
120
  break
121
 
 
13
  from mcp.types import PromptArgument as MCPPromptArgument
14
  from pydantic import BaseModel, BeforeValidator, Field, TypeAdapter, validate_call
15
 
16
+ from fastmcp.utilities.types import (
17
+ _convert_set_defaults,
18
+ is_class_member_of_type,
19
+ )
20
 
21
  if TYPE_CHECKING:
22
  from mcp.server.session import ServerSessionT
 
118
  else:
119
  sig = inspect.signature(fn)
120
  for param_name, param in sig.parameters.items():
121
+ if is_class_member_of_type(param.annotation, Context):
122
  context_kwarg = param_name
123
  break
124
 
src/fastmcp/resources/template.py CHANGED
@@ -20,7 +20,10 @@ from pydantic import (
20
  )
21
 
22
  from fastmcp.resources.types import FunctionResource, Resource
23
- from fastmcp.utilities.types import _convert_set_defaults
 
 
 
24
 
25
  if TYPE_CHECKING:
26
  from mcp.server.session import ServerSessionT
@@ -113,7 +116,7 @@ class ResourceTemplate(BaseModel):
113
  else:
114
  sig = inspect.signature(fn)
115
  for param_name, param in sig.parameters.items():
116
- if param.annotation is Context:
117
  context_kwarg = param_name
118
  break
119
 
 
20
  )
21
 
22
  from fastmcp.resources.types import FunctionResource, Resource
23
+ from fastmcp.utilities.types import (
24
+ _convert_set_defaults,
25
+ is_class_member_of_type,
26
+ )
27
 
28
  if TYPE_CHECKING:
29
  from mcp.server.session import ServerSessionT
 
116
  else:
117
  sig = inspect.signature(fn)
118
  for param_name, param in sig.parameters.items():
119
+ if is_class_member_of_type(param.annotation, Context):
120
  context_kwarg = param_name
121
  break
122
 
src/fastmcp/tools/tool.py CHANGED
@@ -12,7 +12,11 @@ from pydantic import BaseModel, BeforeValidator, Field
12
 
13
  from fastmcp.exceptions import ToolError
14
  from fastmcp.utilities.func_metadata import FuncMetadata, func_metadata
15
- from fastmcp.utilities.types import Image, _convert_set_defaults
 
 
 
 
16
 
17
  if TYPE_CHECKING:
18
  from mcp.server.session import ServerSessionT
@@ -66,7 +70,7 @@ class Tool(BaseModel):
66
  else:
67
  sig = inspect.signature(fn)
68
  for param_name, param in sig.parameters.items():
69
- if param.annotation is Context:
70
  context_kwarg = param_name
71
  break
72
 
 
12
 
13
  from fastmcp.exceptions import ToolError
14
  from fastmcp.utilities.func_metadata import FuncMetadata, func_metadata
15
+ from fastmcp.utilities.types import (
16
+ Image,
17
+ _convert_set_defaults,
18
+ is_class_member_of_type,
19
+ )
20
 
21
  if TYPE_CHECKING:
22
  from mcp.server.session import ServerSessionT
 
70
  else:
71
  sig = inspect.signature(fn)
72
  for param_name, param in sig.parameters.items():
73
+ if is_class_member_of_type(param.annotation, Context):
74
  context_kwarg = param_name
75
  break
76
 
src/fastmcp/utilities/types.py CHANGED
@@ -2,13 +2,41 @@
2
 
3
  import base64
4
  from pathlib import Path
5
- from typing import TypeVar
 
6
 
7
  from mcp.types import ImageContent
8
 
9
  T = TypeVar("T")
10
 
11
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  def _convert_set_defaults(maybe_set: set[T] | list[T] | None) -> set[T]:
13
  """Convert a set or list to a set, defaulting to an empty set if None."""
14
  if maybe_set is None:
 
2
 
3
  import base64
4
  from pathlib import Path
5
+ from types import UnionType
6
+ from typing import Annotated, TypeVar, Union, get_args, get_origin
7
 
8
  from mcp.types import ImageContent
9
 
10
  T = TypeVar("T")
11
 
12
 
13
+ def issubclass_safe(cls: type, base: type) -> bool:
14
+ """Check if cls is a subclass of base, even if cls is a type variable."""
15
+ try:
16
+ if origin := get_origin(cls):
17
+ return issubclass_safe(origin, base)
18
+ return issubclass(cls, base)
19
+ except TypeError:
20
+ return False
21
+
22
+
23
+ def is_class_member_of_type(cls: type, base: type) -> bool:
24
+ """Check if cls is a member of base, even if cls is a type variable."""
25
+ origin = get_origin(cls)
26
+ # Handle both types of unions: UnionType (from types module, used with | syntax)
27
+ # and typing.Union (used with Union[] syntax)
28
+ if origin is UnionType or origin == Union:
29
+ return any(is_class_member_of_type(arg, base) for arg in get_args(cls))
30
+ elif origin is Annotated:
31
+ # For Annotated[T, ...], check if T is a member of base
32
+ args = get_args(cls)
33
+ if args:
34
+ return is_class_member_of_type(args[0], base)
35
+ return False
36
+ else:
37
+ return issubclass_safe(cls, base)
38
+
39
+
40
  def _convert_set_defaults(maybe_set: set[T] | list[T] | None) -> set[T]:
41
  """Convert a set or list to a set, defaulting to an empty set if None."""
42
  if maybe_set is None:
tests/prompts/test_prompt_manager.py CHANGED
@@ -1,5 +1,10 @@
 
 
1
  import pytest
 
 
2
 
 
3
  from fastmcp.exceptions import NotFoundError
4
  from fastmcp.prompts import Prompt
5
  from fastmcp.prompts.prompt import TextContent, UserMessage
@@ -259,3 +264,97 @@ class TestPromptTags:
259
  nlp_prompts = [p for p in manager.get_prompts().values() if "nlp" in p.tags]
260
  assert len(nlp_prompts) == 1
261
  assert nlp_prompts[0].name == "summary"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Annotated
2
+
3
  import pytest
4
+ from mcp.server.session import ServerSessionT
5
+ from mcp.shared.context import LifespanContextT
6
 
7
+ from fastmcp import Context
8
  from fastmcp.exceptions import NotFoundError
9
  from fastmcp.prompts import Prompt
10
  from fastmcp.prompts.prompt import TextContent, UserMessage
 
264
  nlp_prompts = [p for p in manager.get_prompts().values() if "nlp" in p.tags]
265
  assert len(nlp_prompts) == 1
266
  assert nlp_prompts[0].name == "summary"
267
+
268
+
269
+ class TestContextHandling:
270
+ """Test context handling in prompts."""
271
+
272
+ def test_context_parameter_detection(self):
273
+ """Test that context parameters are properly detected in
274
+ Prompt.from_function()."""
275
+
276
+ def prompt_with_context(x: int, ctx: Context) -> str:
277
+ return str(x)
278
+
279
+ prompt = Prompt.from_function(prompt_with_context)
280
+ assert prompt.context_kwarg == "ctx"
281
+
282
+ def prompt_without_context(x: int) -> str:
283
+ return str(x)
284
+
285
+ prompt = Prompt.from_function(prompt_without_context)
286
+ assert prompt.context_kwarg is None
287
+
288
+ def test_parameterized_context_parameter_detection(self):
289
+ """Test that parameterized context parameters are properly detected in
290
+ Prompt.from_function()."""
291
+
292
+ def prompt_with_context(
293
+ x: int, ctx: Context[ServerSessionT, LifespanContextT]
294
+ ) -> str:
295
+ return str(x)
296
+
297
+ prompt = Prompt.from_function(prompt_with_context)
298
+ assert prompt.context_kwarg == "ctx"
299
+
300
+ def test_parameterized_union_context_parameter_detection(self):
301
+ """Test that context parameters in a union are properly detected in
302
+ Prompt.from_function()."""
303
+
304
+ def prompt_with_context(
305
+ x: int, ctx: Context[ServerSessionT, LifespanContextT] | None
306
+ ) -> str:
307
+ return str(x)
308
+
309
+ prompt = Prompt.from_function(prompt_with_context)
310
+ assert prompt.context_kwarg == "ctx"
311
+
312
+ async def test_context_injection(self):
313
+ """Test that context is properly injected during prompt rendering."""
314
+
315
+ def prompt_with_context(x: int, ctx: Context) -> str:
316
+ assert isinstance(ctx, Context)
317
+ return str(x)
318
+
319
+ prompt = Prompt.from_function(prompt_with_context)
320
+ assert prompt.context_kwarg == "ctx"
321
+
322
+ from fastmcp import FastMCP
323
+
324
+ mcp = FastMCP()
325
+ ctx = mcp.get_context()
326
+
327
+ messages = await prompt.render(
328
+ arguments={"x": 42},
329
+ context=ctx,
330
+ )
331
+ assert len(messages) == 1
332
+ assert isinstance(messages[0].content, TextContent)
333
+ assert messages[0].content.text == "42"
334
+
335
+ async def test_context_optional(self):
336
+ """Test that context is optional when rendering prompts."""
337
+
338
+ def prompt_with_context(x: int, ctx: Context | None = None) -> str:
339
+ return str(x)
340
+
341
+ prompt = Prompt.from_function(prompt_with_context)
342
+ assert prompt.context_kwarg == "ctx"
343
+
344
+ # Should not raise an error when context is not provided
345
+ messages = await prompt.render(
346
+ arguments={"x": 42},
347
+ )
348
+ assert len(messages) == 1
349
+ assert isinstance(messages[0].content, TextContent)
350
+ assert messages[0].content.text == "42"
351
+
352
+ async def test_annotated_context_parameter_detection(self):
353
+ """Test that annotated context parameters are properly detected in
354
+ Prompt.from_function()."""
355
+
356
+ def prompt_with_context(x: int, ctx: Annotated[Context, "ctx"]) -> str:
357
+ return str(x)
358
+
359
+ prompt = Prompt.from_function(prompt_with_context)
360
+ assert prompt.context_kwarg == "ctx"
tests/resources/test_resource_template.py CHANGED
@@ -2,8 +2,11 @@ import json
2
  from urllib.parse import quote
3
 
4
  import pytest
 
 
5
  from pydantic import BaseModel
6
 
 
7
  from fastmcp.resources import FunctionResource, ResourceTemplate
8
  from fastmcp.resources.template import match_uri_template
9
 
@@ -520,3 +523,113 @@ class TestMatchUriTemplate:
520
  uri_template = "file://abc/{path*}.py"
521
  result = match_uri_template(uri=uri, uri_template=uri_template)
522
  assert result == expected_params
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  from urllib.parse import quote
3
 
4
  import pytest
5
+ from mcp.server.session import ServerSessionT
6
+ from mcp.shared.context import LifespanContextT
7
  from pydantic import BaseModel
8
 
9
+ from fastmcp import Context
10
  from fastmcp.resources import FunctionResource, ResourceTemplate
11
  from fastmcp.resources.template import match_uri_template
12
 
 
523
  uri_template = "file://abc/{path*}.py"
524
  result = match_uri_template(uri=uri, uri_template=uri_template)
525
  assert result == expected_params
526
+
527
+
528
+ class TestContextHandling:
529
+ """Test context handling in resource templates."""
530
+
531
+ def test_context_parameter_detection(self):
532
+ """Test that context parameters are properly detected in
533
+ ResourceTemplate.from_function()."""
534
+
535
+ def template_with_context(x: int, ctx: Context) -> str:
536
+ return str(x)
537
+
538
+ template = ResourceTemplate.from_function(
539
+ fn=template_with_context,
540
+ uri_template="test://{x}",
541
+ name="test",
542
+ )
543
+ assert template.context_kwarg == "ctx"
544
+
545
+ def template_without_context(x: int) -> str:
546
+ return str(x)
547
+
548
+ template = ResourceTemplate.from_function(
549
+ fn=template_without_context,
550
+ uri_template="test://{x}",
551
+ name="test",
552
+ )
553
+ assert template.context_kwarg is None
554
+
555
+ def test_parameterized_context_parameter_detection(self):
556
+ """Test that parameterized context parameters are properly detected in
557
+ ResourceTemplate.from_function()."""
558
+
559
+ def template_with_context(
560
+ x: int, ctx: Context[ServerSessionT, LifespanContextT]
561
+ ) -> str:
562
+ return str(x)
563
+
564
+ template = ResourceTemplate.from_function(
565
+ fn=template_with_context,
566
+ uri_template="test://{x}",
567
+ name="test",
568
+ )
569
+ assert template.context_kwarg == "ctx"
570
+
571
+ def test_parameterized_union_context_parameter_detection(self):
572
+ """Test that context parameters in a union are properly detected in
573
+ ResourceTemplate.from_function()."""
574
+
575
+ def template_with_context(
576
+ x: int, ctx: Context[ServerSessionT, LifespanContextT] | None
577
+ ) -> str:
578
+ return str(x)
579
+
580
+ template = ResourceTemplate.from_function(
581
+ fn=template_with_context,
582
+ uri_template="test://{x}",
583
+ name="test",
584
+ )
585
+ assert template.context_kwarg == "ctx"
586
+
587
+ async def test_context_injection(self):
588
+ """Test that context is properly injected during resource creation."""
589
+
590
+ def resource_with_context(x: int, ctx: Context) -> str:
591
+ assert isinstance(ctx, Context)
592
+ return str(x)
593
+
594
+ template = ResourceTemplate.from_function(
595
+ fn=resource_with_context,
596
+ uri_template="test://{x}",
597
+ name="test",
598
+ )
599
+ assert template.context_kwarg == "ctx"
600
+
601
+ from fastmcp import FastMCP
602
+
603
+ mcp = FastMCP()
604
+ ctx = mcp.get_context()
605
+
606
+ resource = await template.create_resource(
607
+ "test://42",
608
+ {"x": 42},
609
+ context=ctx,
610
+ )
611
+ assert isinstance(resource, FunctionResource)
612
+ content = await resource.read()
613
+ assert content == "42"
614
+
615
+ async def test_context_optional(self):
616
+ """Test that context is optional when creating resources."""
617
+
618
+ def resource_with_context(x: int, ctx: Context | None = None) -> str:
619
+ return str(x)
620
+
621
+ template = ResourceTemplate.from_function(
622
+ fn=resource_with_context,
623
+ uri_template="test://{x}",
624
+ name="test",
625
+ )
626
+ assert template.context_kwarg == "ctx"
627
+
628
+ # Should not raise an error when context is not provided
629
+ resource = await template.create_resource(
630
+ "test://42",
631
+ {"x": 42},
632
+ )
633
+ assert isinstance(resource, FunctionResource)
634
+ content = await resource.read()
635
+ assert content == "42"
tests/tools/test_tool_manager.py CHANGED
@@ -1,7 +1,10 @@
1
  import json
2
  import logging
 
3
 
4
  import pytest
 
 
5
  from mcp.types import ImageContent, TextContent
6
  from pydantic import BaseModel
7
 
@@ -441,7 +444,8 @@ class TestContextHandling:
441
  return str(x)
442
 
443
  manager = ToolManager()
444
- manager.add_tool_from_fn(tool_with_context)
 
445
 
446
  mcp = FastMCP()
447
  ctx = mcp.get_context()
@@ -459,7 +463,8 @@ class TestContextHandling:
459
  return str(x)
460
 
461
  manager = ToolManager()
462
- manager.add_tool_from_fn(async_tool)
 
463
 
464
  mcp = FastMCP()
465
  ctx = mcp.get_context()
@@ -473,11 +478,12 @@ class TestContextHandling:
473
  """Test that context is optional when calling tools."""
474
  from mcp.types import TextContent
475
 
476
- def tool_with_context(x: int, ctx: Context | None = None) -> str:
477
- return str(x)
478
 
479
  manager = ToolManager()
480
- manager.add_tool_from_fn(tool_with_context)
 
481
  # Should not raise an error when context is not provided
482
  result = await manager.call_tool("tool_with_context", {"x": 42})
483
  assert isinstance(result, list)
@@ -485,6 +491,40 @@ class TestContextHandling:
485
  assert isinstance(result[0], TextContent)
486
  assert result[0].text == "42"
487
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
488
  async def test_context_error_handling(self):
489
  """Test error handling when context injection fails."""
490
 
 
1
  import json
2
  import logging
3
+ from typing import Annotated
4
 
5
  import pytest
6
+ from mcp.server.session import ServerSessionT
7
+ from mcp.shared.context import LifespanContextT
8
  from mcp.types import ImageContent, TextContent
9
  from pydantic import BaseModel
10
 
 
444
  return str(x)
445
 
446
  manager = ToolManager()
447
+ tool = manager.add_tool_from_fn(tool_with_context)
448
+ assert tool.context_kwarg == "ctx"
449
 
450
  mcp = FastMCP()
451
  ctx = mcp.get_context()
 
463
  return str(x)
464
 
465
  manager = ToolManager()
466
+ tool = manager.add_tool_from_fn(async_tool)
467
+ assert tool.context_kwarg == "ctx"
468
 
469
  mcp = FastMCP()
470
  ctx = mcp.get_context()
 
478
  """Test that context is optional when calling tools."""
479
  from mcp.types import TextContent
480
 
481
+ def tool_with_context(x: int, ctx: Context | None) -> int:
482
+ return x
483
 
484
  manager = ToolManager()
485
+ tool = manager.add_tool_from_fn(tool_with_context)
486
+ assert tool.context_kwarg == "ctx"
487
  # Should not raise an error when context is not provided
488
  result = await manager.call_tool("tool_with_context", {"x": 42})
489
  assert isinstance(result, list)
 
491
  assert isinstance(result[0], TextContent)
492
  assert result[0].text == "42"
493
 
494
+ def test_parameterized_context_parameter_detection(self):
495
+ """Test that context parameters are properly detected in
496
+ Tool.from_function()."""
497
+
498
+ def tool_with_context(
499
+ x: int, ctx: Context[ServerSessionT, LifespanContextT]
500
+ ) -> str:
501
+ return str(x)
502
+
503
+ manager = ToolManager()
504
+ tool = manager.add_tool_from_fn(tool_with_context)
505
+ assert tool.context_kwarg == "ctx"
506
+
507
+ def test_annotated_context_parameter_detection(self):
508
+ def tool_with_context(x: int, ctx: Annotated[Context, "ctx"]) -> str:
509
+ return str(x)
510
+
511
+ manager = ToolManager()
512
+ tool = manager.add_tool_from_fn(tool_with_context)
513
+ assert tool.context_kwarg == "ctx"
514
+
515
+ def test_parameterized_union_context_parameter_detection(self):
516
+ """Test that context parameters are properly detected in
517
+ Tool.from_function()."""
518
+
519
+ def tool_with_context(
520
+ x: int, ctx: Context[ServerSessionT, LifespanContextT] | None
521
+ ) -> str:
522
+ return str(x)
523
+
524
+ manager = ToolManager()
525
+ tool = manager.add_tool_from_fn(tool_with_context)
526
+ assert tool.context_kwarg == "ctx"
527
+
528
  async def test_context_error_handling(self):
529
  """Test error handling when context injection fails."""
530
 
tests/utilities/test_types.py ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Annotated, Any
2
+
3
+ import pytest
4
+
5
+ from fastmcp.utilities.types import Image, is_class_member_of_type, issubclass_safe
6
+
7
+
8
+ class BaseClass:
9
+ pass
10
+
11
+
12
+ class ChildClass(BaseClass):
13
+ pass
14
+
15
+
16
+ class OtherClass:
17
+ pass
18
+
19
+
20
+ class TestIsClassMemberOfType:
21
+ def test_basic_subclass_check(self):
22
+ """Test that a subclass is recognized as a member of the base class."""
23
+ assert is_class_member_of_type(ChildClass, BaseClass)
24
+
25
+ def test_self_is_member(self):
26
+ """Test that a class is a member of itself."""
27
+ assert is_class_member_of_type(BaseClass, BaseClass)
28
+
29
+ def test_unrelated_class_is_not_member(self):
30
+ """Test that an unrelated class is not a member of the base class."""
31
+ assert not is_class_member_of_type(OtherClass, BaseClass)
32
+
33
+ def test_typing_union_with_member_is_member(self):
34
+ """Test that Union type with a member class is detected as a member."""
35
+ union_type1: Any = ChildClass | OtherClass
36
+ union_type2: Any = OtherClass | ChildClass
37
+
38
+ assert is_class_member_of_type(union_type1, BaseClass)
39
+ assert is_class_member_of_type(union_type2, BaseClass)
40
+
41
+ def test_typing_union_without_member_is_not_member(self):
42
+ """Test that Union type without any member class is not a member."""
43
+ union_type: Any = OtherClass | str
44
+ assert not is_class_member_of_type(union_type, BaseClass)
45
+
46
+ def test_pipe_union_with_member_is_member(self):
47
+ """Test that pipe syntax union with a member class is detected as a member."""
48
+ union_pipe1: Any = ChildClass | OtherClass
49
+ union_pipe2: Any = OtherClass | ChildClass
50
+
51
+ assert is_class_member_of_type(union_pipe1, BaseClass)
52
+ assert is_class_member_of_type(union_pipe2, BaseClass)
53
+
54
+ def test_pipe_union_without_member_is_not_member(self):
55
+ """Test that pipe syntax union without any member class is not a member."""
56
+ union_pipe: Any = OtherClass | str
57
+ assert not is_class_member_of_type(union_pipe, BaseClass)
58
+
59
+ def test_annotated_member_is_member(self):
60
+ """Test that Annotated with a member class is detected as a member."""
61
+ annotated1: Any = Annotated[ChildClass, "metadata"]
62
+ annotated2: Any = Annotated[BaseClass, "metadata"]
63
+
64
+ assert is_class_member_of_type(annotated1, BaseClass)
65
+ assert is_class_member_of_type(annotated2, BaseClass)
66
+
67
+ def test_annotated_non_member_is_not_member(self):
68
+ """Test that Annotated with a non-member class is not a member."""
69
+ annotated: Any = Annotated[OtherClass, "metadata"]
70
+ assert not is_class_member_of_type(annotated, BaseClass)
71
+
72
+ def test_annotated_with_union_member_is_member(self):
73
+ """Test that Annotated with a Union containing a member class is a member."""
74
+ # Test with both Union styles
75
+ annotated1: Any = Annotated[ChildClass | OtherClass, "metadata"]
76
+ annotated2: Any = Annotated[ChildClass | OtherClass, "metadata"]
77
+
78
+ assert is_class_member_of_type(annotated1, BaseClass)
79
+ assert is_class_member_of_type(annotated2, BaseClass)
80
+
81
+ def test_nested_annotated_with_member_is_member(self):
82
+ """Test that nested Annotated with a member class is a member."""
83
+ annotated: Any = Annotated[Annotated[ChildClass, "inner"], "outer"]
84
+ assert is_class_member_of_type(annotated, BaseClass)
85
+
86
+ def test_none_is_not_member(self):
87
+ """Test that None is not a member of any class."""
88
+ assert not is_class_member_of_type(None, BaseClass) # type: ignore
89
+
90
+ def test_generic_type_is_not_member(self):
91
+ """Test that generic types are not members based on their parameter types."""
92
+ list_type: Any = list[ChildClass]
93
+ assert not is_class_member_of_type(list_type, BaseClass)
94
+
95
+
96
+ class TestIsSubclassSafe:
97
+ def test_child_is_subclass_of_parent(self):
98
+ """Test that a child class is recognized as a subclass of its parent."""
99
+ assert issubclass_safe(ChildClass, BaseClass)
100
+
101
+ def test_class_is_subclass_of_itself(self):
102
+ """Test that a class is a subclass of itself."""
103
+ assert issubclass_safe(BaseClass, BaseClass)
104
+
105
+ def test_unrelated_class_is_not_subclass(self):
106
+ """Test that an unrelated class is not a subclass."""
107
+ assert not issubclass_safe(OtherClass, BaseClass)
108
+
109
+ def test_none_type_handled_safely(self):
110
+ """Test that None type is handled safely without raising TypeError."""
111
+ assert not issubclass_safe(None, BaseClass) # type: ignore
112
+
113
+
114
+ class TestImage:
115
+ def test_image_initialization_with_path(self):
116
+ """Test image initialization with a path."""
117
+ # Mock test - we're not actually going to read a file
118
+ image = Image(path="test.png")
119
+ assert image.path is not None
120
+ assert image.data is None
121
+ assert image._mime_type == "image/png"
122
+
123
+ def test_image_initialization_with_data(self):
124
+ """Test image initialization with data."""
125
+ image = Image(data=b"test")
126
+ assert image.path is None
127
+ assert image.data == b"test"
128
+ assert image._mime_type == "image/png" # Default for raw data
129
+
130
+ def test_image_initialization_with_format(self):
131
+ """Test image initialization with a specific format."""
132
+ image = Image(data=b"test", format="jpeg")
133
+ assert image._mime_type == "image/jpeg"
134
+
135
+ def test_missing_data_and_path_raises_error(self):
136
+ """Test that error is raised when neither path nor data is provided."""
137
+ with pytest.raises(ValueError, match="Either path or data must be provided"):
138
+ Image()
139
+
140
+ def test_both_data_and_path_raises_error(self):
141
+ """Test that error is raised when both path and data are provided."""
142
+ with pytest.raises(
143
+ ValueError, match="Only one of path or data can be provided"
144
+ ):
145
+ Image(path="test.png", data=b"test")