rse-files-core 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.
- rse_files_core-0.1.0/PKG-INFO +98 -0
- rse_files_core-0.1.0/README.md +83 -0
- rse_files_core-0.1.0/pyproject.toml +19 -0
- rse_files_core-0.1.0/rse_files/__init__.py +40 -0
- rse_files_core-0.1.0/rse_files/checksum.py +5 -0
- rse_files_core-0.1.0/rse_files/config.py +120 -0
- rse_files_core-0.1.0/rse_files/exceptions.py +18 -0
- rse_files_core-0.1.0/rse_files/filenames.py +17 -0
- rse_files_core-0.1.0/rse_files/keys.py +42 -0
- rse_files_core-0.1.0/rse_files/metadata.py +22 -0
- rse_files_core-0.1.0/rse_files/storage/__init__.py +5 -0
- rse_files_core-0.1.0/rse_files/storage/base.py +24 -0
- rse_files_core-0.1.0/rse_files/storage/factory.py +31 -0
- rse_files_core-0.1.0/rse_files/storage/gcs.py +111 -0
- rse_files_core-0.1.0/rse_files/validators.py +23 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rse-files-core
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: RSE standard file storage helpers with Vault-backed configuration and GCS support.
|
|
5
|
+
Author: RSE Team
|
|
6
|
+
Requires-Python: >=3.12,<4.0
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
11
|
+
Requires-Dist: google-cloud-storage (>=2.19.0,<3.0.0)
|
|
12
|
+
Requires-Dist: hvac (>=2.4.0,<3.0.0)
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# rse-files-core
|
|
16
|
+
|
|
17
|
+
RSE standard file storage package for Python APIs.
|
|
18
|
+
|
|
19
|
+
Features:
|
|
20
|
+
|
|
21
|
+
- GCS upload/download helpers
|
|
22
|
+
- storage configuration from local environment or HashiCorp Vault
|
|
23
|
+
- filename and storage key sanitization
|
|
24
|
+
- SHA-256 checksums
|
|
25
|
+
- PDF/content-type/max-size validation
|
|
26
|
+
- standardized metadata and exceptions
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from rse_files import (
|
|
32
|
+
build_storage_key,
|
|
33
|
+
create_file_storage,
|
|
34
|
+
validate_pdf_bytes,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
storage = create_file_storage()
|
|
38
|
+
|
|
39
|
+
validate_pdf_bytes(content)
|
|
40
|
+
|
|
41
|
+
storage_key = build_storage_key(
|
|
42
|
+
namespace="nc",
|
|
43
|
+
owner_type="action-plan-evidence",
|
|
44
|
+
owner_id=123,
|
|
45
|
+
filename="evidencia.pdf",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
metadata = storage.upload_bytes(
|
|
49
|
+
content=content,
|
|
50
|
+
storage_key=storage_key,
|
|
51
|
+
content_type="application/pdf",
|
|
52
|
+
filename="evidencia.pdf",
|
|
53
|
+
)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Local Configuration
|
|
57
|
+
|
|
58
|
+
When `RSE_ENV=local`, the package reads regular environment variables:
|
|
59
|
+
|
|
60
|
+
```text
|
|
61
|
+
RSE_FILES_STORAGE_PROVIDER=gcs
|
|
62
|
+
RSE_FILES_BUCKET=my-bucket
|
|
63
|
+
RSE_FILES_PREFIX=uploads
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Vault
|
|
67
|
+
|
|
68
|
+
When `RSE_ENV` is `stg` or `prd`, the package reads Vault using:
|
|
69
|
+
|
|
70
|
+
- `RSE_ENV` as the KV v2 mount point
|
|
71
|
+
- `RSE_VAULT_HOST` as the Vault URL
|
|
72
|
+
- `RSE_VAULT_TOKEN` as the Vault token
|
|
73
|
+
- `RSE_FILES_VAULT_PATH` as the secret path, defaulting to `shared`
|
|
74
|
+
|
|
75
|
+
Expected keys:
|
|
76
|
+
|
|
77
|
+
```text
|
|
78
|
+
rse_files_storage_provider
|
|
79
|
+
rse_files_bucket
|
|
80
|
+
rse_files_prefix
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Fallback keys are also accepted for compatibility:
|
|
84
|
+
|
|
85
|
+
```text
|
|
86
|
+
document_storage_provider
|
|
87
|
+
gcs_documents_bucket
|
|
88
|
+
gcs_documents_prefix
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Example:
|
|
92
|
+
|
|
93
|
+
```text
|
|
94
|
+
stg/shared.rse_files_storage_provider = gcs
|
|
95
|
+
stg/shared.rse_files_bucket = rse-documents-stg
|
|
96
|
+
stg/shared.rse_files_prefix = files
|
|
97
|
+
```
|
|
98
|
+
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# rse-files-core
|
|
2
|
+
|
|
3
|
+
RSE standard file storage package for Python APIs.
|
|
4
|
+
|
|
5
|
+
Features:
|
|
6
|
+
|
|
7
|
+
- GCS upload/download helpers
|
|
8
|
+
- storage configuration from local environment or HashiCorp Vault
|
|
9
|
+
- filename and storage key sanitization
|
|
10
|
+
- SHA-256 checksums
|
|
11
|
+
- PDF/content-type/max-size validation
|
|
12
|
+
- standardized metadata and exceptions
|
|
13
|
+
|
|
14
|
+
## Usage
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
from rse_files import (
|
|
18
|
+
build_storage_key,
|
|
19
|
+
create_file_storage,
|
|
20
|
+
validate_pdf_bytes,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
storage = create_file_storage()
|
|
24
|
+
|
|
25
|
+
validate_pdf_bytes(content)
|
|
26
|
+
|
|
27
|
+
storage_key = build_storage_key(
|
|
28
|
+
namespace="nc",
|
|
29
|
+
owner_type="action-plan-evidence",
|
|
30
|
+
owner_id=123,
|
|
31
|
+
filename="evidencia.pdf",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
metadata = storage.upload_bytes(
|
|
35
|
+
content=content,
|
|
36
|
+
storage_key=storage_key,
|
|
37
|
+
content_type="application/pdf",
|
|
38
|
+
filename="evidencia.pdf",
|
|
39
|
+
)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Local Configuration
|
|
43
|
+
|
|
44
|
+
When `RSE_ENV=local`, the package reads regular environment variables:
|
|
45
|
+
|
|
46
|
+
```text
|
|
47
|
+
RSE_FILES_STORAGE_PROVIDER=gcs
|
|
48
|
+
RSE_FILES_BUCKET=my-bucket
|
|
49
|
+
RSE_FILES_PREFIX=uploads
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Vault
|
|
53
|
+
|
|
54
|
+
When `RSE_ENV` is `stg` or `prd`, the package reads Vault using:
|
|
55
|
+
|
|
56
|
+
- `RSE_ENV` as the KV v2 mount point
|
|
57
|
+
- `RSE_VAULT_HOST` as the Vault URL
|
|
58
|
+
- `RSE_VAULT_TOKEN` as the Vault token
|
|
59
|
+
- `RSE_FILES_VAULT_PATH` as the secret path, defaulting to `shared`
|
|
60
|
+
|
|
61
|
+
Expected keys:
|
|
62
|
+
|
|
63
|
+
```text
|
|
64
|
+
rse_files_storage_provider
|
|
65
|
+
rse_files_bucket
|
|
66
|
+
rse_files_prefix
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Fallback keys are also accepted for compatibility:
|
|
70
|
+
|
|
71
|
+
```text
|
|
72
|
+
document_storage_provider
|
|
73
|
+
gcs_documents_bucket
|
|
74
|
+
gcs_documents_prefix
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Example:
|
|
78
|
+
|
|
79
|
+
```text
|
|
80
|
+
stg/shared.rse_files_storage_provider = gcs
|
|
81
|
+
stg/shared.rse_files_bucket = rse-documents-stg
|
|
82
|
+
stg/shared.rse_files_prefix = files
|
|
83
|
+
```
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "rse-files-core"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "RSE standard file storage helpers with Vault-backed configuration and GCS support."
|
|
5
|
+
authors = ["RSE Team"]
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
packages = [{ include = "rse_files" }]
|
|
8
|
+
|
|
9
|
+
[tool.poetry.dependencies]
|
|
10
|
+
python = "^3.12"
|
|
11
|
+
google-cloud-storage = "^2.19.0"
|
|
12
|
+
hvac = "^2.4.0"
|
|
13
|
+
|
|
14
|
+
[tool.poetry.group.dev.dependencies]
|
|
15
|
+
pytest = "^8.3.0"
|
|
16
|
+
|
|
17
|
+
[build-system]
|
|
18
|
+
requires = ["poetry-core"]
|
|
19
|
+
build-backend = "poetry.core.masonry.api"
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from .checksum import sha256_bytes
|
|
2
|
+
from .config import FileStorageConfig, get_file_storage_config
|
|
3
|
+
from .exceptions import (
|
|
4
|
+
FileConfigurationError,
|
|
5
|
+
FileError,
|
|
6
|
+
FileNotFoundError,
|
|
7
|
+
FileStorageError,
|
|
8
|
+
FileValidationError,
|
|
9
|
+
)
|
|
10
|
+
from .filenames import sanitize_filename
|
|
11
|
+
from .keys import build_storage_key, sanitize_path_segment, validate_storage_key
|
|
12
|
+
from .metadata import FileMetadata
|
|
13
|
+
from .storage.factory import create_file_storage
|
|
14
|
+
from .validators import (
|
|
15
|
+
PDF_CONTENT_TYPE,
|
|
16
|
+
validate_allowed_content_type,
|
|
17
|
+
validate_max_size,
|
|
18
|
+
validate_pdf_bytes,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"FileConfigurationError",
|
|
23
|
+
"FileError",
|
|
24
|
+
"FileMetadata",
|
|
25
|
+
"FileNotFoundError",
|
|
26
|
+
"FileStorageConfig",
|
|
27
|
+
"FileStorageError",
|
|
28
|
+
"FileValidationError",
|
|
29
|
+
"PDF_CONTENT_TYPE",
|
|
30
|
+
"build_storage_key",
|
|
31
|
+
"create_file_storage",
|
|
32
|
+
"get_file_storage_config",
|
|
33
|
+
"sanitize_filename",
|
|
34
|
+
"sanitize_path_segment",
|
|
35
|
+
"sha256_bytes",
|
|
36
|
+
"validate_allowed_content_type",
|
|
37
|
+
"validate_max_size",
|
|
38
|
+
"validate_pdf_bytes",
|
|
39
|
+
"validate_storage_key",
|
|
40
|
+
]
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from hvac import Client
|
|
8
|
+
from hvac.exceptions import Unauthorized
|
|
9
|
+
|
|
10
|
+
from .exceptions import FileConfigurationError
|
|
11
|
+
|
|
12
|
+
STORAGE_PROVIDER_GCS = "gcs"
|
|
13
|
+
DEFAULT_STORAGE_PREFIX = "files"
|
|
14
|
+
DEFAULT_VAULT_PATH = "shared"
|
|
15
|
+
|
|
16
|
+
_vault_cache: dict[str, dict[str, Any]] = {}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class FileStorageConfig:
|
|
21
|
+
storage_provider: str
|
|
22
|
+
bucket_name: str
|
|
23
|
+
prefix: str = DEFAULT_STORAGE_PREFIX
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_file_storage_config(
|
|
27
|
+
*,
|
|
28
|
+
provider: str | None = None,
|
|
29
|
+
bucket_name: str | None = None,
|
|
30
|
+
prefix: str | None = None,
|
|
31
|
+
vault_path: str | None = None,
|
|
32
|
+
) -> FileStorageConfig:
|
|
33
|
+
if provider is not None or bucket_name is not None or prefix is not None:
|
|
34
|
+
return _build_config(
|
|
35
|
+
provider=provider,
|
|
36
|
+
bucket_name=bucket_name,
|
|
37
|
+
prefix=prefix,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
rse_env = os.getenv("RSE_ENV", "local").strip().lower()
|
|
41
|
+
if rse_env == "local":
|
|
42
|
+
return _config_from_environment()
|
|
43
|
+
if rse_env in {"stg", "prd"}:
|
|
44
|
+
return _config_from_vault(vault_path or os.getenv("RSE_FILES_VAULT_PATH") or DEFAULT_VAULT_PATH)
|
|
45
|
+
|
|
46
|
+
raise FileConfigurationError(
|
|
47
|
+
f"Invalid RSE_ENV value: {rse_env}. Must be one of: local, stg, prd."
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _config_from_environment() -> FileStorageConfig:
|
|
52
|
+
return _build_config(
|
|
53
|
+
provider=os.getenv("RSE_FILES_STORAGE_PROVIDER") or os.getenv("DOCUMENT_STORAGE_PROVIDER"),
|
|
54
|
+
bucket_name=os.getenv("RSE_FILES_BUCKET") or os.getenv("GCS_DOCUMENTS_BUCKET"),
|
|
55
|
+
prefix=os.getenv("RSE_FILES_PREFIX") or os.getenv("GCS_DOCUMENTS_PREFIX"),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _config_from_vault(vault_path: str) -> FileStorageConfig:
|
|
60
|
+
secrets = get_vault_secrets(vault_path)
|
|
61
|
+
return _build_config(
|
|
62
|
+
provider=secrets.get("rse_files_storage_provider") or secrets.get("document_storage_provider"),
|
|
63
|
+
bucket_name=secrets.get("rse_files_bucket") or secrets.get("gcs_documents_bucket"),
|
|
64
|
+
prefix=secrets.get("rse_files_prefix") or secrets.get("gcs_documents_prefix"),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _build_config(
|
|
69
|
+
*,
|
|
70
|
+
provider: str | None,
|
|
71
|
+
bucket_name: str | None,
|
|
72
|
+
prefix: str | None,
|
|
73
|
+
) -> FileStorageConfig:
|
|
74
|
+
normalized_provider = (provider or STORAGE_PROVIDER_GCS).strip().lower()
|
|
75
|
+
if normalized_provider != STORAGE_PROVIDER_GCS:
|
|
76
|
+
raise FileConfigurationError(
|
|
77
|
+
f"Invalid file storage provider: {normalized_provider or '<empty>'}."
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
normalized_bucket = (bucket_name or "").strip()
|
|
81
|
+
if not normalized_bucket:
|
|
82
|
+
raise FileConfigurationError("File storage bucket is not configured.")
|
|
83
|
+
|
|
84
|
+
normalized_prefix = (prefix or DEFAULT_STORAGE_PREFIX).strip() or DEFAULT_STORAGE_PREFIX
|
|
85
|
+
return FileStorageConfig(
|
|
86
|
+
storage_provider=normalized_provider,
|
|
87
|
+
bucket_name=normalized_bucket,
|
|
88
|
+
prefix=normalized_prefix,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def get_vault_secrets(path: str) -> dict[str, Any]:
|
|
93
|
+
if path in _vault_cache:
|
|
94
|
+
return _vault_cache[path]
|
|
95
|
+
|
|
96
|
+
vault_host = os.getenv("RSE_VAULT_HOST")
|
|
97
|
+
vault_token = os.getenv("RSE_VAULT_TOKEN")
|
|
98
|
+
rse_env = os.getenv("RSE_ENV", "local").strip().lower()
|
|
99
|
+
|
|
100
|
+
missing = []
|
|
101
|
+
if not vault_host:
|
|
102
|
+
missing.append("RSE_VAULT_HOST")
|
|
103
|
+
if not vault_token:
|
|
104
|
+
missing.append("RSE_VAULT_TOKEN")
|
|
105
|
+
if missing:
|
|
106
|
+
raise FileConfigurationError(
|
|
107
|
+
f"Required environment variables not declared: {', '.join(missing)}"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
client = Client(url=vault_host, token=vault_token)
|
|
111
|
+
if not client.is_authenticated():
|
|
112
|
+
raise Unauthorized("Not authenticated in Vault")
|
|
113
|
+
|
|
114
|
+
response = client.secrets.kv.v2.read_secret_version(
|
|
115
|
+
mount_point=rse_env,
|
|
116
|
+
path=path,
|
|
117
|
+
)
|
|
118
|
+
secrets = response["data"]["data"]
|
|
119
|
+
_vault_cache[path] = secrets
|
|
120
|
+
return secrets
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
class FileError(RuntimeError):
|
|
2
|
+
"""Base exception for rse-files-core."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class FileConfigurationError(FileError):
|
|
6
|
+
"""Raised when file storage is not configured correctly."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class FileValidationError(FileError):
|
|
10
|
+
"""Raised when file content or metadata is invalid."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class FileStorageError(FileError):
|
|
14
|
+
"""Raised when a storage provider operation fails."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class FileNotFoundError(FileStorageError):
|
|
18
|
+
"""Raised when a file is not found in storage."""
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import unicodedata
|
|
3
|
+
|
|
4
|
+
from .exceptions import FileConfigurationError
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def sanitize_filename(value: str) -> str:
|
|
8
|
+
unsafe_name = str(value or "").replace("\\", "/").split("/")[-1]
|
|
9
|
+
normalized = unicodedata.normalize("NFKD", unsafe_name.strip())
|
|
10
|
+
ascii_value = normalized.encode("ascii", "ignore").decode("ascii")
|
|
11
|
+
safe = re.sub(r"[^a-zA-Z0-9._-]+", "-", ascii_value.lower())
|
|
12
|
+
safe = re.sub(r"-+", "-", safe).strip("-. _")
|
|
13
|
+
safe = re.sub(r"-+(\.[a-zA-Z0-9]+)$", r"\1", safe)
|
|
14
|
+
safe = safe.replace("..", ".")
|
|
15
|
+
if not safe:
|
|
16
|
+
raise FileConfigurationError("Invalid filename.")
|
|
17
|
+
return safe
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
from .exceptions import FileConfigurationError
|
|
4
|
+
from .filenames import sanitize_filename
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def build_storage_key(
|
|
8
|
+
*,
|
|
9
|
+
namespace: str,
|
|
10
|
+
owner_type: str,
|
|
11
|
+
owner_id: str | int,
|
|
12
|
+
filename: str,
|
|
13
|
+
prefix: str | None = None,
|
|
14
|
+
) -> str:
|
|
15
|
+
parts = [
|
|
16
|
+
sanitize_path_segment(prefix) if prefix else None,
|
|
17
|
+
sanitize_path_segment(namespace),
|
|
18
|
+
sanitize_path_segment(owner_type),
|
|
19
|
+
sanitize_path_segment(owner_id),
|
|
20
|
+
sanitize_filename(filename),
|
|
21
|
+
]
|
|
22
|
+
return validate_storage_key("/".join(part for part in parts if part))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def sanitize_path_segment(value: str | int | None, *, preserve_case: bool = False) -> str:
|
|
26
|
+
raw = str(value or "").strip().replace("\\", "/")
|
|
27
|
+
if "/" in raw or raw in {"", ".", ".."} or ".." in raw:
|
|
28
|
+
raise FileConfigurationError("Invalid storage key segment.")
|
|
29
|
+
safe = sanitize_filename(raw)
|
|
30
|
+
return raw if preserve_case and re.fullmatch(r"[A-Za-z0-9._-]+", raw) else safe
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def validate_storage_key(storage_key: str) -> str:
|
|
34
|
+
key = str(storage_key or "").strip()
|
|
35
|
+
if not key:
|
|
36
|
+
raise FileConfigurationError("File storage key is not configured.")
|
|
37
|
+
if key.startswith("/") or "\\" in key or "//" in key:
|
|
38
|
+
raise FileConfigurationError("Invalid file storage key.")
|
|
39
|
+
parts = key.split("/")
|
|
40
|
+
if any(part in {"", ".", ".."} or ".." in part for part in parts):
|
|
41
|
+
raise FileConfigurationError("Invalid file storage key.")
|
|
42
|
+
return key
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import asdict, dataclass
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class FileMetadata:
|
|
9
|
+
storage_provider: str
|
|
10
|
+
bucket_name: str
|
|
11
|
+
storage_key: str
|
|
12
|
+
filename: str | None
|
|
13
|
+
content_type: str
|
|
14
|
+
file_size: int
|
|
15
|
+
checksum: str
|
|
16
|
+
etag: str | None = None
|
|
17
|
+
generation: str | None = None
|
|
18
|
+
created: Any | None = None
|
|
19
|
+
updated: Any | None = None
|
|
20
|
+
|
|
21
|
+
def to_dict(self) -> dict[str, Any]:
|
|
22
|
+
return asdict(self)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from typing import Protocol
|
|
2
|
+
|
|
3
|
+
from rse_files.metadata import FileMetadata
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class FileStorage(Protocol):
|
|
7
|
+
def upload_bytes(
|
|
8
|
+
self,
|
|
9
|
+
*,
|
|
10
|
+
content: bytes,
|
|
11
|
+
storage_key: str,
|
|
12
|
+
content_type: str,
|
|
13
|
+
filename: str | None = None,
|
|
14
|
+
) -> FileMetadata:
|
|
15
|
+
...
|
|
16
|
+
|
|
17
|
+
def download_bytes(self, storage_key: str) -> bytes:
|
|
18
|
+
...
|
|
19
|
+
|
|
20
|
+
def exists(self, storage_key: str) -> bool:
|
|
21
|
+
...
|
|
22
|
+
|
|
23
|
+
def delete(self, storage_key: str) -> None:
|
|
24
|
+
...
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from rse_files.config import STORAGE_PROVIDER_GCS, get_file_storage_config
|
|
4
|
+
from rse_files.exceptions import FileConfigurationError
|
|
5
|
+
from rse_files.storage.gcs import GcsFileStorage
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def create_file_storage(
|
|
9
|
+
*,
|
|
10
|
+
provider: str | None = None,
|
|
11
|
+
bucket_name: str | None = None,
|
|
12
|
+
prefix: str | None = None,
|
|
13
|
+
vault_path: str | None = None,
|
|
14
|
+
client: Any | None = None,
|
|
15
|
+
) -> GcsFileStorage:
|
|
16
|
+
config = get_file_storage_config(
|
|
17
|
+
provider=provider,
|
|
18
|
+
bucket_name=bucket_name,
|
|
19
|
+
prefix=prefix,
|
|
20
|
+
vault_path=vault_path,
|
|
21
|
+
)
|
|
22
|
+
if config.storage_provider == STORAGE_PROVIDER_GCS:
|
|
23
|
+
return GcsFileStorage(
|
|
24
|
+
bucket_name=config.bucket_name,
|
|
25
|
+
prefix=config.prefix,
|
|
26
|
+
client=client,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
raise FileConfigurationError(
|
|
30
|
+
f"Unsupported file storage provider: {config.storage_provider}."
|
|
31
|
+
)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from rse_files.checksum import sha256_bytes
|
|
6
|
+
from rse_files.config import DEFAULT_STORAGE_PREFIX, STORAGE_PROVIDER_GCS
|
|
7
|
+
from rse_files.exceptions import (
|
|
8
|
+
FileConfigurationError,
|
|
9
|
+
FileNotFoundError,
|
|
10
|
+
FileStorageError,
|
|
11
|
+
)
|
|
12
|
+
from rse_files.filenames import sanitize_filename
|
|
13
|
+
from rse_files.keys import sanitize_path_segment, validate_storage_key
|
|
14
|
+
from rse_files.metadata import FileMetadata
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class GcsFileStorage:
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
*,
|
|
21
|
+
bucket_name: str,
|
|
22
|
+
prefix: str | None = None,
|
|
23
|
+
client: Any | None = None,
|
|
24
|
+
) -> None:
|
|
25
|
+
if not bucket_name.strip():
|
|
26
|
+
raise FileConfigurationError("File storage bucket is not configured.")
|
|
27
|
+
self.bucket_name = bucket_name.strip()
|
|
28
|
+
self.prefix = sanitize_path_segment(prefix or DEFAULT_STORAGE_PREFIX)
|
|
29
|
+
self.client = client or self.build_client()
|
|
30
|
+
self.bucket = self.client.bucket(self.bucket_name)
|
|
31
|
+
|
|
32
|
+
def upload_bytes(
|
|
33
|
+
self,
|
|
34
|
+
*,
|
|
35
|
+
content: bytes,
|
|
36
|
+
storage_key: str,
|
|
37
|
+
content_type: str,
|
|
38
|
+
filename: str | None = None,
|
|
39
|
+
) -> FileMetadata:
|
|
40
|
+
key = validate_storage_key(storage_key)
|
|
41
|
+
if not content_type:
|
|
42
|
+
raise FileConfigurationError("File content_type is not configured.")
|
|
43
|
+
|
|
44
|
+
checksum = sha256_bytes(content)
|
|
45
|
+
blob = self.bucket.blob(key)
|
|
46
|
+
try:
|
|
47
|
+
blob.upload_from_string(
|
|
48
|
+
content,
|
|
49
|
+
content_type=content_type,
|
|
50
|
+
if_generation_match=0,
|
|
51
|
+
)
|
|
52
|
+
except Exception as exc:
|
|
53
|
+
raise FileStorageError("Failed to upload file to GCS.") from exc
|
|
54
|
+
|
|
55
|
+
return FileMetadata(
|
|
56
|
+
storage_provider=STORAGE_PROVIDER_GCS,
|
|
57
|
+
bucket_name=self.bucket_name,
|
|
58
|
+
storage_key=key,
|
|
59
|
+
filename=sanitize_filename(filename) if filename else None,
|
|
60
|
+
content_type=content_type,
|
|
61
|
+
file_size=len(content),
|
|
62
|
+
checksum=checksum,
|
|
63
|
+
etag=getattr(blob, "etag", None),
|
|
64
|
+
generation=_metadata_value(getattr(blob, "generation", None)),
|
|
65
|
+
created=_metadata_value(getattr(blob, "time_created", None)),
|
|
66
|
+
updated=_metadata_value(getattr(blob, "updated", None)),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def download_bytes(self, storage_key: str) -> bytes:
|
|
70
|
+
key = validate_storage_key(storage_key)
|
|
71
|
+
blob = self.bucket.blob(key)
|
|
72
|
+
try:
|
|
73
|
+
if not blob.exists():
|
|
74
|
+
raise FileNotFoundError("File not found in storage.")
|
|
75
|
+
return blob.download_as_bytes()
|
|
76
|
+
except FileNotFoundError:
|
|
77
|
+
raise
|
|
78
|
+
except Exception as exc:
|
|
79
|
+
raise FileStorageError("Failed to download file from GCS.") from exc
|
|
80
|
+
|
|
81
|
+
def exists(self, storage_key: str) -> bool:
|
|
82
|
+
key = validate_storage_key(storage_key)
|
|
83
|
+
try:
|
|
84
|
+
return bool(self.bucket.blob(key).exists())
|
|
85
|
+
except Exception as exc:
|
|
86
|
+
raise FileStorageError("Failed to check file existence in GCS.") from exc
|
|
87
|
+
|
|
88
|
+
def delete(self, storage_key: str) -> None:
|
|
89
|
+
key = validate_storage_key(storage_key)
|
|
90
|
+
blob = self.bucket.blob(key)
|
|
91
|
+
try:
|
|
92
|
+
if blob.exists():
|
|
93
|
+
blob.delete()
|
|
94
|
+
except Exception as exc:
|
|
95
|
+
raise FileStorageError("Failed to delete file from GCS.") from exc
|
|
96
|
+
|
|
97
|
+
@staticmethod
|
|
98
|
+
def build_client() -> Any:
|
|
99
|
+
try:
|
|
100
|
+
from google.cloud import storage
|
|
101
|
+
except ImportError as exc:
|
|
102
|
+
raise FileConfigurationError(
|
|
103
|
+
"Dependency google-cloud-storage is not installed."
|
|
104
|
+
) from exc
|
|
105
|
+
return storage.Client()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _metadata_value(value: Any) -> Any:
|
|
109
|
+
if hasattr(value, "isoformat"):
|
|
110
|
+
return value.isoformat()
|
|
111
|
+
return value
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from .exceptions import FileValidationError
|
|
2
|
+
|
|
3
|
+
PDF_CONTENT_TYPE = "application/pdf"
|
|
4
|
+
PDF_HEADER = b"%PDF-"
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def validate_allowed_content_type(content_type: str, allowed: set[str]) -> None:
|
|
8
|
+
if content_type not in allowed:
|
|
9
|
+
raise FileValidationError(f"Invalid content_type: {content_type}.")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def validate_max_size(content: bytes, max_bytes: int) -> None:
|
|
13
|
+
if len(content) > max_bytes:
|
|
14
|
+
raise FileValidationError(
|
|
15
|
+
f"File exceeds maximum size of {max_bytes} bytes."
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def validate_pdf_bytes(content: bytes) -> None:
|
|
20
|
+
if not content:
|
|
21
|
+
raise FileValidationError("PDF content is empty.")
|
|
22
|
+
if not content.startswith(PDF_HEADER):
|
|
23
|
+
raise FileValidationError("Invalid PDF content.")
|