filelayer 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sireto
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,125 @@
1
+ Metadata-Version: 2.4
2
+ Name: filelayer
3
+ Version: 0.1.0
4
+ Summary: Simple file access abstraction over local filesystem and S3-compatible storage.
5
+ Author: Sireto
6
+ License: MIT
7
+ Project-URL: Homepage, https://sireto.io
8
+ Project-URL: Repository, https://sireto.io
9
+ Keywords: storage,s3,wasabi,filesystem,abstraction,boto3
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
+ Classifier: Topic :: System :: Filesystems
19
+ Requires-Python: >=3.11
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: boto3<2.0,>=1.34
23
+ Requires-Dist: pydantic<3.0,>=2.7
24
+ Requires-Dist: pydantic-settings<3.0,>=2.2
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest<9.0,>=8.0; extra == "dev"
27
+ Dynamic: license-file
28
+
29
+ # filelayer
30
+
31
+ `filelayer` is a small Python package that provides a simple file abstraction over:
32
+
33
+ - local filesystem
34
+ - S3-compatible object storage such as Wasabi
35
+
36
+ It exposes a minimal API:
37
+
38
+ - `read_file(filepath) -> str`
39
+ - `write_file(filepath, file_content) -> None`
40
+ - `read_file_bytes(filepath) -> bytes`
41
+ - `write_file_bytes(filepath, file_bytes) -> None`
42
+ - `exists(filepath) -> bool`
43
+
44
+ For S3-compatible backends, `filepath` is treated as the object key.
45
+ For local storage, `filepath` is resolved relative to the configured local base path.
46
+
47
+ ## Installation
48
+
49
+ ```bash
50
+ pip install filelayer
51
+ ```
52
+
53
+ For development:
54
+
55
+ ```bash
56
+ pip install -e .[dev]
57
+ ```
58
+
59
+ ## Local filesystem example
60
+
61
+ Environment:
62
+
63
+ ```env
64
+ STORAGE_PROVIDER=local
65
+ STORAGE_DEFAULT_PREFIX=my-app
66
+ STORAGE_ENCODING=utf-8
67
+ LOCAL_STORAGE_BASE_PATH=./data/storage
68
+ ```
69
+
70
+ Usage:
71
+
72
+ ```python
73
+ from filelayer import StorageService
74
+
75
+ storage = StorageService.from_settings()
76
+
77
+ storage.write_file("documents/example.txt", "Hello from local storage")
78
+ content = storage.read_file("documents/example.txt")
79
+ print(content)
80
+
81
+ storage.write_file_bytes("documents/example.bin", b"\x00\x01\x02")
82
+ print(storage.read_file_bytes("documents/example.bin"))
83
+ print(storage.exists("documents/example.txt"))
84
+ ```
85
+
86
+ ## Wasabi / S3-compatible example
87
+
88
+ Environment:
89
+
90
+ ```env
91
+ STORAGE_PROVIDER=s3
92
+ STORAGE_DEFAULT_PREFIX=my-app
93
+ STORAGE_ENCODING=utf-8
94
+
95
+ S3_ENDPOINT_URL=https://s3.eu-central-1.wasabisys.com
96
+ S3_ACCESS_KEY_ID=your-access-key
97
+ S3_SECRET_ACCESS_KEY=your-secret-key
98
+ S3_REGION_NAME=eu-central-1
99
+ S3_BUCKET=your-bucket
100
+ S3_USE_SSL=true
101
+ S3_VERIFY_SSL=true
102
+ S3_ADDRESSING_STYLE=virtual
103
+ S3_CONNECT_TIMEOUT=10
104
+ S3_READ_TIMEOUT=60
105
+ S3_MAX_ATTEMPTS=5
106
+ ```
107
+
108
+ Usage:
109
+
110
+ ```python
111
+ from filelayer import StorageService
112
+
113
+ storage = StorageService.from_settings()
114
+
115
+ storage.write_file("documents/example.txt", "Hello from Wasabi")
116
+ print(storage.read_file("documents/example.txt"))
117
+ print(storage.exists("documents/example.txt"))
118
+ ```
119
+
120
+ ## Notes
121
+
122
+ - `STORAGE_DEFAULT_PREFIX` is prepended to all paths or keys.
123
+ - `write_file()` stores text using `STORAGE_ENCODING`.
124
+ - `write_file_bytes()` stores raw bytes unchanged.
125
+ - Local provider prevents path traversal outside the configured storage root.
@@ -0,0 +1,97 @@
1
+ # filelayer
2
+
3
+ `filelayer` is a small Python package that provides a simple file abstraction over:
4
+
5
+ - local filesystem
6
+ - S3-compatible object storage such as Wasabi
7
+
8
+ It exposes a minimal API:
9
+
10
+ - `read_file(filepath) -> str`
11
+ - `write_file(filepath, file_content) -> None`
12
+ - `read_file_bytes(filepath) -> bytes`
13
+ - `write_file_bytes(filepath, file_bytes) -> None`
14
+ - `exists(filepath) -> bool`
15
+
16
+ For S3-compatible backends, `filepath` is treated as the object key.
17
+ For local storage, `filepath` is resolved relative to the configured local base path.
18
+
19
+ ## Installation
20
+
21
+ ```bash
22
+ pip install filelayer
23
+ ```
24
+
25
+ For development:
26
+
27
+ ```bash
28
+ pip install -e .[dev]
29
+ ```
30
+
31
+ ## Local filesystem example
32
+
33
+ Environment:
34
+
35
+ ```env
36
+ STORAGE_PROVIDER=local
37
+ STORAGE_DEFAULT_PREFIX=my-app
38
+ STORAGE_ENCODING=utf-8
39
+ LOCAL_STORAGE_BASE_PATH=./data/storage
40
+ ```
41
+
42
+ Usage:
43
+
44
+ ```python
45
+ from filelayer import StorageService
46
+
47
+ storage = StorageService.from_settings()
48
+
49
+ storage.write_file("documents/example.txt", "Hello from local storage")
50
+ content = storage.read_file("documents/example.txt")
51
+ print(content)
52
+
53
+ storage.write_file_bytes("documents/example.bin", b"\x00\x01\x02")
54
+ print(storage.read_file_bytes("documents/example.bin"))
55
+ print(storage.exists("documents/example.txt"))
56
+ ```
57
+
58
+ ## Wasabi / S3-compatible example
59
+
60
+ Environment:
61
+
62
+ ```env
63
+ STORAGE_PROVIDER=s3
64
+ STORAGE_DEFAULT_PREFIX=my-app
65
+ STORAGE_ENCODING=utf-8
66
+
67
+ S3_ENDPOINT_URL=https://s3.eu-central-1.wasabisys.com
68
+ S3_ACCESS_KEY_ID=your-access-key
69
+ S3_SECRET_ACCESS_KEY=your-secret-key
70
+ S3_REGION_NAME=eu-central-1
71
+ S3_BUCKET=your-bucket
72
+ S3_USE_SSL=true
73
+ S3_VERIFY_SSL=true
74
+ S3_ADDRESSING_STYLE=virtual
75
+ S3_CONNECT_TIMEOUT=10
76
+ S3_READ_TIMEOUT=60
77
+ S3_MAX_ATTEMPTS=5
78
+ ```
79
+
80
+ Usage:
81
+
82
+ ```python
83
+ from filelayer import StorageService
84
+
85
+ storage = StorageService.from_settings()
86
+
87
+ storage.write_file("documents/example.txt", "Hello from Wasabi")
88
+ print(storage.read_file("documents/example.txt"))
89
+ print(storage.exists("documents/example.txt"))
90
+ ```
91
+
92
+ ## Notes
93
+
94
+ - `STORAGE_DEFAULT_PREFIX` is prepended to all paths or keys.
95
+ - `write_file()` stores text using `STORAGE_ENCODING`.
96
+ - `write_file_bytes()` stores raw bytes unchanged.
97
+ - Local provider prevents path traversal outside the configured storage root.
@@ -0,0 +1,50 @@
1
+ [build-system]
2
+ requires = ["setuptools>=69.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "filelayer"
7
+ version = "0.1.0"
8
+ description = "Simple file access abstraction over local filesystem and S3-compatible storage."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = { text = "MIT" }
12
+ authors = [
13
+ { name = "Sireto" }
14
+ ]
15
+ keywords = ["storage", "s3", "wasabi", "filesystem", "abstraction", "boto3"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Intended Audience :: Developers",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Operating System :: OS Independent",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Topic :: Software Development :: Libraries :: Python Modules",
25
+ "Topic :: System :: Filesystems",
26
+ ]
27
+ dependencies = [
28
+ "boto3>=1.34,<2.0",
29
+ "pydantic>=2.7,<3.0",
30
+ "pydantic-settings>=2.2,<3.0",
31
+ ]
32
+
33
+ [project.optional-dependencies]
34
+ dev = [
35
+ "pytest>=8.0,<9.0",
36
+ ]
37
+
38
+ [project.urls]
39
+ Homepage = "https://sireto.io"
40
+ Repository = "https://sireto.io"
41
+
42
+ [tool.setuptools]
43
+ package-dir = {"" = "src"}
44
+
45
+ [tool.setuptools.packages.find]
46
+ where = ["src"]
47
+
48
+ [tool.pytest.ini_options]
49
+ testpaths = ["tests"]
50
+ pythonpath = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,28 @@
1
+ from .config import StorageSettings
2
+ from .exceptions import (
3
+ StorageConfigurationError,
4
+ StorageError,
5
+ StorageObjectNotFoundError,
6
+ StorageReadError,
7
+ StorageWriteError,
8
+ )
9
+ from .factory import create_file_provider
10
+ from .logging_utils import StructuredLogger, configure_logging
11
+ from .providers import FileProvider, LocalFileProvider, S3FileProvider
12
+ from .service import StorageService
13
+
14
+ __all__ = [
15
+ "StorageSettings",
16
+ "StorageError",
17
+ "StorageConfigurationError",
18
+ "StorageReadError",
19
+ "StorageWriteError",
20
+ "StorageObjectNotFoundError",
21
+ "StructuredLogger",
22
+ "configure_logging",
23
+ "FileProvider",
24
+ "LocalFileProvider",
25
+ "S3FileProvider",
26
+ "create_file_provider",
27
+ "StorageService",
28
+ ]
@@ -0,0 +1,132 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Literal
5
+
6
+ from pydantic import AliasChoices, Field, SecretStr, field_validator, model_validator
7
+ from pydantic_settings import BaseSettings, SettingsConfigDict
8
+
9
+ from .exceptions import StorageConfigurationError
10
+
11
+
12
+ class StorageSettings(BaseSettings):
13
+ model_config = SettingsConfigDict(
14
+ env_file=".env",
15
+ env_file_encoding="utf-8",
16
+ case_sensitive=False,
17
+ extra="ignore",
18
+ )
19
+
20
+ provider: Literal["local", "s3"] = Field(
21
+ ...,
22
+ validation_alias=AliasChoices("STORAGE_PROVIDER"),
23
+ )
24
+
25
+ default_prefix: str = Field(
26
+ default="",
27
+ validation_alias=AliasChoices("STORAGE_DEFAULT_PREFIX"),
28
+ )
29
+
30
+ encoding: str = Field(
31
+ default="utf-8",
32
+ validation_alias=AliasChoices("STORAGE_ENCODING"),
33
+ )
34
+
35
+ local_base_path: Path = Field(
36
+ default=Path("./data/storage"),
37
+ validation_alias=AliasChoices("LOCAL_STORAGE_BASE_PATH"),
38
+ )
39
+
40
+ s3_endpoint_url: str | None = Field(
41
+ default=None,
42
+ validation_alias=AliasChoices("S3_ENDPOINT_URL"),
43
+ )
44
+ s3_access_key_id: str | None = Field(
45
+ default=None,
46
+ validation_alias=AliasChoices("S3_ACCESS_KEY_ID"),
47
+ )
48
+ s3_secret_access_key: SecretStr | None = Field(
49
+ default=None,
50
+ validation_alias=AliasChoices("S3_SECRET_ACCESS_KEY"),
51
+ )
52
+ s3_session_token: SecretStr | None = Field(
53
+ default=None,
54
+ validation_alias=AliasChoices("S3_SESSION_TOKEN"),
55
+ )
56
+ s3_region_name: str = Field(
57
+ default="us-east-1",
58
+ validation_alias=AliasChoices("S3_REGION_NAME", "AWS_DEFAULT_REGION"),
59
+ )
60
+ s3_bucket: str | None = Field(
61
+ default=None,
62
+ validation_alias=AliasChoices("S3_BUCKET", "S3_BUCKET_NAME"),
63
+ )
64
+ s3_use_ssl: bool = Field(
65
+ default=True,
66
+ validation_alias=AliasChoices("S3_USE_SSL"),
67
+ )
68
+ s3_verify_ssl: bool = Field(
69
+ default=True,
70
+ validation_alias=AliasChoices("S3_VERIFY_SSL"),
71
+ )
72
+ s3_addressing_style: Literal["virtual", "path", "auto"] = Field(
73
+ default="virtual",
74
+ validation_alias=AliasChoices("S3_ADDRESSING_STYLE"),
75
+ )
76
+ s3_connect_timeout: int = Field(
77
+ default=10,
78
+ validation_alias=AliasChoices("S3_CONNECT_TIMEOUT"),
79
+ )
80
+ s3_read_timeout: int = Field(
81
+ default=60,
82
+ validation_alias=AliasChoices("S3_READ_TIMEOUT"),
83
+ )
84
+ s3_max_attempts: int = Field(
85
+ default=5,
86
+ validation_alias=AliasChoices("S3_MAX_ATTEMPTS"),
87
+ )
88
+
89
+ @field_validator("default_prefix")
90
+ @classmethod
91
+ def normalize_default_prefix(cls, value: str) -> str:
92
+ return value.strip("/")
93
+
94
+ @field_validator("local_base_path")
95
+ @classmethod
96
+ def normalize_local_base_path(cls, value: Path) -> Path:
97
+ return value.expanduser()
98
+
99
+ @model_validator(mode="after")
100
+ def validate_provider_settings(self) -> "StorageSettings":
101
+ if self.provider == "s3":
102
+ missing: list[str] = []
103
+
104
+ if not self.s3_access_key_id:
105
+ missing.append("S3_ACCESS_KEY_ID")
106
+ if not self.s3_secret_access_key:
107
+ missing.append("S3_SECRET_ACCESS_KEY")
108
+ if not self.s3_bucket:
109
+ missing.append("S3_BUCKET")
110
+
111
+ if missing:
112
+ raise StorageConfigurationError(
113
+ "Missing required S3 settings: " + ", ".join(missing)
114
+ )
115
+
116
+ return self
117
+
118
+ @property
119
+ def s3_secret_access_key_value(self) -> str | None:
120
+ return (
121
+ self.s3_secret_access_key.get_secret_value()
122
+ if self.s3_secret_access_key
123
+ else None
124
+ )
125
+
126
+ @property
127
+ def s3_session_token_value(self) -> str | None:
128
+ return (
129
+ self.s3_session_token.get_secret_value()
130
+ if self.s3_session_token
131
+ else None
132
+ )
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class StorageError(Exception):
5
+ """Base exception for storage-related errors."""
6
+
7
+
8
+ class StorageConfigurationError(StorageError):
9
+ """Raised when storage configuration is invalid."""
10
+
11
+
12
+ class StorageReadError(StorageError):
13
+ """Raised when reading fails."""
14
+
15
+
16
+ class StorageWriteError(StorageError):
17
+ """Raised when writing fails."""
18
+
19
+
20
+ class StorageObjectNotFoundError(StorageError):
21
+ """Raised when the requested file or object does not exist."""
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+
5
+ from .config import StorageSettings
6
+ from .exceptions import StorageConfigurationError
7
+ from .providers import FileProvider, LocalFileProvider, S3FileProvider
8
+
9
+
10
+ def create_file_provider(
11
+ settings: StorageSettings | None = None,
12
+ logger: logging.Logger | None = None,
13
+ ) -> FileProvider:
14
+ resolved_settings = settings or StorageSettings()
15
+
16
+ if resolved_settings.provider == "local":
17
+ return LocalFileProvider(resolved_settings, logger=logger)
18
+
19
+ if resolved_settings.provider == "s3":
20
+ return S3FileProvider(resolved_settings, logger=logger)
21
+
22
+ raise StorageConfigurationError(
23
+ f"Unsupported storage provider: {resolved_settings.provider}"
24
+ )
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ from typing import Any
6
+
7
+
8
+ def configure_logging(level: int = logging.INFO) -> None:
9
+ logging.basicConfig(
10
+ level=level,
11
+ format="%(asctime)s %(levelname)s %(name)s %(message)s",
12
+ )
13
+
14
+
15
+ class StructuredLogger:
16
+ def __init__(self, logger: logging.Logger) -> None:
17
+ self._logger = logger
18
+
19
+ def info(self, event: str, **fields: Any) -> None:
20
+ self._logger.info(self._serialize(event, **fields))
21
+
22
+ def warning(self, event: str, **fields: Any) -> None:
23
+ self._logger.warning(self._serialize(event, **fields))
24
+
25
+ def exception(self, event: str, **fields: Any) -> None:
26
+ self._logger.exception(self._serialize(event, **fields))
27
+
28
+ @staticmethod
29
+ def _serialize(event: str, **fields: Any) -> str:
30
+ return json.dumps({"event": event, **fields}, default=str, ensure_ascii=False)
@@ -0,0 +1,9 @@
1
+ from .base import FileProvider
2
+ from .local import LocalFileProvider
3
+ from .s3 import S3FileProvider
4
+
5
+ __all__ = [
6
+ "FileProvider",
7
+ "LocalFileProvider",
8
+ "S3FileProvider",
9
+ ]
@@ -0,0 +1,25 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+
5
+
6
+ class FileProvider(ABC):
7
+ @abstractmethod
8
+ def read_file(self, filepath: str) -> str:
9
+ raise NotImplementedError
10
+
11
+ @abstractmethod
12
+ def write_file(self, filepath: str, file_content: str) -> None:
13
+ raise NotImplementedError
14
+
15
+ @abstractmethod
16
+ def read_file_bytes(self, filepath: str) -> bytes:
17
+ raise NotImplementedError
18
+
19
+ @abstractmethod
20
+ def write_file_bytes(self, filepath: str, file_bytes: bytes) -> None:
21
+ raise NotImplementedError
22
+
23
+ @abstractmethod
24
+ def exists(self, filepath: str) -> bool:
25
+ raise NotImplementedError
@@ -0,0 +1,202 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from pathlib import Path, PurePosixPath
5
+
6
+ from ..config import StorageSettings
7
+ from ..exceptions import (
8
+ StorageConfigurationError,
9
+ StorageObjectNotFoundError,
10
+ StorageReadError,
11
+ StorageWriteError,
12
+ )
13
+ from ..logging_utils import StructuredLogger
14
+ from .base import FileProvider
15
+
16
+
17
+ class LocalFileProvider(FileProvider):
18
+ def __init__(
19
+ self,
20
+ settings: StorageSettings,
21
+ logger: logging.Logger | None = None,
22
+ ) -> None:
23
+ if settings.provider != "local":
24
+ raise StorageConfigurationError(
25
+ "LocalFileProvider requires STORAGE_PROVIDER=local."
26
+ )
27
+
28
+ self.settings = settings
29
+ self.root_path = settings.local_base_path.resolve()
30
+ self.root_path.mkdir(parents=True, exist_ok=True)
31
+ self.log = StructuredLogger(logger or logging.getLogger(self.__class__.__name__))
32
+
33
+ def _normalize_filepath(self, filepath: str) -> str:
34
+ clean_path = filepath.replace("\\", "/").strip().lstrip("/")
35
+ path_parts = PurePosixPath(clean_path).parts if clean_path else ()
36
+
37
+ if any(part in {".", ".."} for part in path_parts):
38
+ raise StorageConfigurationError(f"Invalid filepath: {filepath}")
39
+
40
+ prefix = self.settings.default_prefix
41
+
42
+ if not clean_path and not prefix:
43
+ raise ValueError("filepath must not be empty.")
44
+
45
+ if prefix and clean_path:
46
+ return f"{prefix}/{clean_path}"
47
+ return clean_path or prefix
48
+
49
+ def _resolve_path(self, filepath: str) -> Path:
50
+ normalized = self._normalize_filepath(filepath)
51
+ candidate = (self.root_path / normalized).resolve()
52
+
53
+ try:
54
+ candidate.relative_to(self.root_path)
55
+ except ValueError as exc:
56
+ raise StorageConfigurationError(
57
+ f"filepath escapes local storage root: {filepath}"
58
+ ) from exc
59
+
60
+ return candidate
61
+
62
+ def read_file(self, filepath: str) -> str:
63
+ resolved = self._resolve_path(filepath)
64
+
65
+ self.log.info(
66
+ "local_read_text_started",
67
+ filepath=filepath,
68
+ resolved_path=str(resolved),
69
+ encoding=self.settings.encoding,
70
+ )
71
+
72
+ if not resolved.exists() or not resolved.is_file():
73
+ raise StorageObjectNotFoundError(f"Local file not found: {filepath}")
74
+
75
+ try:
76
+ content = resolved.read_text(encoding=self.settings.encoding)
77
+ self.log.info(
78
+ "local_read_text_completed",
79
+ filepath=filepath,
80
+ resolved_path=str(resolved),
81
+ size_chars=len(content),
82
+ )
83
+ return content
84
+ except StorageObjectNotFoundError:
85
+ raise
86
+ except UnicodeDecodeError as exc:
87
+ self.log.exception(
88
+ "local_read_text_decode_failed",
89
+ filepath=filepath,
90
+ resolved_path=str(resolved),
91
+ encoding=self.settings.encoding,
92
+ )
93
+ raise StorageReadError(
94
+ f"Failed to decode local file '{filepath}' using '{self.settings.encoding}'."
95
+ ) from exc
96
+ except Exception as exc:
97
+ self.log.exception(
98
+ "local_read_text_failed",
99
+ filepath=filepath,
100
+ resolved_path=str(resolved),
101
+ )
102
+ raise StorageReadError(f"Failed to read local file: {filepath}") from exc
103
+
104
+ def write_file(self, filepath: str, file_content: str) -> None:
105
+ resolved = self._resolve_path(filepath)
106
+ resolved.parent.mkdir(parents=True, exist_ok=True)
107
+
108
+ self.log.info(
109
+ "local_write_text_started",
110
+ filepath=filepath,
111
+ resolved_path=str(resolved),
112
+ size_chars=len(file_content),
113
+ encoding=self.settings.encoding,
114
+ )
115
+
116
+ try:
117
+ resolved.write_text(file_content, encoding=self.settings.encoding)
118
+ self.log.info(
119
+ "local_write_text_completed",
120
+ filepath=filepath,
121
+ resolved_path=str(resolved),
122
+ size_chars=len(file_content),
123
+ )
124
+ except UnicodeEncodeError as exc:
125
+ self.log.exception(
126
+ "local_write_text_encode_failed",
127
+ filepath=filepath,
128
+ resolved_path=str(resolved),
129
+ encoding=self.settings.encoding,
130
+ )
131
+ raise StorageWriteError(
132
+ f"Failed to encode local file '{filepath}' using '{self.settings.encoding}'."
133
+ ) from exc
134
+ except Exception as exc:
135
+ self.log.exception(
136
+ "local_write_text_failed",
137
+ filepath=filepath,
138
+ resolved_path=str(resolved),
139
+ )
140
+ raise StorageWriteError(f"Failed to write local file: {filepath}") from exc
141
+
142
+ def read_file_bytes(self, filepath: str) -> bytes:
143
+ resolved = self._resolve_path(filepath)
144
+
145
+ self.log.info(
146
+ "local_read_bytes_started",
147
+ filepath=filepath,
148
+ resolved_path=str(resolved),
149
+ )
150
+
151
+ if not resolved.exists() or not resolved.is_file():
152
+ raise StorageObjectNotFoundError(f"Local file not found: {filepath}")
153
+
154
+ try:
155
+ data = resolved.read_bytes()
156
+ self.log.info(
157
+ "local_read_bytes_completed",
158
+ filepath=filepath,
159
+ resolved_path=str(resolved),
160
+ size_bytes=len(data),
161
+ )
162
+ return data
163
+ except StorageObjectNotFoundError:
164
+ raise
165
+ except Exception as exc:
166
+ self.log.exception(
167
+ "local_read_bytes_failed",
168
+ filepath=filepath,
169
+ resolved_path=str(resolved),
170
+ )
171
+ raise StorageReadError(f"Failed to read local file: {filepath}") from exc
172
+
173
+ def write_file_bytes(self, filepath: str, file_bytes: bytes) -> None:
174
+ resolved = self._resolve_path(filepath)
175
+ resolved.parent.mkdir(parents=True, exist_ok=True)
176
+
177
+ self.log.info(
178
+ "local_write_bytes_started",
179
+ filepath=filepath,
180
+ resolved_path=str(resolved),
181
+ size_bytes=len(file_bytes),
182
+ )
183
+
184
+ try:
185
+ resolved.write_bytes(file_bytes)
186
+ self.log.info(
187
+ "local_write_bytes_completed",
188
+ filepath=filepath,
189
+ resolved_path=str(resolved),
190
+ size_bytes=len(file_bytes),
191
+ )
192
+ except Exception as exc:
193
+ self.log.exception(
194
+ "local_write_bytes_failed",
195
+ filepath=filepath,
196
+ resolved_path=str(resolved),
197
+ )
198
+ raise StorageWriteError(f"Failed to write local file: {filepath}") from exc
199
+
200
+ def exists(self, filepath: str) -> bool:
201
+ resolved = self._resolve_path(filepath)
202
+ return resolved.exists() and resolved.is_file()
@@ -0,0 +1,284 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from pathlib import PurePosixPath
5
+
6
+ import boto3
7
+ from botocore.client import BaseClient
8
+ from botocore.config import Config as BotoConfig
9
+ from botocore.exceptions import (
10
+ BotoCoreError,
11
+ ClientError,
12
+ ConnectTimeoutError,
13
+ EndpointConnectionError,
14
+ )
15
+
16
+ from ..config import StorageSettings
17
+ from ..exceptions import (
18
+ StorageConfigurationError,
19
+ StorageObjectNotFoundError,
20
+ StorageReadError,
21
+ StorageWriteError,
22
+ )
23
+ from ..logging_utils import StructuredLogger
24
+ from .base import FileProvider
25
+
26
+
27
+ class S3FileProvider(FileProvider):
28
+ def __init__(
29
+ self,
30
+ settings: StorageSettings,
31
+ logger: logging.Logger | None = None,
32
+ ) -> None:
33
+ if settings.provider != "s3":
34
+ raise StorageConfigurationError(
35
+ "S3FileProvider requires STORAGE_PROVIDER=s3."
36
+ )
37
+
38
+ self.settings = settings
39
+ self.bucket = settings.s3_bucket
40
+ if not self.bucket:
41
+ raise StorageConfigurationError("S3 bucket is not configured.")
42
+
43
+ self.log = StructuredLogger(logger or logging.getLogger(self.__class__.__name__))
44
+ self.client = self._build_client()
45
+
46
+ def _build_client(self) -> BaseClient:
47
+ try:
48
+ boto_config = BotoConfig(
49
+ region_name=self.settings.s3_region_name,
50
+ signature_version="s3v4",
51
+ connect_timeout=self.settings.s3_connect_timeout,
52
+ read_timeout=self.settings.s3_read_timeout,
53
+ retries={
54
+ "max_attempts": self.settings.s3_max_attempts,
55
+ "mode": "standard",
56
+ },
57
+ s3={"addressing_style": self.settings.s3_addressing_style},
58
+ )
59
+
60
+ return boto3.client(
61
+ "s3",
62
+ endpoint_url=self.settings.s3_endpoint_url,
63
+ aws_access_key_id=self.settings.s3_access_key_id,
64
+ aws_secret_access_key=self.settings.s3_secret_access_key_value,
65
+ aws_session_token=self.settings.s3_session_token_value,
66
+ region_name=self.settings.s3_region_name,
67
+ use_ssl=self.settings.s3_use_ssl,
68
+ verify=self.settings.s3_verify_ssl,
69
+ config=boto_config,
70
+ )
71
+ except Exception as exc:
72
+ raise StorageConfigurationError("Failed to initialize S3 client.") from exc
73
+
74
+ def _normalize_filepath(self, filepath: str) -> str:
75
+ clean_path = filepath.replace("\\", "/").strip().lstrip("/")
76
+ path_parts = PurePosixPath(clean_path).parts if clean_path else ()
77
+
78
+ if any(part in {".", ".."} for part in path_parts):
79
+ raise StorageConfigurationError(f"Invalid filepath: {filepath}")
80
+
81
+ prefix = self.settings.default_prefix
82
+
83
+ if not clean_path and not prefix:
84
+ raise ValueError("filepath must not be empty.")
85
+
86
+ if prefix and clean_path:
87
+ return f"{prefix}/{clean_path}"
88
+ return clean_path or prefix
89
+
90
+ def read_file(self, filepath: str) -> str:
91
+ key = self._normalize_filepath(filepath)
92
+
93
+ self.log.info(
94
+ "s3_read_text_started",
95
+ bucket=self.bucket,
96
+ key=key,
97
+ encoding=self.settings.encoding,
98
+ )
99
+
100
+ try:
101
+ response = self.client.get_object(Bucket=self.bucket, Key=key)
102
+ body = response["Body"].read()
103
+
104
+ if not isinstance(body, bytes):
105
+ raise StorageReadError(f"Unexpected response body for key: {filepath}")
106
+
107
+ content = body.decode(self.settings.encoding)
108
+
109
+ self.log.info(
110
+ "s3_read_text_completed",
111
+ bucket=self.bucket,
112
+ key=key,
113
+ size_chars=len(content),
114
+ )
115
+ return content
116
+
117
+ except ClientError as exc:
118
+ error_code = exc.response.get("Error", {}).get("Code")
119
+ if error_code in {"404", "NoSuchKey", "NotFound"}:
120
+ raise StorageObjectNotFoundError(f"S3 object not found: {filepath}") from exc
121
+
122
+ self.log.exception(
123
+ "s3_read_text_failed",
124
+ bucket=self.bucket,
125
+ key=key,
126
+ error_code=error_code,
127
+ )
128
+ raise StorageReadError(f"Failed to read S3 object: {filepath}") from exc
129
+
130
+ except UnicodeDecodeError as exc:
131
+ self.log.exception(
132
+ "s3_read_text_decode_failed",
133
+ bucket=self.bucket,
134
+ key=key,
135
+ encoding=self.settings.encoding,
136
+ )
137
+ raise StorageReadError(
138
+ f"Failed to decode S3 object '{filepath}' using '{self.settings.encoding}'."
139
+ ) from exc
140
+
141
+ except (BotoCoreError, EndpointConnectionError, ConnectTimeoutError) as exc:
142
+ self.log.exception(
143
+ "s3_read_text_failed",
144
+ bucket=self.bucket,
145
+ key=key,
146
+ )
147
+ raise StorageReadError(f"Failed to read S3 object: {filepath}") from exc
148
+
149
+ def write_file(self, filepath: str, file_content: str) -> None:
150
+ key = self._normalize_filepath(filepath)
151
+
152
+ self.log.info(
153
+ "s3_write_text_started",
154
+ bucket=self.bucket,
155
+ key=key,
156
+ size_chars=len(file_content),
157
+ encoding=self.settings.encoding,
158
+ )
159
+
160
+ try:
161
+ body = file_content.encode(self.settings.encoding)
162
+ self.client.put_object(
163
+ Bucket=self.bucket,
164
+ Key=key,
165
+ Body=body,
166
+ ContentType=f"text/plain; charset={self.settings.encoding}",
167
+ )
168
+ self.log.info(
169
+ "s3_write_text_completed",
170
+ bucket=self.bucket,
171
+ key=key,
172
+ size_chars=len(file_content),
173
+ )
174
+ except UnicodeEncodeError as exc:
175
+ self.log.exception(
176
+ "s3_write_text_encode_failed",
177
+ bucket=self.bucket,
178
+ key=key,
179
+ encoding=self.settings.encoding,
180
+ )
181
+ raise StorageWriteError(
182
+ f"Failed to encode S3 object '{filepath}' using '{self.settings.encoding}'."
183
+ ) from exc
184
+ except Exception as exc:
185
+ self.log.exception(
186
+ "s3_write_text_failed",
187
+ bucket=self.bucket,
188
+ key=key,
189
+ )
190
+ raise StorageWriteError(f"Failed to write S3 object: {filepath}") from exc
191
+
192
+ def read_file_bytes(self, filepath: str) -> bytes:
193
+ key = self._normalize_filepath(filepath)
194
+
195
+ self.log.info(
196
+ "s3_read_bytes_started",
197
+ bucket=self.bucket,
198
+ key=key,
199
+ )
200
+
201
+ try:
202
+ response = self.client.get_object(Bucket=self.bucket, Key=key)
203
+ body = response["Body"].read()
204
+
205
+ if not isinstance(body, bytes):
206
+ raise StorageReadError(f"Unexpected response body for key: {filepath}")
207
+
208
+ self.log.info(
209
+ "s3_read_bytes_completed",
210
+ bucket=self.bucket,
211
+ key=key,
212
+ size_bytes=len(body),
213
+ )
214
+ return body
215
+
216
+ except ClientError as exc:
217
+ error_code = exc.response.get("Error", {}).get("Code")
218
+ if error_code in {"404", "NoSuchKey", "NotFound"}:
219
+ raise StorageObjectNotFoundError(f"S3 object not found: {filepath}") from exc
220
+
221
+ self.log.exception(
222
+ "s3_read_bytes_failed",
223
+ bucket=self.bucket,
224
+ key=key,
225
+ error_code=error_code,
226
+ )
227
+ raise StorageReadError(f"Failed to read S3 object: {filepath}") from exc
228
+
229
+ except (BotoCoreError, EndpointConnectionError, ConnectTimeoutError) as exc:
230
+ self.log.exception(
231
+ "s3_read_bytes_failed",
232
+ bucket=self.bucket,
233
+ key=key,
234
+ )
235
+ raise StorageReadError(f"Failed to read S3 object: {filepath}") from exc
236
+
237
+ def write_file_bytes(self, filepath: str, file_bytes: bytes) -> None:
238
+ key = self._normalize_filepath(filepath)
239
+
240
+ self.log.info(
241
+ "s3_write_bytes_started",
242
+ bucket=self.bucket,
243
+ key=key,
244
+ size_bytes=len(file_bytes),
245
+ )
246
+
247
+ try:
248
+ self.client.put_object(
249
+ Bucket=self.bucket,
250
+ Key=key,
251
+ Body=file_bytes,
252
+ ContentType="application/octet-stream",
253
+ )
254
+ self.log.info(
255
+ "s3_write_bytes_completed",
256
+ bucket=self.bucket,
257
+ key=key,
258
+ size_bytes=len(file_bytes),
259
+ )
260
+ except Exception as exc:
261
+ self.log.exception(
262
+ "s3_write_bytes_failed",
263
+ bucket=self.bucket,
264
+ key=key,
265
+ )
266
+ raise StorageWriteError(f"Failed to write S3 object: {filepath}") from exc
267
+
268
+ def exists(self, filepath: str) -> bool:
269
+ key = self._normalize_filepath(filepath)
270
+
271
+ try:
272
+ self.client.head_object(Bucket=self.bucket, Key=key)
273
+ return True
274
+ except ClientError as exc:
275
+ error_code = exc.response.get("Error", {}).get("Code")
276
+ if error_code in {"404", "NoSuchKey", "NotFound"}:
277
+ return False
278
+ raise StorageReadError(
279
+ f"Failed to check existence for S3 object: {filepath}"
280
+ ) from exc
281
+ except Exception as exc:
282
+ raise StorageReadError(
283
+ f"Failed to check existence for S3 object: {filepath}"
284
+ ) from exc
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ from .config import StorageSettings
4
+ from .factory import create_file_provider
5
+ from .providers.base import FileProvider
6
+
7
+
8
+ class StorageService:
9
+ def __init__(self, provider: FileProvider) -> None:
10
+ self.provider = provider
11
+
12
+ @classmethod
13
+ def from_settings(cls, settings: StorageSettings | None = None) -> "StorageService":
14
+ provider = create_file_provider(settings=settings)
15
+ return cls(provider)
16
+
17
+ def read_file(self, filepath: str) -> str:
18
+ return self.provider.read_file(filepath)
19
+
20
+ def write_file(self, filepath: str, file_content: str) -> None:
21
+ self.provider.write_file(filepath, file_content)
22
+
23
+ def read_file_bytes(self, filepath: str) -> bytes:
24
+ return self.provider.read_file_bytes(filepath)
25
+
26
+ def write_file_bytes(self, filepath: str, file_bytes: bytes) -> None:
27
+ self.provider.write_file_bytes(filepath, file_bytes)
28
+
29
+ def exists(self, filepath: str) -> bool:
30
+ return self.provider.exists(filepath)
@@ -0,0 +1,125 @@
1
+ Metadata-Version: 2.4
2
+ Name: filelayer
3
+ Version: 0.1.0
4
+ Summary: Simple file access abstraction over local filesystem and S3-compatible storage.
5
+ Author: Sireto
6
+ License: MIT
7
+ Project-URL: Homepage, https://sireto.io
8
+ Project-URL: Repository, https://sireto.io
9
+ Keywords: storage,s3,wasabi,filesystem,abstraction,boto3
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
+ Classifier: Topic :: System :: Filesystems
19
+ Requires-Python: >=3.11
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: boto3<2.0,>=1.34
23
+ Requires-Dist: pydantic<3.0,>=2.7
24
+ Requires-Dist: pydantic-settings<3.0,>=2.2
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest<9.0,>=8.0; extra == "dev"
27
+ Dynamic: license-file
28
+
29
+ # filelayer
30
+
31
+ `filelayer` is a small Python package that provides a simple file abstraction over:
32
+
33
+ - local filesystem
34
+ - S3-compatible object storage such as Wasabi
35
+
36
+ It exposes a minimal API:
37
+
38
+ - `read_file(filepath) -> str`
39
+ - `write_file(filepath, file_content) -> None`
40
+ - `read_file_bytes(filepath) -> bytes`
41
+ - `write_file_bytes(filepath, file_bytes) -> None`
42
+ - `exists(filepath) -> bool`
43
+
44
+ For S3-compatible backends, `filepath` is treated as the object key.
45
+ For local storage, `filepath` is resolved relative to the configured local base path.
46
+
47
+ ## Installation
48
+
49
+ ```bash
50
+ pip install filelayer
51
+ ```
52
+
53
+ For development:
54
+
55
+ ```bash
56
+ pip install -e .[dev]
57
+ ```
58
+
59
+ ## Local filesystem example
60
+
61
+ Environment:
62
+
63
+ ```env
64
+ STORAGE_PROVIDER=local
65
+ STORAGE_DEFAULT_PREFIX=my-app
66
+ STORAGE_ENCODING=utf-8
67
+ LOCAL_STORAGE_BASE_PATH=./data/storage
68
+ ```
69
+
70
+ Usage:
71
+
72
+ ```python
73
+ from filelayer import StorageService
74
+
75
+ storage = StorageService.from_settings()
76
+
77
+ storage.write_file("documents/example.txt", "Hello from local storage")
78
+ content = storage.read_file("documents/example.txt")
79
+ print(content)
80
+
81
+ storage.write_file_bytes("documents/example.bin", b"\x00\x01\x02")
82
+ print(storage.read_file_bytes("documents/example.bin"))
83
+ print(storage.exists("documents/example.txt"))
84
+ ```
85
+
86
+ ## Wasabi / S3-compatible example
87
+
88
+ Environment:
89
+
90
+ ```env
91
+ STORAGE_PROVIDER=s3
92
+ STORAGE_DEFAULT_PREFIX=my-app
93
+ STORAGE_ENCODING=utf-8
94
+
95
+ S3_ENDPOINT_URL=https://s3.eu-central-1.wasabisys.com
96
+ S3_ACCESS_KEY_ID=your-access-key
97
+ S3_SECRET_ACCESS_KEY=your-secret-key
98
+ S3_REGION_NAME=eu-central-1
99
+ S3_BUCKET=your-bucket
100
+ S3_USE_SSL=true
101
+ S3_VERIFY_SSL=true
102
+ S3_ADDRESSING_STYLE=virtual
103
+ S3_CONNECT_TIMEOUT=10
104
+ S3_READ_TIMEOUT=60
105
+ S3_MAX_ATTEMPTS=5
106
+ ```
107
+
108
+ Usage:
109
+
110
+ ```python
111
+ from filelayer import StorageService
112
+
113
+ storage = StorageService.from_settings()
114
+
115
+ storage.write_file("documents/example.txt", "Hello from Wasabi")
116
+ print(storage.read_file("documents/example.txt"))
117
+ print(storage.exists("documents/example.txt"))
118
+ ```
119
+
120
+ ## Notes
121
+
122
+ - `STORAGE_DEFAULT_PREFIX` is prepended to all paths or keys.
123
+ - `write_file()` stores text using `STORAGE_ENCODING`.
124
+ - `write_file_bytes()` stores raw bytes unchanged.
125
+ - Local provider prevents path traversal outside the configured storage root.
@@ -0,0 +1,21 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/filelayer/__init__.py
5
+ src/filelayer/config.py
6
+ src/filelayer/exceptions.py
7
+ src/filelayer/factory.py
8
+ src/filelayer/logging_utils.py
9
+ src/filelayer/service.py
10
+ src/filelayer.egg-info/PKG-INFO
11
+ src/filelayer.egg-info/SOURCES.txt
12
+ src/filelayer.egg-info/dependency_links.txt
13
+ src/filelayer.egg-info/requires.txt
14
+ src/filelayer.egg-info/top_level.txt
15
+ src/filelayer/providers/__init__.py
16
+ src/filelayer/providers/base.py
17
+ src/filelayer/providers/local.py
18
+ src/filelayer/providers/s3.py
19
+ tests/test_factory.py
20
+ tests/test_local_provider.py
21
+ tests/test_service.py
@@ -0,0 +1,6 @@
1
+ boto3<2.0,>=1.34
2
+ pydantic<3.0,>=2.7
3
+ pydantic-settings<3.0,>=2.2
4
+
5
+ [dev]
6
+ pytest<9.0,>=8.0
@@ -0,0 +1 @@
1
+ filelayer
@@ -0,0 +1,14 @@
1
+ from pathlib import Path
2
+
3
+ from filelayer import LocalFileProvider, StorageSettings, create_file_provider
4
+
5
+
6
+ def test_create_local_provider(tmp_path: Path) -> None:
7
+ settings = StorageSettings(
8
+ STORAGE_PROVIDER="local",
9
+ LOCAL_STORAGE_BASE_PATH=str(tmp_path),
10
+ )
11
+
12
+ provider = create_file_provider(settings)
13
+
14
+ assert isinstance(provider, LocalFileProvider)
@@ -0,0 +1,47 @@
1
+ from pathlib import Path
2
+
3
+ import pytest
4
+
5
+ from filelayer import LocalFileProvider, StorageObjectNotFoundError, StorageSettings
6
+
7
+
8
+ @pytest.fixture
9
+ def local_settings(tmp_path: Path) -> StorageSettings:
10
+ return StorageSettings(
11
+ STORAGE_PROVIDER="local",
12
+ LOCAL_STORAGE_BASE_PATH=str(tmp_path),
13
+ STORAGE_DEFAULT_PREFIX="test-prefix",
14
+ STORAGE_ENCODING="utf-8",
15
+ )
16
+
17
+
18
+ def test_write_and_read_text(local_settings: StorageSettings) -> None:
19
+ provider = LocalFileProvider(local_settings)
20
+
21
+ provider.write_file("docs/hello.txt", "hello world")
22
+
23
+ assert provider.exists("docs/hello.txt") is True
24
+ assert provider.read_file("docs/hello.txt") == "hello world"
25
+
26
+
27
+ def test_write_and_read_bytes(local_settings: StorageSettings) -> None:
28
+ provider = LocalFileProvider(local_settings)
29
+
30
+ provider.write_file_bytes("bin/data.bin", b"\x00\x01\x02")
31
+
32
+ assert provider.exists("bin/data.bin") is True
33
+ assert provider.read_file_bytes("bin/data.bin") == b"\x00\x01\x02"
34
+
35
+
36
+ def test_missing_file_raises(local_settings: StorageSettings) -> None:
37
+ provider = LocalFileProvider(local_settings)
38
+
39
+ with pytest.raises(StorageObjectNotFoundError):
40
+ provider.read_file("missing/file.txt")
41
+
42
+
43
+ def test_path_traversal_is_blocked(local_settings: StorageSettings) -> None:
44
+ provider = LocalFileProvider(local_settings)
45
+
46
+ with pytest.raises(Exception):
47
+ provider.write_file("../escape.txt", "nope")
@@ -0,0 +1,23 @@
1
+ from pathlib import Path
2
+
3
+ from filelayer import LocalFileProvider, StorageService, StorageSettings
4
+
5
+
6
+ def test_storage_service_text_and_bytes(tmp_path: Path) -> None:
7
+ settings = StorageSettings(
8
+ STORAGE_PROVIDER="local",
9
+ LOCAL_STORAGE_BASE_PATH=str(tmp_path),
10
+ STORAGE_DEFAULT_PREFIX="svc",
11
+ )
12
+ provider = LocalFileProvider(settings)
13
+ service = StorageService(provider)
14
+
15
+ service.write_file("notes/test.txt", "service text")
16
+ assert service.read_file("notes/test.txt") == "service text"
17
+
18
+ service.write_file_bytes("notes/test.bin", b"abc")
19
+ assert service.read_file_bytes("notes/test.bin") == b"abc"
20
+
21
+ assert service.exists("notes/test.txt") is True
22
+ assert service.exists("notes/test.bin") is True
23
+ assert service.exists("notes/missing.txt") is False