Skip to content

Commit fc5fcbb

Browse files
committed
feat: add SIGNED capability
1 parent 685d3b8 commit fc5fcbb

File tree

9 files changed

+107
-13
lines changed

9 files changed

+107
-13
lines changed

pyproject.toml

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,12 @@ test = [ "pytest-cov", "pytest-faker", "responses", "werkzeug" ]
3939
docs = [ "mkdocs", "mkdocs-material", "pymdown-extensions", "mkdocstrings[python]",]
4040
dev = [ "pytest-cov", "pytest-faker", "responses", "mkdocs", "mkdocs-material", "pymdown-extensions", "mkdocstrings[python]",]
4141

42-
redis = [ "redis",]
43-
s3 = [ "boto3", "boto3-stubs[essential]"]
42+
azure = ["azure-storage-blob",]
4443
gcs = [ "google-cloud-storage",]
45-
opendal = [ "opendal",]
4644
libcloud = [ "apache-libcloud", "cryptography",]
45+
opendal = [ "opendal",]
46+
redis = [ "redis",]
47+
s3 = [ "boto3", "boto3-stubs[essential]"]
4748
sqlalchemy = ["sqlalchemy"]
4849

4950
[project.entry-points."file_keeper_ext"]

src/file_keeper/core/storage.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,16 @@ def analyze(
213213
"""Return all details about filename."""
214214
raise NotImplementedError
215215

216+
def signed(
217+
self,
218+
action: types.SignedAction,
219+
duration: int,
220+
location: types.Location,
221+
extras: dict[str, Any],
222+
) -> str:
223+
"""Make an URL for signed action."""
224+
raise NotImplementedError
225+
216226

217227
class Reader(StorageService):
218228
"""Service responsible for reading data from the storage.
@@ -558,6 +568,17 @@ def analyze(self, location: types.Location, /, **kwargs: Any) -> data.FileData:
558568
"""Return file details for the given location."""
559569
return self.manager.analyze(location, kwargs)
560570

571+
@requires_capability(Capability.SIGNED)
572+
def signed(
573+
self,
574+
action: types.SignedAction,
575+
duration: int,
576+
location: types.Location,
577+
**kwargs: Any,
578+
) -> str:
579+
"""Make an URL for signed action."""
580+
return self.manager.signed(action, duration, location, kwargs)
581+
561582
@requires_capability(Capability.STREAM)
562583
def stream(self, data: data.FileData, /, **kwargs: Any) -> Iterable[bytes]:
563584
"""Return the stream of file's content."""

src/file_keeper/core/types.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from collections.abc import Callable, Iterator
44
from typing import TYPE_CHECKING, Any, NewType, Protocol
55

6-
from typing_extensions import TypeAlias
6+
from typing_extensions import Literal, TypeAlias
77

88
if TYPE_CHECKING:
99
from .data import BaseData
@@ -15,6 +15,8 @@
1515
[str, "Upload | BaseData | None", "dict[str, Any]"], str
1616
]
1717

18+
SignedAction = Literal["upload", "download", "delete"]
19+
1820

1921
class PReadable(Protocol):
2022
def read(self, size: Any = ..., /) -> bytes: ...
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# from azure.storage.blob import generate_blob_sas, BlobSasPermissions
2+
3+
# def azure_signed_action(action, duration, location):
4+
# container, blob = location.split('/', 1)
5+
# perms = BlobSasPermissions()
6+
# if action == 'download': perms.read = True
7+
# elif action == 'upload': perms.write = True; perms.create = True
8+
# elif action == 'delete': perms.delete = True
9+
# else: raise ValueError('Unsupported action')
10+
11+
# sas = generate_blob_sas(
12+
# account_name=...,
13+
# account_key=...,
14+
# container_name=container,
15+
# blob_name=blob,
16+
# permission=perms,
17+
# expiry=datetime.utcnow() + timedelta(seconds=duration)
18+
# )
19+
# return f"https://{account}.blob.core.windows.net/{container}/{blob}?{sas}"

src/file_keeper/default/adapters/gcs.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import dataclasses
55
import os
66
import re
7+
from datetime import timedelta
78
from typing import Any, cast
89

910
import requests
@@ -251,7 +252,27 @@ def multipart_complete(
251252

252253
class Manager(fk.Manager):
253254
storage: GoogleCloudStorage
254-
capabilities: fk.Capability = fk.Capability.REMOVE
255+
capabilities: fk.Capability = fk.Capability.REMOVE | fk.Capability.SIGNED
256+
257+
@override
258+
def signed(
259+
self,
260+
action: fk.types.SignedAction,
261+
duration: int,
262+
location: fk.Location,
263+
extras: dict[str, Any],
264+
) -> str:
265+
name = self.storage.full_path(location)
266+
267+
client = self.storage.settings.client
268+
bucket = client.bucket(self.storage.settings.bucket)
269+
blob = bucket.blob(name)
270+
271+
method = {"download": "GET", "upload": "PUT", "delete": "DELETE"}[action]
272+
273+
return blob.generate_signed_url(
274+
version="v4", expiration=timedelta(seconds=duration), method=method
275+
)
255276

256277
@override
257278
def remove(

src/file_keeper/default/adapters/null.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,12 @@ def permanent_link(self, data: fk.FileData, extras: dict[str, Any]) -> str:
151151
return data.location
152152

153153
@override
154-
def temporal_link(self, data: fk.FileData, extras: dict[str, Any]) -> str:
154+
def temporal_link(
155+
self,
156+
data: fk.FileData,
157+
duration: int,
158+
extras: dict[str, Any],
159+
) -> str:
155160
return data.location
156161

157162
@override

src/file_keeper/default/adapters/proxy.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@
44

55

66
@fk.Storage.register
7-
class ProxyStorage: ...
7+
class ProxyStorage:
8+
hidden = True

src/file_keeper/default/adapters/redis.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
import file_keeper as fk
1313

14-
pools = fk.Registry[redis.ConnectionPool]()
14+
pool = fk.Registry[redis.ConnectionPool]()
1515

1616

1717
@dataclasses.dataclass
@@ -34,15 +34,15 @@ def __post_init__(self, url: str, **kwargs: Any):
3434
super().__post_init__(**kwargs)
3535

3636
if self.redis is None: # pyright: ignore[reportUnnecessaryComparison]
37-
if url not in pools:
38-
pools.register(
39-
url,
37+
if url not in pool:
38+
conn = (
4039
redis.ConnectionPool.from_url(url)
4140
if url
42-
else redis.ConnectionPool(),
41+
else redis.ConnectionPool()
4342
)
43+
pool.register(url, conn)
4444

45-
self.redis = redis.Redis(connection_pool=pools[url])
45+
self.redis = redis.Redis(connection_pool=pool[url])
4646

4747

4848
class Uploader(fk.Uploader):

src/file_keeper/default/adapters/s3.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,8 +259,32 @@ class Manager(fk.Manager):
259259
| fk.Capability.SCAN
260260
| fk.Capability.MOVE
261261
| fk.Capability.COPY
262+
| fk.Capability.SIGNED
262263
)
263264

265+
@override
266+
def signed(
267+
self,
268+
action: fk.types.SignedAction,
269+
duration: int,
270+
location: fk.Location,
271+
extras: dict[str, Any],
272+
) -> str:
273+
client = self.storage.settings.client
274+
method = {
275+
"download": "get_object",
276+
"upload": "put_object",
277+
"delete": "delete_object",
278+
}[action]
279+
280+
key = self.storage.full_path(location)
281+
282+
return client.generate_presigned_url(
283+
ClientMethod=method,
284+
Params={"Bucket": self.storage.settings.bucket, "Key": key},
285+
ExpiresIn=duration,
286+
)
287+
264288
@override
265289
def copy(
266290
self,

0 commit comments

Comments
 (0)