svc-infra 0.1.640__py3-none-any.whl → 0.1.664__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.

Potentially problematic release.


This version of svc-infra might be problematic. Click here for more details.

Files changed (33) hide show
  1. svc_infra/api/fastapi/apf_payments/setup.py +0 -2
  2. svc_infra/api/fastapi/auth/add.py +0 -4
  3. svc_infra/api/fastapi/auth/routers/oauth_router.py +19 -4
  4. svc_infra/api/fastapi/cache/add.py +9 -5
  5. svc_infra/api/fastapi/db/nosql/mongo/add.py +33 -27
  6. svc_infra/api/fastapi/db/sql/add.py +8 -5
  7. svc_infra/api/fastapi/db/sql/crud_router.py +4 -4
  8. svc_infra/api/fastapi/docs/scoped.py +41 -6
  9. svc_infra/api/fastapi/setup.py +10 -12
  10. svc_infra/api/fastapi/versioned.py +101 -0
  11. svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
  12. svc_infra/db/sql/templates/setup/env_async.py.tmpl +25 -11
  13. svc_infra/db/sql/templates/setup/env_sync.py.tmpl +20 -5
  14. svc_infra/docs/acceptance-matrix.md +17 -0
  15. svc_infra/docs/adr/0012-generic-file-storage.md +498 -0
  16. svc_infra/docs/api.md +127 -0
  17. svc_infra/docs/storage.md +982 -0
  18. svc_infra/docs/versioned-integrations.md +146 -0
  19. svc_infra/security/models.py +27 -7
  20. svc_infra/security/oauth_models.py +59 -0
  21. svc_infra/storage/__init__.py +93 -0
  22. svc_infra/storage/add.py +250 -0
  23. svc_infra/storage/backends/__init__.py +11 -0
  24. svc_infra/storage/backends/local.py +331 -0
  25. svc_infra/storage/backends/memory.py +214 -0
  26. svc_infra/storage/backends/s3.py +329 -0
  27. svc_infra/storage/base.py +239 -0
  28. svc_infra/storage/easy.py +182 -0
  29. svc_infra/storage/settings.py +192 -0
  30. {svc_infra-0.1.640.dist-info → svc_infra-0.1.664.dist-info}/METADATA +8 -3
  31. {svc_infra-0.1.640.dist-info → svc_infra-0.1.664.dist-info}/RECORD +33 -19
  32. {svc_infra-0.1.640.dist-info → svc_infra-0.1.664.dist-info}/WHEEL +0 -0
  33. {svc_infra-0.1.640.dist-info → svc_infra-0.1.664.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,331 @@
1
+ """
2
+ Local filesystem storage backend.
3
+
4
+ Ideal for Railway persistent volumes, Render disks, and local development.
5
+ """
6
+
7
+ import hashlib
8
+ import hmac
9
+ import json
10
+ import secrets
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+ from typing import Optional
14
+ from urllib.parse import urlencode
15
+
16
+ import aiofiles
17
+ import aiofiles.os
18
+
19
+ from ..base import FileNotFoundError as StorageFileNotFoundError
20
+ from ..base import InvalidKeyError, PermissionDeniedError, StorageError
21
+
22
+
23
+ class LocalBackend:
24
+ """
25
+ Local filesystem storage backend.
26
+
27
+ Stores files on the local filesystem with metadata stored as JSON sidecar files.
28
+ Supports HMAC-based signed URLs with expiration.
29
+
30
+ Args:
31
+ base_path: Base directory for file storage
32
+ base_url: Base URL for file serving (e.g., "http://localhost:8000/files")
33
+ signing_secret: Secret key for URL signing (auto-generated if not provided)
34
+
35
+ Example:
36
+ >>> # Railway persistent volume
37
+ >>> backend = LocalBackend(
38
+ ... base_path="/data/uploads",
39
+ ... base_url="https://api.example.com/files"
40
+ ... )
41
+ >>>
42
+ >>> # Local development
43
+ >>> backend = LocalBackend(
44
+ ... base_path="./uploads",
45
+ ... base_url="http://localhost:8000/files"
46
+ ... )
47
+ """
48
+
49
+ def __init__(
50
+ self,
51
+ base_path: str = "/data/uploads",
52
+ base_url: str = "http://localhost:8000/files",
53
+ signing_secret: Optional[str] = None,
54
+ ):
55
+ self.base_path = Path(base_path)
56
+ self.base_url = base_url.rstrip("/")
57
+ self.signing_secret = signing_secret or secrets.token_urlsafe(32)
58
+
59
+ def _validate_key(self, key: str) -> None:
60
+ """Validate storage key format."""
61
+ if not key:
62
+ raise InvalidKeyError("Key cannot be empty")
63
+
64
+ if key.startswith("/"):
65
+ raise InvalidKeyError("Key cannot start with /")
66
+
67
+ if ".." in key:
68
+ raise InvalidKeyError("Key cannot contain .. (path traversal)")
69
+
70
+ if len(key) > 1024:
71
+ raise InvalidKeyError("Key cannot exceed 1024 characters")
72
+
73
+ # Check for safe characters
74
+ safe_chars = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-/")
75
+ if not all(c in safe_chars for c in key):
76
+ raise InvalidKeyError(
77
+ "Key can only contain alphanumeric, dot, dash, underscore, and slash"
78
+ )
79
+
80
+ def _get_file_path(self, key: str) -> Path:
81
+ """Get absolute file path for a key."""
82
+ return self.base_path / key
83
+
84
+ def _get_metadata_path(self, key: str) -> Path:
85
+ """Get metadata file path for a key."""
86
+ return self.base_path / f"{key}.meta.json"
87
+
88
+ def _sign_url(self, key: str, expires_at: int, download: bool) -> str:
89
+ """Generate HMAC signature for URL."""
90
+ message = f"{key}:{expires_at}:{download}"
91
+ signature = hmac.new(
92
+ self.signing_secret.encode(),
93
+ message.encode(),
94
+ hashlib.sha256,
95
+ ).hexdigest()
96
+ return signature
97
+
98
+ def _verify_signature(self, key: str, expires_at: int, download: bool, signature: str) -> bool:
99
+ """Verify HMAC signature."""
100
+ expected = self._sign_url(key, expires_at, download)
101
+ return hmac.compare_digest(expected, signature)
102
+
103
+ async def put(
104
+ self,
105
+ key: str,
106
+ data: bytes,
107
+ content_type: str,
108
+ metadata: Optional[dict] = None,
109
+ ) -> str:
110
+ """Store file on local filesystem."""
111
+ self._validate_key(key)
112
+
113
+ file_path = self._get_file_path(key)
114
+ meta_path = self._get_metadata_path(key)
115
+
116
+ try:
117
+ # Create parent directories
118
+ await aiofiles.os.makedirs(file_path.parent, exist_ok=True)
119
+
120
+ # Write file atomically using temp file
121
+ temp_path = file_path.with_suffix(f"{file_path.suffix}.tmp")
122
+ async with aiofiles.open(temp_path, "wb") as f:
123
+ await f.write(data)
124
+
125
+ # Rename to final path (atomic on POSIX)
126
+ await aiofiles.os.rename(temp_path, file_path)
127
+
128
+ # Write metadata
129
+ meta_data = {
130
+ "size": len(data),
131
+ "content_type": content_type,
132
+ "created_at": datetime.now(timezone.utc).isoformat(),
133
+ **(metadata or {}),
134
+ }
135
+
136
+ async with aiofiles.open(meta_path, "w") as f:
137
+ await f.write(json.dumps(meta_data, indent=2))
138
+
139
+ except PermissionError as e:
140
+ raise PermissionDeniedError(f"Permission denied writing to {key}: {e}")
141
+ except OSError as e:
142
+ raise StorageError(f"Failed to write file {key}: {e}")
143
+
144
+ # Return signed URL (1 hour expiration)
145
+ return await self.get_url(key, expires_in=3600)
146
+
147
+ async def get(self, key: str) -> bytes:
148
+ """Retrieve file from local filesystem."""
149
+ self._validate_key(key)
150
+
151
+ file_path = self._get_file_path(key)
152
+
153
+ try:
154
+ async with aiofiles.open(file_path, "rb") as f:
155
+ return await f.read()
156
+ except FileNotFoundError:
157
+ raise StorageFileNotFoundError(f"File not found: {key}")
158
+ except PermissionError as e:
159
+ raise PermissionDeniedError(f"Permission denied reading {key}: {e}")
160
+ except OSError as e:
161
+ raise StorageError(f"Failed to read file {key}: {e}")
162
+
163
+ async def delete(self, key: str) -> bool:
164
+ """Delete file from local filesystem."""
165
+ self._validate_key(key)
166
+
167
+ file_path = self._get_file_path(key)
168
+ meta_path = self._get_metadata_path(key)
169
+
170
+ if not file_path.exists():
171
+ return False
172
+
173
+ try:
174
+ # Delete file
175
+ await aiofiles.os.remove(file_path)
176
+
177
+ # Delete metadata if exists
178
+ if meta_path.exists():
179
+ await aiofiles.os.remove(meta_path)
180
+
181
+ return True
182
+
183
+ except PermissionError as e:
184
+ raise PermissionDeniedError(f"Permission denied deleting {key}: {e}")
185
+ except OSError as e:
186
+ raise StorageError(f"Failed to delete file {key}: {e}")
187
+
188
+ async def exists(self, key: str) -> bool:
189
+ """Check if file exists on local filesystem."""
190
+ self._validate_key(key)
191
+
192
+ file_path = self._get_file_path(key)
193
+ return file_path.exists()
194
+
195
+ async def get_url(
196
+ self,
197
+ key: str,
198
+ expires_in: int = 3600,
199
+ download: bool = False,
200
+ ) -> str:
201
+ """
202
+ Generate signed URL for file access.
203
+
204
+ Args:
205
+ key: Storage key
206
+ expires_in: URL expiration in seconds (default: 1 hour)
207
+ download: If True, force download instead of inline display
208
+
209
+ Returns:
210
+ Signed URL with expiration and signature
211
+
212
+ Example:
213
+ >>> url = await backend.get_url("avatars/user_123/profile.jpg")
214
+ >>> # https://api.example.com/files/avatars/user_123/profile.jpg?expires=...&signature=...
215
+ """
216
+ self._validate_key(key)
217
+
218
+ # Check if file exists
219
+ if not await self.exists(key):
220
+ raise StorageFileNotFoundError(f"File not found: {key}")
221
+
222
+ # Calculate expiration timestamp
223
+ expires_at = int(datetime.now(timezone.utc).timestamp()) + expires_in
224
+
225
+ # Generate signature
226
+ signature = self._sign_url(key, expires_at, download)
227
+
228
+ # Build URL with query parameters
229
+ params = {
230
+ "expires": str(expires_at),
231
+ "signature": signature,
232
+ }
233
+ if download:
234
+ params["download"] = "true"
235
+
236
+ url = f"{self.base_url}/{key}?{urlencode(params)}"
237
+ return url
238
+
239
+ def verify_url(self, key: str, expires: str, signature: str, download: bool = False) -> bool:
240
+ """
241
+ Verify a signed URL (for use in file serving endpoint).
242
+
243
+ Args:
244
+ key: Storage key
245
+ expires: Expiration timestamp as string
246
+ signature: HMAC signature
247
+ download: Download flag
248
+
249
+ Returns:
250
+ True if signature is valid and not expired
251
+
252
+ Example:
253
+ >>> # In file serving route
254
+ >>> if not backend.verify_url(key, expires, signature):
255
+ ... raise HTTPException(403, "Invalid signature")
256
+ """
257
+ try:
258
+ expires_at = int(expires)
259
+ except (ValueError, TypeError):
260
+ return False
261
+
262
+ # Check expiration
263
+ now = int(datetime.now(timezone.utc).timestamp())
264
+ if now > expires_at:
265
+ return False
266
+
267
+ # Verify signature
268
+ return self._verify_signature(key, expires_at, download, signature)
269
+
270
+ async def list_keys(
271
+ self,
272
+ prefix: str = "",
273
+ limit: int = 100,
274
+ ) -> list[str]:
275
+ """List stored keys with optional prefix filter."""
276
+ import os
277
+
278
+ prefix_path = self.base_path / prefix if prefix else self.base_path
279
+
280
+ if not prefix_path.exists():
281
+ return []
282
+
283
+ keys: list[str] = []
284
+
285
+ # Walk directory tree (using os.walk for Python 3.11 compatibility)
286
+ for root, _, files in os.walk(prefix_path):
287
+ for file in files:
288
+ # Skip metadata files
289
+ if file.endswith(".meta.json"):
290
+ continue
291
+
292
+ # Get relative path
293
+ file_path = Path(root) / file
294
+ relative = file_path.relative_to(self.base_path)
295
+ key = str(relative)
296
+
297
+ keys.append(key)
298
+
299
+ if len(keys) >= limit:
300
+ return keys
301
+
302
+ return keys
303
+
304
+ async def get_metadata(self, key: str) -> dict:
305
+ """Get file metadata."""
306
+ self._validate_key(key)
307
+
308
+ meta_path = self._get_metadata_path(key)
309
+
310
+ if not meta_path.exists():
311
+ # File exists but no metadata, create basic metadata
312
+ file_path = self._get_file_path(key)
313
+ if not file_path.exists():
314
+ raise StorageFileNotFoundError(f"File not found: {key}")
315
+
316
+ stat = await aiofiles.os.stat(file_path)
317
+ return {
318
+ "size": stat.st_size,
319
+ "content_type": "application/octet-stream",
320
+ "created_at": datetime.fromtimestamp(stat.st_ctime, tz=timezone.utc).isoformat(),
321
+ }
322
+
323
+ try:
324
+ async with aiofiles.open(meta_path, "r") as f:
325
+ content = await f.read()
326
+ return json.loads(content)
327
+ except (OSError, json.JSONDecodeError) as e:
328
+ raise StorageError(f"Failed to read metadata for {key}: {e}")
329
+
330
+
331
+ __all__ = ["LocalBackend"]
@@ -0,0 +1,214 @@
1
+ """
2
+ In-memory storage backend for testing and development.
3
+
4
+ WARNING: Data is not persisted across restarts. Use only for testing or development.
5
+ """
6
+
7
+ import asyncio
8
+ from datetime import datetime, timezone
9
+ from typing import Optional
10
+
11
+ from ..base import FileNotFoundError, InvalidKeyError, QuotaExceededError
12
+
13
+
14
+ class MemoryBackend:
15
+ """
16
+ In-memory storage backend.
17
+
18
+ Stores files in memory using dictionaries. Fast and simple for testing,
19
+ but data is lost when the process restarts.
20
+
21
+ Args:
22
+ max_size: Maximum total storage size in bytes (default: 100MB)
23
+ default_expires_in: Default URL expiration in seconds (default: 3600)
24
+
25
+ Example:
26
+ >>> backend = MemoryBackend(max_size=10_000_000) # 10MB max
27
+ >>> url = await backend.put(
28
+ ... key="test/file.txt",
29
+ ... data=b"Hello, World!",
30
+ ... content_type="text/plain"
31
+ ... )
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ max_size: int = 100_000_000, # 100MB
37
+ default_expires_in: int = 3600,
38
+ ):
39
+ self.max_size = max_size
40
+ self.default_expires_in = default_expires_in
41
+ self._storage: dict[str, bytes] = {}
42
+ self._metadata: dict[str, dict] = {}
43
+ self._lock = asyncio.Lock()
44
+
45
+ def _validate_key(self, key: str) -> None:
46
+ """Validate storage key format."""
47
+ if not key:
48
+ raise InvalidKeyError("Key cannot be empty")
49
+
50
+ if key.startswith("/"):
51
+ raise InvalidKeyError("Key cannot start with /")
52
+
53
+ if ".." in key:
54
+ raise InvalidKeyError("Key cannot contain .. (path traversal)")
55
+
56
+ if len(key) > 1024:
57
+ raise InvalidKeyError("Key cannot exceed 1024 characters")
58
+
59
+ # Check for safe characters
60
+ safe_chars = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-/")
61
+ if not all(c in safe_chars for c in key):
62
+ raise InvalidKeyError(
63
+ "Key can only contain alphanumeric, dot, dash, underscore, and slash"
64
+ )
65
+
66
+ def _get_total_size(self) -> int:
67
+ """Calculate total storage size."""
68
+ return sum(len(data) for data in self._storage.values())
69
+
70
+ async def put(
71
+ self,
72
+ key: str,
73
+ data: bytes,
74
+ content_type: str,
75
+ metadata: Optional[dict] = None,
76
+ ) -> str:
77
+ """Store file in memory."""
78
+ self._validate_key(key)
79
+
80
+ async with self._lock:
81
+ # Check quota
82
+ current_size = self._get_total_size()
83
+ new_size = len(data)
84
+
85
+ # If replacing existing file, subtract its size
86
+ if key in self._storage:
87
+ current_size -= len(self._storage[key])
88
+
89
+ if current_size + new_size > self.max_size:
90
+ raise QuotaExceededError(
91
+ f"Storage quota exceeded. "
92
+ f"Current: {current_size}, New: {new_size}, Max: {self.max_size}"
93
+ )
94
+
95
+ # Store file
96
+ self._storage[key] = data
97
+
98
+ # Store metadata
99
+ self._metadata[key] = {
100
+ "size": len(data),
101
+ "content_type": content_type,
102
+ "created_at": datetime.now(timezone.utc).isoformat(),
103
+ **(metadata or {}),
104
+ }
105
+
106
+ # Return memory:// URL
107
+ return f"memory://{key}"
108
+
109
+ async def get(self, key: str) -> bytes:
110
+ """Retrieve file from memory."""
111
+ self._validate_key(key)
112
+
113
+ async with self._lock:
114
+ if key not in self._storage:
115
+ raise FileNotFoundError(f"File not found: {key}")
116
+
117
+ return self._storage[key]
118
+
119
+ async def delete(self, key: str) -> bool:
120
+ """Delete file from memory."""
121
+ self._validate_key(key)
122
+
123
+ async with self._lock:
124
+ if key not in self._storage:
125
+ return False
126
+
127
+ del self._storage[key]
128
+ del self._metadata[key]
129
+ return True
130
+
131
+ async def exists(self, key: str) -> bool:
132
+ """Check if file exists in memory."""
133
+ self._validate_key(key)
134
+
135
+ async with self._lock:
136
+ return key in self._storage
137
+
138
+ async def get_url(
139
+ self,
140
+ key: str,
141
+ expires_in: int = 3600,
142
+ download: bool = False,
143
+ ) -> str:
144
+ """
145
+ Generate memory:// URL.
146
+
147
+ Note: Memory backend doesn't support real URLs or expiration.
148
+ Returns memory:// scheme for testing purposes.
149
+ """
150
+ self._validate_key(key)
151
+
152
+ async with self._lock:
153
+ if key not in self._storage:
154
+ raise FileNotFoundError(f"File not found: {key}")
155
+
156
+ # Memory backend doesn't support real URLs
157
+ # Return memory:// scheme for testing
158
+ suffix = "?download=true" if download else ""
159
+ return f"memory://{key}{suffix}"
160
+
161
+ async def list_keys(
162
+ self,
163
+ prefix: str = "",
164
+ limit: int = 100,
165
+ ) -> list[str]:
166
+ """List stored keys with optional prefix filter."""
167
+ async with self._lock:
168
+ keys = [key for key in self._storage.keys() if key.startswith(prefix)]
169
+ return keys[:limit]
170
+
171
+ async def get_metadata(self, key: str) -> dict:
172
+ """Get file metadata."""
173
+ self._validate_key(key)
174
+
175
+ async with self._lock:
176
+ if key not in self._metadata:
177
+ raise FileNotFoundError(f"File not found: {key}")
178
+
179
+ return self._metadata[key].copy()
180
+
181
+ async def clear(self) -> None:
182
+ """
183
+ Clear all stored files (testing utility).
184
+
185
+ Example:
186
+ >>> backend = MemoryBackend()
187
+ >>> await backend.put("test.txt", b"data", "text/plain")
188
+ >>> await backend.clear()
189
+ >>> await backend.exists("test.txt") # False
190
+ """
191
+ async with self._lock:
192
+ self._storage.clear()
193
+ self._metadata.clear()
194
+
195
+ def get_stats(self) -> dict:
196
+ """
197
+ Get storage statistics (testing utility).
198
+
199
+ Returns:
200
+ Dict with file_count, total_size, max_size
201
+
202
+ Example:
203
+ >>> backend = MemoryBackend(max_size=1000)
204
+ >>> stats = backend.get_stats()
205
+ >>> print(f"Files: {stats['file_count']}, Size: {stats['total_size']}")
206
+ """
207
+ return {
208
+ "file_count": len(self._storage),
209
+ "total_size": self._get_total_size(),
210
+ "max_size": self.max_size,
211
+ }
212
+
213
+
214
+ __all__ = ["MemoryBackend"]