diff --git a/README.md b/README.md index e843e9b..900ba5e 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,7 @@ Your Python dependencies can be packaged as .py files, .zip archives (containing Your entry point script will define logic using the `Client` object which wraps data access layers. You should only need the following methods: +* `read_file(file_name)` - Returns a file handle of the provided file_name * `read_dlo(name)` – Read from a Data Lake Object by name * `read_dmo(name)` – Read from a Data Model Object by name * `write_to_dlo(name, spark_dataframe, write_mode)` – Write to a Data Model Object by name with a Spark dataframe diff --git a/docs/file_reader_refactoring.md b/docs/file_reader_refactoring.md new file mode 100644 index 0000000..a1d88c5 --- /dev/null +++ b/docs/file_reader_refactoring.md @@ -0,0 +1,179 @@ +# DefaultFileReader Class Refactoring + +## Overview + +The `DefaultFileReader` class has been refactored to improve testability, readability, and maintainability. This document outlines the changes made and how to use the new implementation. + +## Key Improvements + +### 1. **Separation of Concerns** +- **File path resolution** is now handled by dedicated methods +- **File opening** is separated from path resolution +- **Configuration management** is centralized and configurable + +### 2. **Enhanced Testability** +- **Dependency injection** through constructor parameters +- **Mockable methods** for unit testing +- **Clear interfaces** between different responsibilities +- **Comprehensive test coverage** with isolated test cases + +### 3. **Better Error Handling** +- **Custom exception hierarchy** for different error types +- **Descriptive error messages** with context +- **Proper exception chaining** for debugging + +### 4. **Improved Configuration** +- **Configurable defaults** that can be overridden +- **Environment-specific settings** support +- **Clear configuration contract** + +### 5. **Enhanced Readability** +- **Comprehensive docstrings** for all methods +- **Clear method names** that describe their purpose +- **Logical method organization** from public to private +- **Type hints** throughout the codebase + +## Class Structure + +### DefaultFileReader +The main class that provides the file reading framework: + +```python +class DefaultFileReader(BaseDataAccessLayer): + # Configuration constants + DEFAULT_CODE_PACKAGE = 'payload' + DEFAULT_FILE_FOLDER = 'files' + DEFAULT_CONFIG_FILE = 'config.json' + + def __init__(self, code_package=None, file_folder=None, config_file=None): + # Initialize with custom or default configuration + + def file_open(self, file_name: str) -> io.TextIOWrapper: + # Main public method for opening files + + def get_search_locations(self) -> list[Path]: + # Get all possible search locations +``` + +## Exception Hierarchy + +```python +FileReaderError (base) +├── FileNotFoundError (file not found in any location) +└── FileAccessError (permission, I/O errors, etc.) +``` + +## Usage Examples + +### Basic Usage +```python +from datacustomcode.file.reader.default import DefaultFileReader + +# Use default configuration +reader = DefaultFileReader() +with reader.file_open('data.csv') as f: + content = f.read() +``` + +### Custom Configuration +```python +from datacustomcode.file.reader.default import DefaultFileReader + +# Custom configuration +reader = DefaultFileReader( + code_package='my_package', + file_folder='data', + config_file='settings.json' +) +``` + +### Error Handling +```python +try: + with reader.file_open('data.csv') as f: + content = f.read() +except FileNotFoundError as e: + print(f"File not found: {e}") +except FileAccessError as e: + print(f"Access error: {e}") +``` + +## File Resolution Strategy + +The file reader uses a two-tier search strategy: + +1. **Primary Location**: `{code_package}/{file_folder}/{filename}` +2. **Fallback Location**: `{config_file_parent}/{file_folder}/{filename}` + +This allows for flexible deployment scenarios where files might be in different locations depending on the environment. + +## Testing + +### Unit Tests +The refactored class includes comprehensive unit tests covering: +- Configuration initialization +- File path resolution +- Error handling scenarios +- File opening operations +- Search location determination + +### Mocking +The class is designed for easy mocking in tests: +```python +from unittest.mock import patch + +with patch('DefaultFileReader._resolve_file_path') as mock_resolve: + mock_resolve.return_value = Path('/test/file.txt') + # Test file opening logic +``` + +### Integration Tests +Integration tests verify the complete file resolution and opening flow using temporary directories and real file operations. + +## Migration Guide + +### From Old Implementation +The old implementation had these issues: +- Hardcoded configuration values +- Mixed responsibilities in single methods +- Limited error handling +- Difficult to test + +### To New Implementation +1. **Update imports**: Use `DefaultFileReader` from `datacustomcode.file.reader.default` +2. **Error handling**: Catch specific exceptions instead of generic ones +3. **Configuration**: Use constructor parameters for custom settings +4. **Testing**: Leverage the new mockable methods + +## Benefits + +### For Developers +- **Easier debugging** with clear error messages +- **Better IDE support** with type hints and docstrings +- **Simplified testing** with dependency injection +- **Clearer code structure** with separated responsibilities + +### For Maintainers +- **Easier to extend** with new file resolution strategies +- **Better error tracking** with custom exception types +- **Improved test coverage** with isolated test cases +- **Clearer documentation** with comprehensive docstrings + +### For Users +- **More reliable** with proper error handling +- **More flexible** with configurable behavior +- **Better debugging** with descriptive error messages +- **Consistent interface** across different implementations + +## Future Enhancements + +The refactored structure makes it easy to add: +- **Additional file resolution strategies** (URLs, cloud storage, etc.) +- **File format detection** and automatic handling +- **Caching mechanisms** for frequently accessed files +- **Async file operations** for better performance +- **File validation** and integrity checking + +## Conclusion + +The refactored `DefaultFileReader` class provides a solid foundation for file reading operations while maintaining backward compatibility. The improvements in testability, readability, and maintainability make it easier to develop, test, and maintain file reading functionality in the Data Cloud Custom Code SDK. diff --git a/poetry.lock b/poetry.lock index 51a9c6e..01af0f0 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2205,13 +2205,13 @@ files = [ [[package]] name = "pyspark" -version = "3.5.1" +version = "3.5.6" description = "Apache Spark Python API" optional = false python-versions = ">=3.8" groups = ["main"] files = [ - {file = "pyspark-3.5.1.tar.gz", hash = "sha256:dd6569e547365eadc4f887bf57f153e4d582a68c4b490de475d55b9981664910"}, + {file = "pyspark-3.5.6.tar.gz", hash = "sha256:f8b1c4360e41ab398c64904fae08740503bcb6bd389457d659fa6d9f2952cc48"}, ] [package.dependencies] @@ -3302,4 +3302,4 @@ cffi = ["cffi (>=1.11)"] [metadata] lock-version = "2.1" python-versions = ">=3.10,<3.12" -content-hash = "e1bae8d48b24e894b3d007c2faf60be866db10ebf674d92fac73c44d292a9ffc" +content-hash = "7e6869f93ffb757fc9ba16c26b76a5975b77cbf6957ce6eff7b85db482bddec9" diff --git a/pyproject.toml b/pyproject.toml index 1dc434f..1b95bb9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -100,7 +100,7 @@ loguru = "^0.7.3" numpy = "*" pandas = "*" pydantic = "^1.8.2 || ^2.0.0" -pyspark = "3.5.1" +pyspark = "3.5.6" python = ">=3.10,<3.12" pyyaml = "^6.0" salesforce-cdp-connector = "*" diff --git a/src/datacustomcode/client.py b/src/datacustomcode/client.py index 7a1f9a4..58ec656 100644 --- a/src/datacustomcode/client.py +++ b/src/datacustomcode/client.py @@ -24,9 +24,12 @@ from pyspark.sql import SparkSession from datacustomcode.config import SparkConfig, config +from datacustomcode.file.reader.default import DefaultFileReader from datacustomcode.io.reader.base import BaseDataCloudReader if TYPE_CHECKING: + import io + from pyspark.sql import DataFrame as PySparkDataFrame from datacustomcode.io.reader.base import BaseDataCloudReader @@ -112,6 +115,7 @@ class Client: _instance: ClassVar[Optional[Client]] = None _reader: BaseDataCloudReader _writer: BaseDataCloudWriter + _file: DefaultFileReader _data_layer_history: dict[DataCloudObjectType, set[str]] def __new__( @@ -154,6 +158,7 @@ def __new__( writer_init = writer cls._instance._reader = reader_init cls._instance._writer = writer_init + cls._instance._file = DefaultFileReader() cls._instance._data_layer_history = { DataCloudObjectType.DLO: set(), DataCloudObjectType.DMO: set(), @@ -212,6 +217,11 @@ def write_to_dmo( self._validate_data_layer_history_does_not_contain(DataCloudObjectType.DLO) return self._writer.write_to_dmo(name, dataframe, write_mode, **kwargs) + def read_file(self, file_name: str) -> io.TextIOWrapper: + """Read a file from the local file system.""" + + return self._file.read_file(file_name) + def _validate_data_layer_history_does_not_contain( self, data_cloud_object_type: DataCloudObjectType ) -> None: diff --git a/src/datacustomcode/file/__init__.py b/src/datacustomcode/file/__init__.py new file mode 100644 index 0000000..93988ff --- /dev/null +++ b/src/datacustomcode/file/__init__.py @@ -0,0 +1,14 @@ +# Copyright (c) 2025, Salesforce, Inc. +# SPDX-License-Identifier: Apache-2 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/src/datacustomcode/file/base.py b/src/datacustomcode/file/base.py new file mode 100644 index 0000000..fdd8320 --- /dev/null +++ b/src/datacustomcode/file/base.py @@ -0,0 +1,19 @@ +# Copyright (c) 2025, Salesforce, Inc. +# SPDX-License-Identifier: Apache-2 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from __future__ import annotations + + +class BaseDataAccessLayer: + """Base class for data access layer implementations.""" diff --git a/src/datacustomcode/file/reader/__init__.py b/src/datacustomcode/file/reader/__init__.py new file mode 100644 index 0000000..93988ff --- /dev/null +++ b/src/datacustomcode/file/reader/__init__.py @@ -0,0 +1,14 @@ +# Copyright (c) 2025, Salesforce, Inc. +# SPDX-License-Identifier: Apache-2 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/src/datacustomcode/file/reader/default.py b/src/datacustomcode/file/reader/default.py new file mode 100644 index 0000000..22f6b6f --- /dev/null +++ b/src/datacustomcode/file/reader/default.py @@ -0,0 +1,203 @@ +# Copyright (c) 2025, Salesforce, Inc. +# SPDX-License-Identifier: Apache-2 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from __future__ import annotations + +import os +from pathlib import Path +from typing import TYPE_CHECKING, Optional + +from datacustomcode.file.base import BaseDataAccessLayer + +if TYPE_CHECKING: + import io + + +class FileReaderError(Exception): + """Base exception for file reader operations.""" + + +class FileNotFoundError(FileReaderError): + """Raised when a file cannot be found.""" + + +class FileAccessError(FileReaderError): + """Raised when there's an error accessing a file.""" + + +class DefaultFileReader(BaseDataAccessLayer): + """Base class for file reading operations. + + This class provides a framework for reading files from various locations + with configurable search strategies and error handling. + """ + + # Default configuration values + DEFAULT_CODE_PACKAGE = "payload" + DEFAULT_FILE_FOLDER = "files" + DEFAULT_CONFIG_FILE = "config.json" + + def __init__( + self, + code_package: Optional[str] = None, + file_folder: Optional[str] = None, + config_file: Optional[str] = None, + ): + """Initialize the file reader with configuration. + + Args: + code_package: The default code package directory to search + file_folder: The folder containing files relative to the code package + config_file: The configuration file to use for path resolution + """ + self.code_package = code_package or self.DEFAULT_CODE_PACKAGE + self.file_folder = file_folder or self.DEFAULT_FILE_FOLDER + self.config_file = config_file or self.DEFAULT_CONFIG_FILE + + def read_file(self, file_name: str) -> "io.TextIOWrapper": + """Open a file for reading. + + Args: + file_name: The name of the file to open + + Returns: + A file handle for reading + + Raises: + FileNotFoundError: If the file cannot be found + FileAccessError: If there's an error opening the file + """ + if not file_name: + raise ValueError("file_name cannot be empty") + + file_path = self._resolve_file_path(file_name) + + if not file_path: + raise FileNotFoundError( + f"File '{file_name}' not found in any search location" + ) + + try: + return self._open_file(file_path) + except (OSError, IOError) as e: + raise FileAccessError(f"Error opening file '{file_path}': {e}") from e + + def _resolve_file_path(self, file_name: str) -> Optional[Path]: + """Resolve the full path to a file. + + Args: + file_name: The name of the file to resolve + + Returns: + The full path to the file, or None if not found + """ + # First try the default code package location + if self._code_package_exists(): + file_path = self._get_code_package_file_path(file_name) + if file_path.exists(): + return file_path + + # Fall back to config.json-based location + config_path = self._find_config_file() + if config_path: + file_path = self._get_config_based_file_path(file_name, config_path) + if file_path.exists(): + return file_path + + return Path(file_name) + + def _code_package_exists(self) -> bool: + """Check if the default code package directory exists. + + Returns: + True if the code package directory exists + """ + return os.path.exists(self.code_package) + + def _get_code_package_file_path(self, file_name: str) -> Path: + """Get the file path relative to the code package. + + Args: + file_name: The name of the file + + Returns: + The full path to the file + """ + relative_path = f"{self.code_package}/{self.file_folder}/{file_name}" + return Path.cwd().joinpath(relative_path) + + def _find_config_file(self) -> Optional[Path]: + """Find the configuration file in the current directory tree. + + Returns: + The path to the config file, or None if not found + """ + return self._find_file_in_tree(self.config_file, Path.cwd()) + + def _get_config_based_file_path(self, file_name: str, config_path: Path) -> Path: + """Get the file path relative to the config file location. + + Args: + file_name: The name of the file + config_path: The path to the config file + + Returns: + The full path to the file + """ + relative_path = f"{self.file_folder}/{file_name}" + return config_path.parent.joinpath(relative_path) + + def _find_file_in_tree(self, filename: str, search_path: Path) -> Optional[Path]: + """Find a file within a directory tree. + + Args: + filename: The name of the file to find + search_path: The root directory to search from + + Returns: + The full path to the file, or None if not found + """ + for file_path in search_path.rglob(filename): + return file_path + return None + + def _open_file(self, file_path: Path) -> "io.TextIOWrapper": + """Open a file at the given path. + + Args: + file_path: The path to the file + + Returns: + A file handle for reading + """ + return open(file_path, "r", encoding="utf-8") + + def get_search_locations(self) -> list[Path]: + """Get all possible search locations for files. + + Returns: + A list of paths where files might be found + """ + locations = [] + + # Add code package location + if self._code_package_exists(): + locations.append(Path.cwd().joinpath(self.code_package, self.file_folder)) + + # Add config-based location + config_path = self._find_config_file() + if config_path: + locations.append(config_path.parent.joinpath(self.file_folder)) + + return locations diff --git a/tests/test_file_reader.py b/tests/test_file_reader.py new file mode 100644 index 0000000..1e072b7 --- /dev/null +++ b/tests/test_file_reader.py @@ -0,0 +1,293 @@ +# Copyright (c) 2025, Salesforce, Inc. +# SPDX-License-Identifier: Apache-2 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +from pathlib import Path +import tempfile +from unittest.mock import Mock, patch + +import pytest + +from datacustomcode.file.reader.default import ( + DefaultFileReader, + FileAccessError, + FileNotFoundError, + FileReaderError, +) + + +class TestDefaultFileReader: + """Test cases for the DefaultFileReader class.""" + + def test_init_with_defaults(self): + """Test initialization with default values.""" + reader = DefaultFileReader() + assert reader.code_package == "payload" + assert reader.file_folder == "files" + assert reader.config_file == "config.json" + + def test_init_with_custom_values(self): + """Test initialization with custom values.""" + reader = DefaultFileReader( + code_package="custom_package", + file_folder="custom_files", + config_file="custom_config.json", + ) + assert reader.code_package == "custom_package" + assert reader.file_folder == "custom_files" + assert reader.config_file == "custom_config.json" + + def test_file_open_with_empty_filename(self): + """Test that read_file raises ValueError for empty filename.""" + reader = DefaultFileReader() + with pytest.raises(ValueError, match="file_name cannot be empty"): + reader.read_file("") + + def test_file_open_with_none_filename(self): + """Test that read_file raises ValueError for None filename.""" + reader = DefaultFileReader() + with pytest.raises(ValueError, match="file_name cannot be empty"): + reader.read_file(None) + + @patch("datacustomcode.file.reader.default.DefaultFileReader._resolve_file_path") + @patch("datacustomcode.file.reader.default.DefaultFileReader._open_file") + def test_file_open_success(self, mock_open_file, mock_resolve_path): + """Test successful file opening.""" + mock_path = Path("/test/path/file.txt") + mock_file_handle = Mock() + + mock_resolve_path.return_value = mock_path + mock_open_file.return_value = mock_file_handle + + reader = DefaultFileReader() + result = reader.read_file("test.txt") + + assert result == mock_file_handle + mock_resolve_path.assert_called_once_with("test.txt") + mock_open_file.assert_called_once_with(mock_path) + + @patch("datacustomcode.file.reader.default.DefaultFileReader._resolve_file_path") + def test_file_open_file_not_found(self, mock_resolve_path): + """Test read_file when file is not found.""" + mock_resolve_path.return_value = None + + reader = DefaultFileReader() + with pytest.raises(FileNotFoundError, match="File 'test.txt' not found"): + reader.read_file("test.txt") + + @patch("datacustomcode.file.reader.default.DefaultFileReader._resolve_file_path") + @patch("datacustomcode.file.reader.default.DefaultFileReader._open_file") + def test_file_open_access_error(self, mock_open_file, mock_resolve_path): + """Test read_file when there's an access error.""" + mock_path = Path("/test/path/file.txt") + mock_resolve_path.return_value = mock_path + mock_open_file.side_effect = PermissionError("Permission denied") + + reader = DefaultFileReader() + with pytest.raises(FileAccessError, match="Error opening file"): + reader.read_file("test.txt") + + def test_code_package_exists_true(self): + """Test _code_package_exists when directory exists.""" + with patch("os.path.exists", return_value=True): + reader = DefaultFileReader() + assert reader._code_package_exists() is True + + def test_code_package_exists_false(self): + """Test _code_package_exists when directory doesn't exist.""" + with patch("os.path.exists", return_value=False): + reader = DefaultFileReader() + assert reader._code_package_exists() is False + + def test_get_code_package_file_path(self): + """Test _get_code_package_file_path.""" + reader = DefaultFileReader() + result = reader._get_code_package_file_path("test.txt") + expected = Path.cwd().joinpath("payload", "files", "test.txt") + assert result == expected + + def test_get_config_based_file_path(self): + """Test _get_config_based_file_path.""" + reader = DefaultFileReader() + config_path = Path("/test/config.json") + result = reader._get_config_based_file_path("test.txt", config_path) + expected = Path("/test/files/test.txt") + assert result == expected + + def test_find_file_in_tree_found(self): + """Test _find_file_in_tree when file is found.""" + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + test_file = temp_path / "test.txt" + test_file.write_text("test content") + + reader = DefaultFileReader() + result = reader._find_file_in_tree("test.txt", temp_path) + + assert result == test_file + + def test_find_file_in_tree_not_found(self): + """Test _find_file_in_tree when file is not found.""" + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + reader = DefaultFileReader() + result = reader._find_file_in_tree("nonexistent.txt", temp_path) + + assert result is None + + def test_open_file(self): + """Test _open_file method.""" + reader = DefaultFileReader() + + with tempfile.NamedTemporaryFile(mode="w", delete=False) as temp_file: + temp_file.write("test content") + temp_file_path = Path(temp_file.name) + + try: + with reader._open_file(temp_file_path) as f: + content = f.read() + assert content == "test content" + finally: + os.unlink(temp_file_path) + + def test_get_search_locations_with_code_package(self): + """Test get_search_locations when code package exists.""" + with patch( + "datacustomcode.file.reader.default.DefaultFileReader._code_package_exists", + return_value=True, + ): + with patch( + "datacustomcode.file.reader.default.DefaultFileReader._find_config_file", + return_value=None, + ): + reader = DefaultFileReader() + locations = reader.get_search_locations() + + assert len(locations) == 1 + expected = Path.cwd().joinpath("payload", "files") + assert locations[0] == expected + + def test_get_search_locations_with_config(self): + """Test get_search_locations when config file exists.""" + with patch( + "datacustomcode.file.reader.default.DefaultFileReader._code_package_exists", + return_value=False, + ): + with patch( + "datacustomcode.file.reader.default.DefaultFileReader._find_config_file", + return_value=Path("/test/config.json"), + ): + reader = DefaultFileReader() + locations = reader.get_search_locations() + + assert len(locations) == 1 + expected = Path("/test/files") + assert locations[0] == expected + + def test_get_search_locations_both(self): + """Test get_search_locations when both locations exist.""" + with patch( + "datacustomcode.file.reader.default.DefaultFileReader._code_package_exists", + return_value=True, + ): + with patch( + "datacustomcode.file.reader.default.DefaultFileReader._find_config_file", + return_value=Path("/test/config.json"), + ): + reader = DefaultFileReader() + locations = reader.get_search_locations() + + assert len(locations) == 2 + expected_code_package = Path.cwd().joinpath("payload", "files") + expected_config = Path("/test/files") + assert locations[0] == expected_code_package + assert locations[1] == expected_config + + +class TestFileReaderIntegration: + """Integration tests for the file reader.""" + + def test_full_file_resolution_flow(self): + """Test the complete file resolution and opening flow.""" + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + # Create a mock payload/files structure + payload_dir = temp_path / "payload" + files_dir = payload_dir / "files" + files_dir.mkdir(parents=True) + + test_file = files_dir / "test.txt" + test_file.write_text("test content") + + # Change to temp directory and test + original_cwd = os.getcwd() + try: + os.chdir(temp_path) + + reader = DefaultFileReader() + with reader.read_file("test.txt") as f: + content = f.read() + assert content == "test content" + finally: + os.chdir(original_cwd) + + def test_fallback_to_config_based_location(self): + """Test fallback from code package to config-based location.""" + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + # Create config.json but no payload directory + config_file = temp_path / "config.json" + config_file.write_text("{}") + + files_dir = temp_path / "files" + files_dir.mkdir() + + test_file = files_dir / "test.txt" + test_file.write_text("test content") + + # Change to temp directory and test + original_cwd = os.getcwd() + try: + os.chdir(temp_path) + + reader = DefaultFileReader() + with reader.read_file("test.txt") as f: + content = f.read() + assert content == "test content" + finally: + os.chdir(original_cwd) + + +class TestFileReaderErrorHandling: + """Test error handling scenarios.""" + + def test_file_reader_error_inheritance(self): + """Test that FileReaderError is the base exception.""" + assert issubclass(FileNotFoundError, FileReaderError) + assert issubclass(FileAccessError, FileReaderError) + + def test_file_not_found_error_message(self): + """Test FileNotFoundError message formatting.""" + error = FileNotFoundError("test.txt") + assert "test.txt" in str(error) + + def test_file_access_error_message(self): + """Test FileAccessError message formatting.""" + error = FileAccessError("test.txt", "Permission denied") + assert "test.txt" in str(error) + assert "Permission denied" in str(error)