fallbacks3 0.1.0__py3-none-any.whl

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.
fallbacks3/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """fallbacks3 - A Python library for S3-compatible storage with fallback support."""
2
+
3
+ from fallbacks3.storage import Storage
4
+
5
+ __version__ = "0.1.0"
6
+
7
+ __all__ = ["__version__", "Storage"]
fallbacks3/config.py ADDED
@@ -0,0 +1,11 @@
1
+ from pydantic_settings import BaseSettings, SettingsConfigDict
2
+
3
+
4
+ class Config(BaseSettings):
5
+ providers: str
6
+ fallback_provider: str
7
+
8
+ model_config = SettingsConfigDict(case_sensitive=False)
9
+
10
+
11
+ config = Config()
@@ -0,0 +1,3 @@
1
+ from fallbacks3.provider.provider import S3Provider
2
+
3
+ __all__ = ["S3Provider"]
@@ -0,0 +1,12 @@
1
+ from pydantic import BaseModel, ConfigDict
2
+
3
+
4
+ class ProviderCredentials(BaseModel):
5
+ """Parsed provider URI in the format: <provider>://<access_key>:<secret_key>@<endpoint>."""
6
+
7
+ provider_scheme: str
8
+ access_key: str
9
+ secret_key: str
10
+ endpoint: str
11
+
12
+ model_config = ConfigDict(frozen=True)
@@ -0,0 +1,81 @@
1
+ from boto3 import client
2
+ from botocore.client import Config as BotoConfig
3
+ from mypy_boto3_s3.client import S3Client
4
+
5
+
6
+ class S3Provider:
7
+ """S3-compatible storage provider."""
8
+
9
+ def __init__(
10
+ self,
11
+ provider_scheme: str,
12
+ endpoint: str,
13
+ access_key_id: str,
14
+ secret_access_key: str,
15
+ ):
16
+ """Initialize S3 provider with credentials.
17
+
18
+ Args:
19
+ provider_scheme: URI scheme for the provider (e.g., 'ps3', 'r2')
20
+ endpoint: S3 endpoint URL with scheme (e.g., 'https://ps3.palver.com')
21
+ access_key_id: Access key ID
22
+ secret_access_key: Secret access key
23
+ """
24
+ self.provider_scheme: str = provider_scheme
25
+ self.endpoint: str = endpoint
26
+ self.client: S3Client = client(
27
+ "s3",
28
+ config=BotoConfig(signature_version="s3v4"),
29
+ endpoint_url=endpoint,
30
+ aws_access_key_id=access_key_id,
31
+ aws_secret_access_key=secret_access_key,
32
+ )
33
+
34
+ def upload_file(self, file_name: str, file_path: str, bucket: str) -> str:
35
+ """Upload a file to S3 bucket.
36
+
37
+ Args:
38
+ file_name: Name/key for the file in the bucket
39
+ file_path: Local path to the file to upload
40
+ bucket: Bucket name
41
+
42
+ Returns:
43
+ Full URI of the uploaded file in format:
44
+ <provider_scheme>://<bucket>/<file_name>
45
+ """
46
+ self.client.upload_file(file_path, bucket, file_name)
47
+ return f"{self.provider_scheme}://{bucket}/{file_name}"
48
+
49
+ def download_file(self, file_name: str, local_path: str, bucket: str) -> str:
50
+ """Download a file from S3 bucket.
51
+
52
+ Args:
53
+ file_name: Name/key of the file in the bucket
54
+ local_path: Local path where the file will be saved
55
+ bucket: Bucket name
56
+
57
+ Returns:
58
+ Local path where the file was saved
59
+ """
60
+ self.client.download_file(bucket, file_name, local_path)
61
+ return local_path
62
+
63
+ def generate_signed_url(
64
+ self, file_name: str, bucket: str, expiration: int = 60
65
+ ) -> str:
66
+ """Generate a presigned URL for temporary access to a file.
67
+
68
+ Args:
69
+ file_name: Name/key of the file in the bucket
70
+ bucket: Bucket name
71
+ expiration: Time in seconds for the URL to remain valid (default: 3600)
72
+
73
+ Returns:
74
+ Presigned URL string
75
+ """
76
+ url: str = self.client.generate_presigned_url(
77
+ "get_object",
78
+ Params={"Bucket": bucket, "Key": file_name},
79
+ ExpiresIn=expiration,
80
+ )
81
+ return url
fallbacks3/storage.py ADDED
@@ -0,0 +1,178 @@
1
+ from fallbacks3.config import config
2
+ from fallbacks3.provider import S3Provider
3
+ from fallbacks3.utils.uri import build_provider_credentials, parse_uri
4
+ from typing import Dict, Tuple
5
+ from urllib.parse import ParseResult
6
+
7
+
8
+ class Storage:
9
+ """S3-compatible storage client with multi-provider support and automatic fallback."""
10
+
11
+ def __init__(
12
+ self,
13
+ providers: str = config.providers,
14
+ fallback_provider: str = config.fallback_provider,
15
+ ):
16
+ """Initialize storage with multiple providers.
17
+
18
+ Args:
19
+ providers: Comma-separated provider URIs in format:
20
+ <provider>://<access_key>:<secret_key>@<endpoint>
21
+ Example: "ps3://key:secret@ps3.palver.com,s4://a:b@s4.mega.com"
22
+ Defaults to PROVIDERS environment variable.
23
+ fallback_provider: Provider scheme to use for fallback (e.g., "r2").
24
+ Must be one of the providers in the providers string.
25
+ Defaults to FALLBACK_PROVIDER environment variable.
26
+
27
+ Raises:
28
+ ValueError: If fallback_provider not found in providers
29
+ """
30
+ self.providers: Dict[str, S3Provider] = {}
31
+
32
+ # Parse and initialize all providers
33
+ for provider in providers.split(","):
34
+ stripped_uri: str = provider.strip()
35
+ if stripped_uri:
36
+ creds = build_provider_credentials(uri=stripped_uri)
37
+ self.providers[creds.provider_scheme] = S3Provider(
38
+ provider_scheme=creds.provider_scheme,
39
+ endpoint=creds.endpoint,
40
+ access_key_id=creds.access_key,
41
+ secret_access_key=creds.secret_key,
42
+ )
43
+
44
+ self.fallback_provider: str = fallback_provider
45
+
46
+ if fallback_provider not in self.providers:
47
+ raise ValueError(
48
+ f"Fallback provider '{fallback_provider}' not found in providers"
49
+ )
50
+
51
+ def upload_file(self, remote_file_uri: str, local_file_path: str) -> str:
52
+ """Upload a file with automatic fallback on failure.
53
+
54
+ Args:
55
+ remote_file_uri: Remote file URI in format <provider>://<bucket>/<file_path>
56
+ Example: "ps3://palver-whatsapp/file.mp4"
57
+ local_file_path: Local path to the file to upload
58
+
59
+ Returns:
60
+ URI of uploaded file in format: <provider>://<bucket>/<file_path>
61
+
62
+ Raises:
63
+ ValueError: If provider is not configured
64
+ Exception: If upload fails on both primary and fallback providers
65
+ """
66
+ parsed_uri, provider = self._resolve_uri(uri=remote_file_uri)
67
+
68
+ try:
69
+ return provider.upload_file(
70
+ file_name=parsed_uri.path.lstrip("/"),
71
+ file_path=local_file_path,
72
+ bucket=parsed_uri.netloc,
73
+ )
74
+ except Exception as e:
75
+ # Try fallback if different from primary
76
+ if self.fallback_provider != parsed_uri.scheme:
77
+ try:
78
+ fallback: S3Provider = self.providers[self.fallback_provider]
79
+ # Use same bucket and key with fallback provider
80
+ return fallback.upload_file(
81
+ file_name=parsed_uri.path.lstrip("/"),
82
+ file_path=local_file_path,
83
+ bucket=parsed_uri.netloc,
84
+ )
85
+ except Exception as fallback_error:
86
+ raise Exception(
87
+ f"Both primary and fallback providers failed. "
88
+ f"Primary error: {e}. Fallback error: {fallback_error}"
89
+ )
90
+ raise
91
+
92
+ def download_file(self, remote_file_uri: str, local_file_path: str) -> str:
93
+ """Download a file from storage.
94
+
95
+ Args:
96
+ remote_file_uri: Remote file URI in format <provider>://<bucket>/<file_path>
97
+ Example: "ps3://palver-whatsapp/file.mp4"
98
+ local_file_path: Local path where file will be saved
99
+
100
+ Returns:
101
+ Local path where file was saved
102
+
103
+ Raises:
104
+ ValueError: If provider is not configured
105
+ """
106
+ parsed_uri, provider = self._resolve_uri(uri=remote_file_uri)
107
+
108
+ return provider.download_file(
109
+ file_name=parsed_uri.path.lstrip("/"),
110
+ local_path=local_file_path,
111
+ bucket=parsed_uri.netloc,
112
+ )
113
+
114
+ def generate_signed_url(self, remote_file_uri: str, expiration: int = 60) -> str:
115
+ """Generate a presigned URL for temporary access.
116
+
117
+ Args:
118
+ remote_file_uri: Remote file URI in format <provider>://<bucket>/<file_path>
119
+ Example: "ps3://palver-whatsapp/file.mp4"
120
+ expiration: Time in seconds for URL to remain valid (default: 60)
121
+
122
+ Returns:
123
+ Presigned URL string
124
+
125
+ Raises:
126
+ ValueError: If provider is not configured
127
+ """
128
+ parsed_uri, provider = self._resolve_uri(uri=remote_file_uri)
129
+
130
+ return provider.generate_signed_url(
131
+ file_name=parsed_uri.path.lstrip("/"),
132
+ bucket=parsed_uri.netloc,
133
+ expiration=expiration,
134
+ )
135
+
136
+ def _resolve_uri(self, uri: str) -> Tuple[ParseResult, S3Provider]:
137
+ """Parse and validate URI, then return parsed URI and provider.
138
+
139
+ Args:
140
+ uri: Remote file URI in format <provider>://<bucket>/<file_path>
141
+
142
+ Returns:
143
+ Tuple of (parsed_uri, provider)
144
+
145
+ Raises:
146
+ ValueError: If URI format is invalid or provider not configured
147
+ """
148
+ parsed_uri: ParseResult = parse_uri(uri=uri)
149
+ self._validate_parsed_uri(parsed_uri=parsed_uri, remote_file_uri=uri)
150
+ provider: S3Provider = self.providers[parsed_uri.scheme]
151
+ return parsed_uri, provider
152
+
153
+ def _validate_parsed_uri(
154
+ self, parsed_uri: ParseResult, remote_file_uri: str
155
+ ) -> None:
156
+ """Validate parsed URI format and provider configuration.
157
+
158
+ Args:
159
+ parsed_uri: ParseResult from urlparse
160
+ remote_file_uri: Original URI string for error messages
161
+
162
+ Raises:
163
+ ValueError: If URI format is invalid or provider not configured
164
+ """
165
+ if (
166
+ not parsed_uri.scheme
167
+ or not parsed_uri.netloc
168
+ or not parsed_uri.path.lstrip("/")
169
+ ):
170
+ raise ValueError(
171
+ f"Invalid media path format: {remote_file_uri}. "
172
+ f"Expected format: <provider>://<bucket>/<file_path>"
173
+ )
174
+
175
+ if parsed_uri.scheme not in self.providers:
176
+ raise ValueError(f"Provider '{parsed_uri.scheme}' not configured")
177
+
178
+ return
File without changes
@@ -0,0 +1,51 @@
1
+ from fallbacks3.provider.config_credentials import ProviderCredentials
2
+ from urllib.parse import urlparse, ParseResult
3
+
4
+
5
+ def parse_uri(uri: str) -> ParseResult:
6
+ """
7
+ Parse provider URI format: <provider_name>://<access_key>:<secret_key>@<endpoint>
8
+
9
+ Example: ps3://key:secret@ps3.palver.com
10
+
11
+ Args:
12
+ uri: Provider URI string
13
+
14
+ Returns:
15
+ ParseResult with the following fields:
16
+ - scheme: Provider name (e.g., "ps3", "s4", "r2")
17
+ - username: Access key
18
+ - password: Secret key
19
+ - hostname: Endpoint hostname
20
+ - port: Endpoint port (None if not specified)
21
+ """
22
+ return urlparse(uri)
23
+
24
+
25
+ def build_provider_credentials(uri: str) -> ProviderCredentials:
26
+ """
27
+ Build provider credentials from URI.
28
+
29
+ Args:
30
+ uri: Provider URI string in format <provider_name>://<access_key>:<secret_key>@<endpoint>
31
+
32
+ Returns:
33
+ ProviderCredentials with parsed fields
34
+ """
35
+ uri_parsed: ParseResult = parse_uri(uri=uri)
36
+
37
+ scheme: str = (
38
+ "http" if uri_parsed.hostname in ("localhost", "127.0.0.1") else "https"
39
+ )
40
+ endpoint = (
41
+ f"{scheme}://{uri_parsed.hostname}"
42
+ if not uri_parsed.port
43
+ else f"{scheme}://{uri_parsed.hostname}:{uri_parsed.port}"
44
+ )
45
+
46
+ return ProviderCredentials(
47
+ provider_scheme=uri_parsed.scheme,
48
+ access_key=uri_parsed.username,
49
+ secret_key=uri_parsed.password,
50
+ endpoint=endpoint,
51
+ )
@@ -0,0 +1,172 @@
1
+ Metadata-Version: 2.4
2
+ Name: fallbacks3
3
+ Version: 0.1.0
4
+ Summary: S3-compatible storage library with automatic fallback support
5
+ Author-email: Mila de Oliveira <mila.oliveira@palver.com.br>
6
+ Project-URL: Homepage, https://github.com/palverdata/fallbacks3
7
+ Project-URL: Repository, https://github.com/palverdata/fallbacks3
8
+ Project-URL: Issues, https://github.com/palverdata/fallbacks3/issues
9
+ Requires-Python: >=3.8
10
+ Description-Content-Type: text/markdown
11
+ Requires-Dist: boto3>=1.26.0
12
+ Requires-Dist: pydantic-settings>=2.0.0
13
+ Provides-Extra: dev
14
+ Requires-Dist: pytest>=7.0; extra == "dev"
15
+ Requires-Dist: pytest-cov>=4.0; extra == "dev"
16
+ Requires-Dist: black>=23.0; extra == "dev"
17
+ Requires-Dist: ruff>=0.1.0; extra == "dev"
18
+ Requires-Dist: build>=1.0.0; extra == "dev"
19
+ Requires-Dist: twine>=4.0.0; extra == "dev"
20
+ Requires-Dist: boto3-stubs[s3]>=1.34.0; extra == "dev"
21
+
22
+ # fallbacks3
23
+
24
+ S3-compatible storage library with automatic fallback for the upload process.
25
+
26
+
27
+ ## Installation
28
+
29
+ ```bash
30
+ pip install fallbacks3
31
+ ```
32
+
33
+ For development:
34
+
35
+ ```bash
36
+ pip install -e .
37
+ ```
38
+
39
+
40
+ ## Configuration
41
+
42
+ You must configure a comma-separated list of providers, as well as the scheme of the fallback provider, with environment variables named `PROVIDERS` and `FALLBACK_PROVIDER`.
43
+
44
+ ```bash
45
+ # Comma-separated list of provider URIs
46
+ PROVIDERS="ps3://access_key:secret_key@ps3.palver.com,r2://access_key:secret_key@account.r2.cloudflarestorage.com"
47
+
48
+ # Fallback provider scheme (must be one of the providers in PROVIDERS)
49
+ FALLBACK_PROVIDER="r2"
50
+ ```
51
+
52
+
53
+ ### Provider URI Format
54
+
55
+ Provider URIs follow the format:
56
+ ```
57
+ <provider_scheme>://<access_key>:<secret_key>@<endpoint>
58
+ ```
59
+
60
+ Examples: `r2://key:secret@account.r2.cloudflarestorage.com`, `local://test:testkey@localhost:9000`
61
+
62
+
63
+ ### Remote File URI Format
64
+
65
+ File URIs should specify the exact location from a provider bucket:
66
+
67
+ ```
68
+ <provider>://<bucket>/<file_path>
69
+ ```
70
+
71
+ Examples: `ps3://palver-whatsapp/audio.mp3`, `s3://my-bucket/documents/report.pdf`
72
+
73
+ ## Usage
74
+
75
+ ```python
76
+ from fallbacks3 import Storage
77
+
78
+ # Initialize storage using environment variables (PROVIDERS and FALLBACK_PROVIDER)
79
+ storage = Storage()
80
+
81
+ # Upload a local file to the provided remote path
82
+ stored_file_uri = storage.upload_file(
83
+ remote_file_uri="ps3://palver-whatsapp/audio.mp3",
84
+ local_file_path="/local/path/audio.mp3"
85
+ )
86
+ print(uri) # "ps3://palver-whatsapp/audio.mp3"
87
+
88
+ # Download a file
89
+ downloaded_file_path = storage.download_file(
90
+ remote_file_uri="ps3://palver-whatsapp/audio.mp3",
91
+ local_file_path="/local/path/audio.mp3"
92
+ )
93
+ print(local_path) # "/local/path/audio.mp3"
94
+
95
+ # Generate a signed URL for temporary access (default: 60 seconds)
96
+ signed_url = storage.generate_signed_url(
97
+ remote_file_uri="ps3://palver-whatsapp/audio.mp3",
98
+ expiration=3600
99
+ )
100
+ print(signed_url) # "https://ps3.palver.com/palver-whatsapp/audio.mp3?signature=..."
101
+ ```
102
+
103
+ ### Upload with Automatic Fallback
104
+
105
+ If the primary provider fails for the upload method, the library automatically retries with the fallback provider (using the same bucket and file path), as to ensure files are not lost.
106
+
107
+ ```python
108
+ # If ps3 fails, automatically falls back to r2
109
+ stored_file_path = storage.upload_file(
110
+ remote_file_uri="ps3://palver-whatsapp/audio.mp3",
111
+ local_file_path="/local/path/audio.mp3"
112
+ )
113
+ # Returns "r2://palver-whatsapp/audio.mp3" if ps3 failed
114
+ ```
115
+
116
+ Raises an exception if both primary and fallback providers fail.
117
+
118
+ ### Download and Signed URLs
119
+
120
+ Download and signed URL generation use the provider specified in the remote file URI.
121
+
122
+
123
+ ## Development
124
+
125
+ Install development dependencies:
126
+
127
+ ```bash
128
+ pip install -e ".[dev]"
129
+ ```
130
+
131
+ You can also develop with `uv` if available.
132
+
133
+ ```bash
134
+ uv pip install -e ".[dev]"
135
+ ```
136
+
137
+ Run tests:
138
+
139
+ ```bash
140
+ pytest --cov=fallbacks3
141
+
142
+ # alternatively, with uv:
143
+ uv run pytest --cov=fallbacks3
144
+ ```
145
+
146
+ ## Publishing to PyPI
147
+
148
+ The project is set up to be automatically uploaded to PyPI when a new tag is pushed. For a successful push, all tests must pass and the project must pass the `check` and `format` requirements.
149
+
150
+ ### One-time setup
151
+
152
+ Before publishing for the first time:
153
+
154
+ 1. **Configure Trusted Publishing on PyPI**:
155
+ - Go to https://pypi.org/manage/account/publishing/
156
+ - Add a new pending publisher:
157
+ - **PyPI Project Name**: `fallbacks3`
158
+ - **Owner**: `palverdata`
159
+ - **Repository name**: `fallbacks3`
160
+ - **Workflow name**: `publish.yaml`
161
+ - **Environment name**: `pypi`
162
+
163
+ You must have access to the `pypi` GitHub environment or be approved by required reviewers (`milasd` or `giancarlopro`) to publish.
164
+
165
+ ### Publishing a new version
166
+
167
+ 1. Update version in `pyproject.toml`
168
+ 2. Create and push a tag:
169
+ ```bash
170
+ git tag v[new version] # eg.: git tag v0.1.0
171
+ git push origin v[new version] # eg.: git push origin v0.1.0
172
+ ```
@@ -0,0 +1,12 @@
1
+ fallbacks3/__init__.py,sha256=e8KpdMS8mWubH6QFn2_TGZgrGZtpZjil43vxzO-j_YM,186
2
+ fallbacks3/config.py,sha256=BIlekhzOkEVdqSmETpS1Y4Y8FpcJdwrdjnISnXVCevo,220
3
+ fallbacks3/storage.py,sha256=RicumVKzyJgsvpDL1u4yp2NiI1a47DLHNSVKkWwT_q0,6709
4
+ fallbacks3/provider/__init__.py,sha256=lFphOB3APdNzwQoJP_GgakL0ELXduBViFqoBBb3AR_0,78
5
+ fallbacks3/provider/config_credentials.py,sha256=cLvA4GVC9JYoJMBi17lbeibBGhf6exD7nCg-j0aEMXg,307
6
+ fallbacks3/provider/provider.py,sha256=V49uJht5m2o3qjOKVh4tjgUfpVDDEKiTkJcsbUhxNqY,2644
7
+ fallbacks3/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ fallbacks3/utils/uri.py,sha256=8ur7zPs-ZdZBb-rZA9oG0_fo7CzMoOp9HnlAVMcV2sI,1475
9
+ fallbacks3-0.1.0.dist-info/METADATA,sha256=Z70wTuSDu8GSXUOLg5TE8XOsP_E-dYv8IW52GoAPo9A,4751
10
+ fallbacks3-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
11
+ fallbacks3-0.1.0.dist-info/top_level.txt,sha256=JsC-ee_dPAtcp_-4LvPVZk6KUAtrj8HQNWQzbxF9myU,11
12
+ fallbacks3-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ fallbacks3