From e810734b4a37a1558fe4f921a53181c07d7655ae Mon Sep 17 00:00:00 2001
From: David Sanchez <64162682+dsfaccini@users.noreply.github.com>
Date: Wed, 19 Nov 2025 10:12:00 -0500
Subject: [PATCH 1/7] implement `FIlePart.from_path`
---
pydantic_ai_slim/pydantic_ai/messages.py | 24 +++++++++++++++++++++++-
tests/CLAUDE.md | 10 ++++++++++
tests/test_messages.py | 18 ++++++++++++++++++
3 files changed, 51 insertions(+), 1 deletion(-)
create mode 100644 tests/CLAUDE.md
diff --git a/pydantic_ai_slim/pydantic_ai/messages.py b/pydantic_ai_slim/pydantic_ai/messages.py
index 988430d12a..87a998cfc0 100644
--- a/pydantic_ai_slim/pydantic_ai/messages.py
+++ b/pydantic_ai_slim/pydantic_ai/messages.py
@@ -7,7 +7,9 @@
from dataclasses import KW_ONLY, dataclass, field, replace
from datetime import datetime
from mimetypes import guess_type
-from typing import TYPE_CHECKING, Annotated, Any, Literal, TypeAlias, cast, overload
+from os import PathLike
+from pathlib import Path
+from typing import TYPE_CHECKING, Annotated, Any, Literal, Self, TypeAlias, cast, overload
import pydantic
import pydantic_core
@@ -1062,6 +1064,26 @@ def has_content(self) -> bool:
"""Return `True` if the file content is non-empty."""
return bool(self.content) # pragma: no cover
+ @classmethod
+ def from_path(cls, path: PathLike[str]) -> Self:
+ """Create a `BinaryContent` from a path.
+
+ Defaults to 'application/octet-stream' if the media type cannot be inferred.
+
+ Raises:
+ FileNotFoundError: if the file does not exist.
+ PermissionError: if the file cannot be read.
+ """
+ path = Path(path)
+ if not path.exists():
+ raise FileNotFoundError(f'File not found: {path}')
+ media_type, _ = guess_type(path.as_posix())
+ if media_type is None:
+ media_type = 'application/octet-stream'
+
+ binary_content = BinaryContent(data=path.read_bytes(), media_type=media_type)
+ return cls(content=binary_content)
+
__repr__ = _utils.dataclasses_no_defaults_repr
diff --git a/tests/CLAUDE.md b/tests/CLAUDE.md
new file mode 100644
index 0000000000..fc73701d3c
--- /dev/null
+++ b/tests/CLAUDE.md
@@ -0,0 +1,10 @@
+# Testing conventions
+
+## general rules
+
+- prefer using `snapshot()` instead of line-by-line assertions
+- unless the snapshot is too big and you only need to check specific values
+
+## for testing filepaths
+
+- define your function with a parameter `tmp_path: Path`
\ No newline at end of file
diff --git a/tests/test_messages.py b/tests/test_messages.py
index 9b6ad3fbb8..5c6203d30b 100644
--- a/tests/test_messages.py
+++ b/tests/test_messages.py
@@ -1,5 +1,6 @@
import sys
from datetime import datetime, timezone
+from pathlib import Path
import pytest
from inline_snapshot import snapshot
@@ -605,3 +606,20 @@ def test_binary_content_validation_with_optional_identifier():
'identifier': 'foo',
}
)
+
+
+def test_file_part_from_path(tmp_path: Path):
+ # test normal file
+ test_xml_file = tmp_path / 'test.xml'
+ test_xml_file.write_text('about trains', encoding='utf-8')
+ file_part = FilePart.from_path(test_xml_file)
+ assert file_part == snapshot(
+ FilePart(
+ content=BinaryContent(data=b'about trains', media_type='application/xml')
+ )
+ )
+
+ # test non-existent file
+ non_existent_file = tmp_path / 'non-existent.txt'
+ with pytest.raises(FileNotFoundError, match='File not found:'):
+ FilePart.from_path(non_existent_file)
\ No newline at end of file
From fa7aabfb93ffdfc2280af02168ae9bc3076fae1f Mon Sep 17 00:00:00 2001
From: David Sanchez <64162682+dsfaccini@users.noreply.github.com>
Date: Wed, 19 Nov 2025 18:25:18 -0500
Subject: [PATCH 2/7] add unknown extension
---
tests/test_messages.py | 12 +++++++++++-
1 file changed, 11 insertions(+), 1 deletion(-)
diff --git a/tests/test_messages.py b/tests/test_messages.py
index 5c6203d30b..bb7d422b9d 100644
--- a/tests/test_messages.py
+++ b/tests/test_messages.py
@@ -622,4 +622,14 @@ def test_file_part_from_path(tmp_path: Path):
# test non-existent file
non_existent_file = tmp_path / 'non-existent.txt'
with pytest.raises(FileNotFoundError, match='File not found:'):
- FilePart.from_path(non_existent_file)
\ No newline at end of file
+ FilePart.from_path(non_existent_file)
+
+ # test file with unknown media type
+ test_unknown_file = tmp_path / 'test.unknownext'
+ test_unknown_file.write_text('some content', encoding='utf-8')
+ file_part = FilePart.from_path(test_unknown_file)
+ assert file_part == snapshot(
+ FilePart(
+ content=BinaryContent(data=b'some content', media_type='application/octet-stream')
+ )
+ )
\ No newline at end of file
From aa11f5d59eb67d92a980a044711af80c55d70e45 Mon Sep 17 00:00:00 2001
From: David Sanchez <64162682+dsfaccini@users.noreply.github.com>
Date: Wed, 19 Nov 2025 18:33:11 -0500
Subject: [PATCH 3/7] format
---
pydantic_ai_slim/pydantic_ai/messages.py | 2 +-
tests/test_messages.py | 10 +++-------
2 files changed, 4 insertions(+), 8 deletions(-)
diff --git a/pydantic_ai_slim/pydantic_ai/messages.py b/pydantic_ai_slim/pydantic_ai/messages.py
index 9fbec52cf3..50a189a3fd 100644
--- a/pydantic_ai_slim/pydantic_ai/messages.py
+++ b/pydantic_ai_slim/pydantic_ai/messages.py
@@ -1093,7 +1093,7 @@ def from_path(cls, path: PathLike[str]) -> Self:
binary_content = BinaryContent(data=path.read_bytes(), media_type=media_type)
return cls(content=binary_content)
-
+
__repr__ = _utils.dataclasses_no_defaults_repr
diff --git a/tests/test_messages.py b/tests/test_messages.py
index 9c7ce39c4d..2b5a906595 100644
--- a/tests/test_messages.py
+++ b/tests/test_messages.py
@@ -621,9 +621,7 @@ def test_file_part_from_path(tmp_path: Path):
test_xml_file.write_text('about trains', encoding='utf-8')
file_part = FilePart.from_path(test_xml_file)
assert file_part == snapshot(
- FilePart(
- content=BinaryContent(data=b'about trains', media_type='application/xml')
- )
+ FilePart(content=BinaryContent(data=b'about trains', media_type='application/xml'))
)
# test non-existent file
@@ -636,7 +634,5 @@ def test_file_part_from_path(tmp_path: Path):
test_unknown_file.write_text('some content', encoding='utf-8')
file_part = FilePart.from_path(test_unknown_file)
assert file_part == snapshot(
- FilePart(
- content=BinaryContent(data=b'some content', media_type='application/octet-stream')
- )
- )
\ No newline at end of file
+ FilePart(content=BinaryContent(data=b'some content', media_type='application/octet-stream'))
+ )
From 9642d9e4b971958959714ffa0a547ab8884e5341 Mon Sep 17 00:00:00 2001
From: David Sanchez <64162682+dsfaccini@users.noreply.github.com>
Date: Wed, 19 Nov 2025 18:36:08 -0500
Subject: [PATCH 4/7] remove Self
---
pydantic_ai_slim/pydantic_ai/messages.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/pydantic_ai_slim/pydantic_ai/messages.py b/pydantic_ai_slim/pydantic_ai/messages.py
index 50a189a3fd..31b83492d8 100644
--- a/pydantic_ai_slim/pydantic_ai/messages.py
+++ b/pydantic_ai_slim/pydantic_ai/messages.py
@@ -9,7 +9,7 @@
from mimetypes import guess_type
from os import PathLike
from pathlib import Path
-from typing import TYPE_CHECKING, Annotated, Any, Literal, Self, TypeAlias, cast, overload
+from typing import TYPE_CHECKING, Annotated, Any, Literal, TypeAlias, cast, overload
import pydantic
import pydantic_core
@@ -1075,7 +1075,7 @@ def has_content(self) -> bool:
return bool(self.content.data)
@classmethod
- def from_path(cls, path: PathLike[str]) -> Self:
+ def from_path(cls, path: PathLike[str]) -> FilePart:
"""Create a `BinaryContent` from a path.
Defaults to 'application/octet-stream' if the media type cannot be inferred.
From ae701f3e795565e02f95dd06de90e8013752f81d Mon Sep 17 00:00:00 2001
From: David Sanchez <64162682+dsfaccini@users.noreply.github.com>
Date: Wed, 19 Nov 2025 19:10:55 -0500
Subject: [PATCH 5/7] remove claudemd
---
tests/CLAUDE.md | 10 ----------
1 file changed, 10 deletions(-)
delete mode 100644 tests/CLAUDE.md
diff --git a/tests/CLAUDE.md b/tests/CLAUDE.md
deleted file mode 100644
index fc73701d3c..0000000000
--- a/tests/CLAUDE.md
+++ /dev/null
@@ -1,10 +0,0 @@
-# Testing conventions
-
-## general rules
-
-- prefer using `snapshot()` instead of line-by-line assertions
-- unless the snapshot is too big and you only need to check specific values
-
-## for testing filepaths
-
-- define your function with a parameter `tmp_path: Path`
\ No newline at end of file
From aad356e9e1a7e9c8c08e55198b374543a8eebf07 Mon Sep 17 00:00:00 2001
From: David Sanchez <64162682+dsfaccini@users.noreply.github.com>
Date: Thu, 20 Nov 2025 10:06:33 -0500
Subject: [PATCH 6/7] move classmethod to BinaryContent
---
pydantic_ai_slim/pydantic_ai/messages.py | 39 ++++++++++++------------
tests/test_messages.py | 14 +++------
2 files changed, 24 insertions(+), 29 deletions(-)
diff --git a/pydantic_ai_slim/pydantic_ai/messages.py b/pydantic_ai_slim/pydantic_ai/messages.py
index 31b83492d8..3a8109135b 100644
--- a/pydantic_ai_slim/pydantic_ai/messages.py
+++ b/pydantic_ai_slim/pydantic_ai/messages.py
@@ -532,6 +532,25 @@ def from_data_uri(cls, data_uri: str) -> BinaryContent:
media_type, data = data_uri[len(prefix) :].split(';base64,', 1)
return cls.narrow_type(cls(data=base64.b64decode(data), media_type=media_type))
+ @classmethod
+ def from_path(cls, path: PathLike[str]) -> BinaryContent:
+ """Create a `BinaryContent` from a path.
+
+ Defaults to 'application/octet-stream' if the media type cannot be inferred.
+
+ Raises:
+ FileNotFoundError: if the file does not exist.
+ PermissionError: if the file cannot be read.
+ """
+ path = Path(path)
+ if not path.exists():
+ raise FileNotFoundError(f'File not found: {path}')
+ media_type, _ = guess_type(path.as_posix())
+ if media_type is None:
+ media_type = 'application/octet-stream'
+
+ return cls(data=path.read_bytes(), media_type=media_type)
+
@pydantic.computed_field
@property
def identifier(self) -> str:
@@ -1074,26 +1093,6 @@ def has_content(self) -> bool:
"""Return `True` if the file content is non-empty."""
return bool(self.content.data)
- @classmethod
- def from_path(cls, path: PathLike[str]) -> FilePart:
- """Create a `BinaryContent` from a path.
-
- Defaults to 'application/octet-stream' if the media type cannot be inferred.
-
- Raises:
- FileNotFoundError: if the file does not exist.
- PermissionError: if the file cannot be read.
- """
- path = Path(path)
- if not path.exists():
- raise FileNotFoundError(f'File not found: {path}')
- media_type, _ = guess_type(path.as_posix())
- if media_type is None:
- media_type = 'application/octet-stream'
-
- binary_content = BinaryContent(data=path.read_bytes(), media_type=media_type)
- return cls(content=binary_content)
-
__repr__ = _utils.dataclasses_no_defaults_repr
diff --git a/tests/test_messages.py b/tests/test_messages.py
index 2b5a906595..4ecc1f6105 100644
--- a/tests/test_messages.py
+++ b/tests/test_messages.py
@@ -619,20 +619,16 @@ def test_file_part_from_path(tmp_path: Path):
# test normal file
test_xml_file = tmp_path / 'test.xml'
test_xml_file.write_text('about trains', encoding='utf-8')
- file_part = FilePart.from_path(test_xml_file)
- assert file_part == snapshot(
- FilePart(content=BinaryContent(data=b'about trains', media_type='application/xml'))
- )
+ binary_content = BinaryContent.from_path(test_xml_file)
+ assert binary_content == snapshot(BinaryContent(data=b'about trains', media_type='application/xml'))
# test non-existent file
non_existent_file = tmp_path / 'non-existent.txt'
with pytest.raises(FileNotFoundError, match='File not found:'):
- FilePart.from_path(non_existent_file)
+ BinaryContent.from_path(non_existent_file)
# test file with unknown media type
test_unknown_file = tmp_path / 'test.unknownext'
test_unknown_file.write_text('some content', encoding='utf-8')
- file_part = FilePart.from_path(test_unknown_file)
- assert file_part == snapshot(
- FilePart(content=BinaryContent(data=b'some content', media_type='application/octet-stream'))
- )
+ binary_content = BinaryContent.from_path(test_unknown_file)
+ assert binary_content == snapshot(BinaryContent(data=b'some content', media_type='application/octet-stream'))
From 7038dcf00c79f4bf60d845643928599b977c7473 Mon Sep 17 00:00:00 2001
From: David Sanchez <64162682+dsfaccini@users.noreply.github.com>
Date: Thu, 20 Nov 2025 11:30:06 -0500
Subject: [PATCH 7/7] handle narrow type and test string path
---
pydantic_ai_slim/pydantic_ai/messages.py | 4 ++--
tests/test_messages.py | 17 ++++++++++++++++-
2 files changed, 18 insertions(+), 3 deletions(-)
diff --git a/pydantic_ai_slim/pydantic_ai/messages.py b/pydantic_ai_slim/pydantic_ai/messages.py
index 3a8109135b..9019b81931 100644
--- a/pydantic_ai_slim/pydantic_ai/messages.py
+++ b/pydantic_ai_slim/pydantic_ai/messages.py
@@ -545,11 +545,11 @@ def from_path(cls, path: PathLike[str]) -> BinaryContent:
path = Path(path)
if not path.exists():
raise FileNotFoundError(f'File not found: {path}')
- media_type, _ = guess_type(path.as_posix())
+ media_type, _ = guess_type(path)
if media_type is None:
media_type = 'application/octet-stream'
- return cls(data=path.read_bytes(), media_type=media_type)
+ return cls.narrow_type(cls(data=path.read_bytes(), media_type=media_type))
@pydantic.computed_field
@property
diff --git a/tests/test_messages.py b/tests/test_messages.py
index 4ecc1f6105..943d68fe8c 100644
--- a/tests/test_messages.py
+++ b/tests/test_messages.py
@@ -615,7 +615,7 @@ def test_binary_content_validation_with_optional_identifier():
)
-def test_file_part_from_path(tmp_path: Path):
+def test_binary_content_from_path(tmp_path: Path):
# test normal file
test_xml_file = tmp_path / 'test.xml'
test_xml_file.write_text('about trains', encoding='utf-8')
@@ -632,3 +632,18 @@ def test_file_part_from_path(tmp_path: Path):
test_unknown_file.write_text('some content', encoding='utf-8')
binary_content = BinaryContent.from_path(test_unknown_file)
assert binary_content == snapshot(BinaryContent(data=b'some content', media_type='application/octet-stream'))
+
+ # test string path
+ test_txt_file = tmp_path / 'test.txt'
+ test_txt_file.write_text('just some text', encoding='utf-8')
+ string_path = test_txt_file.as_posix()
+ binary_content = BinaryContent.from_path(string_path) # pyright: ignore[reportArgumentType]
+ assert binary_content == snapshot(BinaryContent(data=b'just some text', media_type='text/plain'))
+
+ # test image file
+ test_jpg_file = tmp_path / 'test.jpg'
+ test_jpg_file.write_bytes(b'\xff\xd8\xff\xe0' + b'0' * 100) # minimal JPEG header + padding
+ binary_content = BinaryContent.from_path(test_jpg_file)
+ assert binary_content == snapshot(
+ BinaryImage(data=b'\xff\xd8\xff\xe0' + b'0' * 100, media_type='image/jpeg', _identifier='bc8d49')
+ )