Skip to content

Commit 4a9c814

Browse files
committed
add file-like object support
1 parent e861ec2 commit 4a9c814

File tree

3 files changed

+108
-26
lines changed

3 files changed

+108
-26
lines changed

pkg/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,6 @@ namespaces = true
1515

1616
# ----------------------------------------- Project Metadata -------------------------------------
1717
[project]
18-
version = "0.2.6"
18+
version = "0.2.7"
1919
name = "FileEx"
2020
requires-python = ">=3.10"

pkg/src/fileex/file.py

Lines changed: 88 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
from pathlib import Path
2-
from typing import IO, Literal, Type
2+
from typing import IO, Literal, overload
33
import io
4+
import os
45

56
import fileex
6-
from fileex.typing import FileLike
7+
from fileex.typing import FileLike, ReadableFileLike
78

89

910
def open_file(
@@ -57,11 +58,33 @@ def open_file(
5758
)
5859

5960

61+
@overload
62+
def content(
63+
file: FileLike,
64+
*,
65+
output: Literal["str"] = "str",
66+
encoding: str = "utf-8",
67+
errors: str = "strict",
68+
preserve_pos: bool = True,
69+
) -> str: ...
70+
71+
@overload
72+
def content(
73+
file: FileLike,
74+
*,
75+
output: Literal["bytes"],
76+
encoding: str = "utf-8",
77+
errors: str = "strict",
78+
preserve_pos: bool = True,
79+
) -> bytes: ...
80+
6081
def content(
6182
file: FileLike,
6283
*,
6384
output: Literal["str", "bytes"] = "str",
64-
encoding: str = "utf-8"
85+
encoding: str = "utf-8",
86+
errors: str = "strict",
87+
preserve_pos: bool = True,
6588
) -> str | bytes:
6689
"""Get the content of a file-like input.
6790
@@ -72,30 +95,78 @@ def content(
7295
output
7396
Output type, either 'str' or 'bytes'.
7497
encoding
75-
Encoding used to decode the file if it is provided as bytes or Path,
76-
and output is 'str'.
98+
Encoding used when converting between bytes and str.
99+
errors
100+
Error handling for encode/decode (e.g. 'strict', 'replace', 'ignore').
101+
preserve_pos
102+
If True and the object is seekable, restores the stream position after reading.
77103
78104
Returns
79105
-------
80106
file_content
81107
Content of the file as a string or bytes.
108+
109+
Raises
110+
-------
111+
ValueError
112+
If `output` is not 'str' or 'bytes'.
113+
TypeError
114+
If `file` is not a supported type.
82115
"""
116+
def return_from_bytes(b: bytes) -> str | bytes:
117+
"""Helper to return bytes or decoded str based on output parameter."""
118+
return b.decode(encoding, errors=errors) if output == "str" else b
119+
120+
def return_from_str(s: str) -> str | bytes:
121+
"""Helper to return str or encoded bytes based on output parameter."""
122+
return s if output == "str" else s.encode(encoding, errors=errors)
123+
83124
if output not in ("str", "bytes"):
84125
raise ValueError("output must be either 'str' or 'bytes'")
85126

127+
# Path: normalize to bytes via filesystem read
128+
if isinstance(file, os.PathLike):
129+
return return_from_bytes(Path(file).read_bytes())
130+
131+
# String: check if path or content
86132
if isinstance(file, str):
87-
content_bytes = (
88-
Path(file).read_bytes()
89-
if fileex.path.is_path(file) else
90-
file.encode(encoding)
91-
)
92-
elif isinstance(file, bytes):
93-
content_bytes = file
94-
elif isinstance(file, Path):
95-
content_bytes = file.read_bytes()
96-
else:
133+
if fileex.path.is_path(file):
134+
return return_from_bytes(Path(file).read_bytes())
135+
return return_from_str(file)
136+
137+
# Bytes-like: normalize to bytes
138+
if isinstance(file, (bytes, bytearray, memoryview)):
139+
return return_from_bytes(bytes(file))
140+
141+
# Streams / file objects (open(...), BytesIO, sockets, etc.)
142+
if isinstance(file, ReadableFileLike):
143+
# Try to preserve cursor position if possible.
144+
pos: int | None = None
145+
if preserve_pos:
146+
try:
147+
pos = file.tell() # type: ignore[attr-defined]
148+
except Exception:
149+
pos = None
150+
151+
try:
152+
raw = file.read()
153+
finally:
154+
if preserve_pos and pos is not None:
155+
try:
156+
file.seek(pos) # type: ignore[attr-defined]
157+
except Exception:
158+
pass
159+
160+
if isinstance(raw, str):
161+
return return_from_str(raw)
162+
if isinstance(raw, (bytes, bytearray, memoryview)):
163+
return return_from_bytes(bytes(raw))
164+
97165
raise TypeError(
98-
f"Expected str, bytes, or pathlib.Path, got {type(file).__name__}"
166+
f"file.read() must return str or bytes, got {type(raw).__name__}"
99167
)
100168

101-
return content_bytes.decode(encoding) if output == "str" else content_bytes
169+
raise TypeError(
170+
"Expected str, bytes-like, path-like, or a readable stream; "
171+
f"got {type(file).__name__}"
172+
)

pkg/src/fileex/typing.py

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,30 @@
33
This module defines type-hints used throughout the package.
44
"""
55

6-
from pathlib import Path
7-
from typing import TypeAlias
6+
import os
7+
from typing import TypeAlias, Protocol, runtime_checkable
88

99

10-
PathLike: TypeAlias = Path | str
11-
"""A file path, either as a string or a `pathlib.Path` object."""
10+
@runtime_checkable
11+
class ReadableFileLike(Protocol):
12+
"""Protocol for file-like objects that can be read from."""
13+
def read(self, size: int = -1) -> str | bytes: ...
1214

1315

14-
FileLike: TypeAlias = PathLike | str | bytes
16+
PathLike: TypeAlias = os.PathLike | str
17+
"""A file path, either as a string or any `os.PathLike` object."""
18+
19+
20+
BytesLike: TypeAlias = bytes | bytearray | memoryview
21+
"""A bytes-like object."""
22+
23+
24+
FileLike: TypeAlias = ReadableFileLike | PathLike | str | BytesLike
1525
"""A file-like input.
1626
17-
- If a `pathlib.Path` is provided, it is interpreted as the path to a file.
18-
- If `bytes` are provided, they are interpreted as the content of the file.
19-
- If a `str` is provided, it is interpreted as the content of the file
27+
- If an `os.PathLike` object is provided, it is interpreted as the path to a file.
28+
- If a string is provided, it is interpreted as the content of the file
2029
unless it is a valid existing file path, in which case it is treated as the path to a file.
30+
- If a bytes-like object is provided, it is interpreted as the content of the file.
31+
- If a `ReadableFileLike` object is provided, its `read()` method will be used to read content.
2132
"""

0 commit comments

Comments
 (0)