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') + )