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.
@@ -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,5 @@
1
+ import hashlib
2
+
3
+
4
+ def sha256_bytes(content: bytes) -> str:
5
+ return hashlib.sha256(content).hexdigest()
@@ -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,5 @@
1
+ from .base import FileStorage
2
+ from .factory import create_file_storage
3
+ from .gcs import GcsFileStorage
4
+
5
+ __all__ = ["FileStorage", "GcsFileStorage", "create_file_storage"]
@@ -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.")