Recently Written · git

subplz-web

git clone https://github.com/equwal/subplz-web

Log | Files | Refs


backend/storage.py (5761 bytes)

1 """Artifact storage. Local filesystem now, S3 for the public release.
2 
3 Everything downstream deals in opaque storage keys, so swapping the backend
4 touches neither the API nor the job runner.
5 """
6 
7 import shutil
8 from abc import ABC, abstractmethod
9 from pathlib import Path
10 
11 from .settings import settings
12 
13 
14 class Storage(ABC):
15     @abstractmethod
16     def put_file(self, key: str, source: Path) -> int:
17         """Store the file at `source` under `key`; return the byte size."""
18 
19     @abstractmethod
20     def open_stream(self, key: str):
21         """Return a binary file-like object for `key`."""
22 
23     @abstractmethod
24     def read_text(self, key: str) -> str:
25         ...
26 
27     @abstractmethod
28     def delete_prefix(self, prefix: str) -> None:
29         ...
30 
31     @abstractmethod
32     def list_prefix(self, prefix: str) -> list[str]:
33         """Every key under `prefix`. Used to move a job's inputs between machines."""
34 
35     def presigned_url(self, key: str, filename: str) -> str | None:
36         """S3 hands back a direct URL; local storage streams through the app."""
37         return None
38 
39     def exists(self, key: str) -> bool:
40         try:
41             self.open_stream(key).close()
42             return True
43         except Exception:
44             return False
45 
46     def fetch_to(self, key: str, dest: Path) -> Path:
47         """Copy `key` out of storage onto local disk.
48 
49         This is what lets a worker on another machine pick up a job whose files
50         were uploaded to a different API process.
51         """
52         dest.parent.mkdir(parents=True, exist_ok=True)
53         src = self.open_stream(key)
54         try:
55             with dest.open("wb") as out:
56                 while chunk := src.read(8 * 1024 * 1024):
57                     out.write(chunk)
58         finally:
59             close = getattr(src, "close", None)
60             if close:
61                 close()
62         return dest
63 
64 
65 class LocalStorage(Storage):
66     def __init__(self, root: Path):
67         self.root = root.resolve()
68         self.root.mkdir(parents=True, exist_ok=True)
69 
70     def _path(self, key: str) -> Path:
71         # Keys are app-generated, but refuse traversal regardless.
72         p = (self.root / key).resolve()
73         if not str(p).startswith(str(self.root)):
74             raise ValueError(f"unsafe storage key: {key!r}")
75         return p
76 
77     def path_for(self, key: str) -> Path:
78         """On-disk location of `key`. Local backend only - used to serve files."""
79         return self._path(key)
80 
81     def put_file(self, key: str, source: Path) -> int:
82         dest = self._path(key)
83         dest.parent.mkdir(parents=True, exist_ok=True)
84         shutil.copy2(source, dest)
85         return dest.stat().st_size
86 
87     def open_stream(self, key: str):
88         return self._path(key).open("rb")
89 
90     def read_text(self, key: str) -> str:
91         return self._path(key).read_text(encoding="utf-8")
92 
93     def delete_prefix(self, prefix: str) -> None:
94         target = self._path(prefix)
95         if target.is_dir():
96             shutil.rmtree(target, ignore_errors=True)
97         elif target.exists():
98             target.unlink()
99 
100     def list_prefix(self, prefix: str) -> list[str]:
101         target = self._path(prefix)
102         if not target.is_dir():
103             return [prefix] if target.exists() else []
104         return sorted(
105             p.relative_to(self.root).as_posix()
106             for p in target.rglob("*")
107             if p.is_file()
108         )
109 
110 
111 class S3Storage(Storage):
112     """Public-release backend. Needs boto3 and SUBPLZ_WEB_S3_BUCKET."""
113 
114     def __init__(self, bucket: str, prefix: str):
115         import boto3  # lazy import so localhost never needs boto3
116 
117         self.client = boto3.client("s3")
118         self.bucket = bucket
119         self.prefix = prefix.rstrip("/") + "/" if prefix else ""
120 
121     def _key(self, key: str) -> str:
122         return f"{self.prefix}{key}"
123 
124     def put_file(self, key: str, source: Path) -> int:
125         self.client.upload_file(str(source), self.bucket, self._key(key))
126         return source.stat().st_size
127 
128     def open_stream(self, key: str):
129         return self.client.get_object(Bucket=self.bucket, Key=self._key(key))["Body"]
130 
131     def read_text(self, key: str) -> str:
132         return self.open_stream(key).read().decode("utf-8")
133 
134     def delete_prefix(self, prefix: str) -> None:
135         paginator = self.client.get_paginator("list_objects_v2")
136         for page in paginator.paginate(Bucket=self.bucket, Prefix=self._key(prefix)):
137             keys = [{"Key": o["Key"]} for o in page.get("Contents", [])]
138             if keys:
139                 self.client.delete_objects(Bucket=self.bucket, Delete={"Objects": keys})
140 
141     def list_prefix(self, prefix: str) -> list[str]:
142         paginator = self.client.get_paginator("list_objects_v2")
143         out: list[str] = []
144         head = len(self.prefix)
145         for page in paginator.paginate(Bucket=self.bucket, Prefix=self._key(prefix)):
146             out += [o["Key"][head:] for o in page.get("Contents", [])]
147         return sorted(out)
148 
149     def presigned_url(self, key: str, filename: str) -> str | None:
150         disposition = f'attachment; filename="{filename}"'
151         return self.client.generate_presigned_url(
152             "get_object",
153             Params={
154                 "Bucket": self.bucket,
155                 "Key": self._key(key),
156                 "ResponseContentDisposition": disposition,
157             },
158             ExpiresIn=settings.download_url_ttl_seconds,
159         )
160 
161 
162 def get_storage() -> Storage:
163     if settings.storage_backend == "s3":
164         if not settings.s3_bucket:
165             raise RuntimeError("storage_backend=s3 requires SUBPLZ_WEB_S3_BUCKET")
166         return S3Storage(settings.s3_bucket, settings.s3_prefix)
167     return LocalStorage(settings.data_dir / "artifacts")
168 
169 
170 storage = get_storage()