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.
- filelayer-0.1.0/LICENSE +21 -0
- filelayer-0.1.0/PKG-INFO +125 -0
- filelayer-0.1.0/README.md +97 -0
- filelayer-0.1.0/pyproject.toml +50 -0
- filelayer-0.1.0/setup.cfg +4 -0
- filelayer-0.1.0/src/filelayer/__init__.py +28 -0
- filelayer-0.1.0/src/filelayer/config.py +132 -0
- filelayer-0.1.0/src/filelayer/exceptions.py +21 -0
- filelayer-0.1.0/src/filelayer/factory.py +24 -0
- filelayer-0.1.0/src/filelayer/logging_utils.py +30 -0
- filelayer-0.1.0/src/filelayer/providers/__init__.py +9 -0
- filelayer-0.1.0/src/filelayer/providers/base.py +25 -0
- filelayer-0.1.0/src/filelayer/providers/local.py +202 -0
- filelayer-0.1.0/src/filelayer/providers/s3.py +284 -0
- filelayer-0.1.0/src/filelayer/service.py +30 -0
- filelayer-0.1.0/src/filelayer.egg-info/PKG-INFO +125 -0
- filelayer-0.1.0/src/filelayer.egg-info/SOURCES.txt +21 -0
- filelayer-0.1.0/src/filelayer.egg-info/dependency_links.txt +1 -0
- filelayer-0.1.0/src/filelayer.egg-info/requires.txt +6 -0
- filelayer-0.1.0/src/filelayer.egg-info/top_level.txt +1 -0
- filelayer-0.1.0/tests/test_factory.py +14 -0
- filelayer-0.1.0/tests/test_local_provider.py +47 -0
- filelayer-0.1.0/tests/test_service.py +23 -0
filelayer-0.1.0/LICENSE
ADDED
|
@@ -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.
|
filelayer-0.1.0/PKG-INFO
ADDED
|
@@ -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,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,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 @@
|
|
|
1
|
+
|
|
@@ -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
|