Skip to content

Commit 89ba0dd

Browse files
authored
Merge pull request #18 from iamtatsuki05/develop
Sync: Develop to Main
2 parents 42d1d41 + 358970f commit 89ba0dd

17 files changed

Lines changed: 959 additions & 357 deletions

File tree

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
from pathlib import Path
2+
from typing import Any, Protocol, TypeVar
3+
4+
T = TypeVar('T')
5+
6+
JsonLikeValue = dict[str, Any] | list[Any] | str | int | float | bool | None
7+
8+
9+
class FileLoader(Protocol):
10+
"""Protocol for loading data from files.
11+
12+
Implementations should handle file format-specific deserialization.
13+
"""
14+
15+
def load(self, path: str | Path) -> Any: # noqa: ANN401
16+
"""Load data from the specified file path.
17+
18+
Args:
19+
path: Path to the file to load
20+
21+
Returns:
22+
Deserialized data from the file
23+
24+
"""
25+
...
26+
27+
28+
class FileSaver(Protocol):
29+
"""Protocol for saving data to files.
30+
31+
Implementations should handle file format-specific serialization.
32+
"""
33+
34+
def save(
35+
self,
36+
data: Any, # noqa: ANN401
37+
path: str | Path,
38+
*,
39+
parents: bool = True,
40+
exist_ok: bool = True,
41+
) -> None:
42+
"""Save data to the specified file path.
43+
44+
Args:
45+
data: Data to serialize and save
46+
path: Path where the file should be saved
47+
parents: If True, create parent directories as needed
48+
exist_ok: If True, don't raise error if directory exists
49+
50+
"""
51+
...
52+
53+
54+
class FileHandler(Protocol):
55+
"""Combined protocol for both loading and saving files.
56+
57+
Provides a complete interface for file I/O operations.
58+
"""
59+
60+
def load(self, path: str | Path) -> Any: # noqa: ANN401
61+
"""Load data from the specified file path."""
62+
...
63+
64+
def save(
65+
self,
66+
data: Any, # noqa: ANN401
67+
path: str | Path,
68+
*,
69+
parents: bool = True,
70+
exist_ok: bool = True,
71+
) -> None:
72+
"""Save data to the specified file path."""
73+
...

src/project/common/utils/file/config.py

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,24 @@
11
from pathlib import Path
22
from typing import Any
33

4-
from project.common.utils.file.json import load_json
5-
from project.common.utils.file.toml import load_toml
6-
from project.common.utils.file.yaml import load_yaml
4+
from project.common.utils.file.io import load_file
75

86

97
def load_config(path: str | Path) -> dict[str, Any]:
10-
"""Load configuration from a file (JSON, YAML, or TOML)."""
11-
ext = Path(path).suffix.lower()
12-
13-
if ext == '.json':
14-
data = load_json(path)
15-
elif ext in ('.yaml', '.yml'):
16-
data = load_yaml(path)
17-
elif ext == '.toml':
18-
data = load_toml(path)
19-
else:
20-
raise ValueError(f'Unsupported config file format: {ext}')
8+
"""Load configuration from a file (JSON, YAML, TOML, XML).
9+
10+
Args:
11+
path: Path to the configuration file. Format is detected from extension.
12+
13+
Returns:
14+
Configuration data as a dictionary.
15+
16+
Raises:
17+
ValueError: If file format is not supported.
18+
TypeError: If the loaded data is not a dictionary.
19+
20+
"""
21+
data = load_file(path)
2122

2223
if not isinstance(data, dict):
2324
raise TypeError(f'Config file {path!r} did not return a dict, got {type(data).__name__}')
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
from pathlib import Path
2+
from typing import ClassVar, Literal
3+
4+
from project.common.utils.file.base import FileHandler
5+
from project.common.utils.file.json import JsonFileHandler
6+
from project.common.utils.file.toml import TomlFileHandler
7+
from project.common.utils.file.xml import XmlFileHandler
8+
from project.common.utils.file.yaml import YamlFileHandler
9+
10+
FileFormat = Literal['json', 'yaml', 'toml', 'xml']
11+
12+
13+
class FileHandlerFactory:
14+
"""Factory for creating file handlers based on file format."""
15+
16+
_handlers: ClassVar[dict[FileFormat, type[FileHandler]]] = {
17+
'json': JsonFileHandler,
18+
'yaml': YamlFileHandler,
19+
'toml': TomlFileHandler,
20+
'xml': XmlFileHandler,
21+
}
22+
23+
@classmethod
24+
def create(cls, format_type: FileFormat) -> FileHandler:
25+
"""Create a file handler for the specified format.
26+
27+
Args:
28+
format_type: File format ('json', 'yaml', or 'toml')
29+
30+
Returns:
31+
File handler instance for the specified format
32+
33+
Raises:
34+
ValueError: If format_type is not supported
35+
36+
"""
37+
handler_class = cls._handlers.get(format_type)
38+
if handler_class is None:
39+
supported = ', '.join(cls._handlers.keys())
40+
msg = f'Unsupported file format: {format_type}. Supported formats: {supported}'
41+
raise ValueError(msg)
42+
return handler_class()
43+
44+
@classmethod
45+
def from_path(cls, path: str | Path) -> FileHandler:
46+
"""Create a file handler by detecting format from file extension.
47+
48+
Args:
49+
path: File path with extension
50+
51+
Returns:
52+
File handler instance for the detected format
53+
54+
Raises:
55+
ValueError: If file extension is not recognized or missing
56+
57+
"""
58+
suffix = Path(path).suffix.lstrip('.')
59+
if not suffix:
60+
msg = f'Cannot detect file format: no extension in {path}'
61+
raise ValueError(msg)
62+
63+
# Map common extensions to format types
64+
extension_map: dict[str, FileFormat] = {
65+
'json': 'json',
66+
'yaml': 'yaml',
67+
'yml': 'yaml',
68+
'toml': 'toml',
69+
'xml': 'xml',
70+
}
71+
72+
format_type = extension_map.get(suffix.lower())
73+
if format_type is None:
74+
supported = ', '.join(extension_map.keys())
75+
msg = f'Unsupported file extension: .{suffix}. Supported extensions: {supported}'
76+
raise ValueError(msg)
77+
78+
return cls.create(format_type)
79+
80+
81+
def get_file_handler(path: str | Path) -> FileHandler:
82+
"""Get a file handler from a file path.
83+
84+
Args:
85+
path: File path with extension
86+
87+
Returns:
88+
File handler instance for the detected format
89+
90+
"""
91+
return FileHandlerFactory.from_path(path)
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
"""Generic file I/O operations using FileHandler abstraction.
2+
3+
This module provides format-agnostic file operations that automatically
4+
detect and handle different file formats (JSON, YAML, TOML).
5+
"""
6+
7+
from pathlib import Path
8+
from typing import Any
9+
10+
from project.common.utils.file.factory import get_file_handler
11+
12+
13+
def load_file(path: str | Path) -> Any: # noqa: ANN401
14+
"""Load data from a file, automatically detecting format from extension.
15+
16+
Args:
17+
path: Path to the file (extension determines format)
18+
19+
Returns:
20+
Deserialized data from the file
21+
22+
Raises:
23+
ValueError: If file format cannot be detected or is unsupported
24+
25+
Example:
26+
>>> data = load_file('config.json')
27+
>>> data = load_file('settings.yaml')
28+
>>> data = load_file('pyproject.toml')
29+
30+
"""
31+
handler = get_file_handler(path)
32+
return handler.load(path)
33+
34+
35+
def save_file(
36+
data: Any, # noqa: ANN401
37+
path: str | Path,
38+
*,
39+
parents: bool = True,
40+
exist_ok: bool = True,
41+
) -> None:
42+
"""Save data to a file, automatically detecting format from extension.
43+
44+
Args:
45+
data: Data to save
46+
path: Path where the file should be saved (extension determines format)
47+
parents: If True, create parent directories as needed
48+
exist_ok: If True, don't raise error if directory exists
49+
50+
Raises:
51+
ValueError: If file format cannot be detected or is unsupported
52+
53+
Example:
54+
>>> save_file({'key': 'value'}, 'output.json')
55+
>>> save_file(['item1', 'item2'], 'output.yaml')
56+
>>> save_file({'tool': {'poetry': {}}}, 'pyproject.toml')
57+
58+
"""
59+
handler = get_file_handler(path)
60+
handler.save(data, path, parents=parents, exist_ok=exist_ok)

src/project/common/utils/file/json.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,22 @@ def save_as_indented_json(
2020
target.parent.mkdir(parents=parents, exist_ok=exist_ok)
2121
with target.open(mode='w', encoding='utf-8') as fout:
2222
json.dump(data, fout, ensure_ascii=False, indent=4, separators=(',', ': '))
23+
24+
25+
class JsonFileHandler:
26+
"""JSON file handler implementing FileHandler protocol."""
27+
28+
def load(self, path: str | Path) -> JsonValue:
29+
"""Load JSON data from file."""
30+
return load_json(path)
31+
32+
def save(
33+
self,
34+
data: JsonValue,
35+
path: str | Path,
36+
*,
37+
parents: bool = True,
38+
exist_ok: bool = True,
39+
) -> None:
40+
"""Save data as indented JSON to file."""
41+
save_as_indented_json(data, path, parents=parents, exist_ok=exist_ok)

src/project/common/utils/file/toml.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,22 @@ def save_as_toml(
1919
target.parent.mkdir(parents=parents, exist_ok=exist_ok)
2020
with target.open(mode='w', encoding='utf-8') as fout:
2121
toml.dump(data, fout)
22+
23+
24+
class TomlFileHandler:
25+
"""TOML file handler implementing FileHandler protocol."""
26+
27+
def load(self, path: str | Path) -> dict[str, Any]:
28+
"""Load TOML data from file."""
29+
return load_toml(path)
30+
31+
def save(
32+
self,
33+
data: dict[str, Any],
34+
path: str | Path,
35+
*,
36+
parents: bool = True,
37+
exist_ok: bool = True,
38+
) -> None:
39+
"""Save data as TOML to file."""
40+
save_as_toml(data, path, parents=parents, exist_ok=exist_ok)

0 commit comments

Comments
 (0)