soreplicator 0.1.0__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.
- soreplicator/__init__.py +3 -0
- soreplicator/client/__init__.py +3 -0
- soreplicator/client/cli.py +154 -0
- soreplicator/client/config.py +89 -0
- soreplicator/client/storage.py +225 -0
- soreplicator/server/__init__.py +5 -0
- soreplicator/server/app.py +247 -0
- soreplicator/server/auth.py +41 -0
- soreplicator/server/config.py +74 -0
- soreplicator/server/gateway.py +278 -0
- soreplicator/server/static/app.css +1 -0
- soreplicator/server/static/app.js +65 -0
- soreplicator/server/static/apple-touch-icon.png +0 -0
- soreplicator/server/static/favicon-32.png +0 -0
- soreplicator/server/static/htmx.min.js +1 -0
- soreplicator/server/static/icon-192.png +0 -0
- soreplicator/server/static/icon-512.png +0 -0
- soreplicator/server/static/site.webmanifest +19 -0
- soreplicator/server/static/sore-logo-64.png +0 -0
- soreplicator/server/static/sore_pixel.png +0 -0
- soreplicator/server/templates/admin.html +12 -0
- soreplicator/server/templates/base.html +30 -0
- soreplicator/server/templates/credentials.html +5 -0
- soreplicator/server/templates/error.html +5 -0
- soreplicator/server/templates/fragments/admin_table.html +19 -0
- soreplicator/server/templates/fragments/credentials_panel.html +10 -0
- soreplicator/server/templates/fragments/provisioning_error.html +3 -0
- soreplicator/server/templates/provisioning_error.html +5 -0
- soreplicator/server/templates/setup.html +24 -0
- soreplicator/server/templates.py +133 -0
- soreplicator-0.1.0.dist-info/METADATA +221 -0
- soreplicator-0.1.0.dist-info/RECORD +35 -0
- soreplicator-0.1.0.dist-info/WHEEL +5 -0
- soreplicator-0.1.0.dist-info/entry_points.txt +3 -0
- soreplicator-0.1.0.dist-info/top_level.txt +1 -0
soreplicator/__init__.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""The ``sore`` command-line interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import getpass
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import TextIO
|
|
10
|
+
from urllib.parse import urlsplit
|
|
11
|
+
|
|
12
|
+
from pydantic import ValidationError
|
|
13
|
+
|
|
14
|
+
from soreplicator.client.config import (
|
|
15
|
+
ClientConfig,
|
|
16
|
+
config_path,
|
|
17
|
+
load_config,
|
|
18
|
+
save_config,
|
|
19
|
+
)
|
|
20
|
+
from soreplicator.client.storage import StorageError, delete, upload
|
|
21
|
+
|
|
22
|
+
DEFAULT_ENDPOINT = "https://sore.simonsobservatory.org"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _human_bytes(value: int) -> str:
|
|
26
|
+
size = float(value)
|
|
27
|
+
for unit in ("B", "KB", "MB", "GB", "TB"):
|
|
28
|
+
if size < 1000 or unit == "TB":
|
|
29
|
+
return f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}"
|
|
30
|
+
size /= 1000
|
|
31
|
+
return f"{size:.1f} TB"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ProgressBar:
|
|
35
|
+
def __init__(self, total: int, stream: TextIO = sys.stderr, width: int = 28):
|
|
36
|
+
self.total = total
|
|
37
|
+
self.stream = stream
|
|
38
|
+
self.width = width
|
|
39
|
+
self.enabled = stream.isatty()
|
|
40
|
+
self.rendered = False
|
|
41
|
+
if self.enabled:
|
|
42
|
+
self.update(0, total)
|
|
43
|
+
|
|
44
|
+
def update(self, uploaded: int, total: int) -> None:
|
|
45
|
+
if not self.enabled:
|
|
46
|
+
return
|
|
47
|
+
fraction = 1.0 if total == 0 else min(1.0, uploaded / total)
|
|
48
|
+
filled = round(self.width * fraction)
|
|
49
|
+
bar = "#" * filled + "-" * (self.width - filled)
|
|
50
|
+
self.stream.write(
|
|
51
|
+
f"\rUploading [{bar}] {fraction:6.1%} "
|
|
52
|
+
f"{_human_bytes(uploaded)} / {_human_bytes(total)}"
|
|
53
|
+
)
|
|
54
|
+
self.stream.flush()
|
|
55
|
+
self.rendered = True
|
|
56
|
+
|
|
57
|
+
def close(self) -> None:
|
|
58
|
+
if self.rendered:
|
|
59
|
+
self.stream.write("\n")
|
|
60
|
+
self.stream.flush()
|
|
61
|
+
self.rendered = False
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
65
|
+
parser = argparse.ArgumentParser(
|
|
66
|
+
prog="sore", description="Upload immutable files to the SORE replica service."
|
|
67
|
+
)
|
|
68
|
+
actions = parser.add_mutually_exclusive_group(required=True)
|
|
69
|
+
actions.add_argument(
|
|
70
|
+
"--setup", action="store_true", help="store gateway credentials"
|
|
71
|
+
)
|
|
72
|
+
actions.add_argument("-i", "--input", type=Path, help="local file to upload")
|
|
73
|
+
actions.add_argument(
|
|
74
|
+
"-d", "--delete", metavar="REMOTE_PATH", help="delete an object"
|
|
75
|
+
)
|
|
76
|
+
parser.add_argument("-o", "--output", help="remote path for an upload")
|
|
77
|
+
parser.add_argument(
|
|
78
|
+
"-f", "--force", action="store_true", help="skip delete confirmation"
|
|
79
|
+
)
|
|
80
|
+
parser.add_argument(
|
|
81
|
+
"--config", type=Path, default=config_path(), help=argparse.SUPPRESS
|
|
82
|
+
)
|
|
83
|
+
return parser
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _setup(path: Path) -> int:
|
|
87
|
+
endpoint = (
|
|
88
|
+
input(f"Gateway endpoint [{DEFAULT_ENDPOINT}]: ").strip() or DEFAULT_ENDPOINT
|
|
89
|
+
)
|
|
90
|
+
parsed = urlsplit(endpoint)
|
|
91
|
+
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
|
92
|
+
raise ValueError("endpoint must be an absolute HTTP(S) URL")
|
|
93
|
+
hostname = parsed.hostname or ""
|
|
94
|
+
is_loopback = hostname in {"localhost", "127.0.0.1", "::1"} or hostname.endswith(
|
|
95
|
+
".localhost"
|
|
96
|
+
)
|
|
97
|
+
if parsed.scheme != "https" and not is_loopback:
|
|
98
|
+
raise ValueError(
|
|
99
|
+
"credentials may only be sent over HTTPS (except on localhost)"
|
|
100
|
+
)
|
|
101
|
+
username = input("Username: ").strip()
|
|
102
|
+
password = getpass.getpass("Password: ")
|
|
103
|
+
if not password:
|
|
104
|
+
raise ValueError("password cannot be empty")
|
|
105
|
+
destination = save_config(
|
|
106
|
+
ClientConfig(endpoint=endpoint, username=username, password=password), path
|
|
107
|
+
)
|
|
108
|
+
print(f"Credentials saved to {destination} with mode 600.")
|
|
109
|
+
return 0
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _confirm_delete(remote_path: str) -> bool:
|
|
113
|
+
answer = input(f"Delete {remote_path!r} permanently? [y/N] ").strip().lower()
|
|
114
|
+
return answer in {"y", "yes"}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def main(argv: list[str] | None = None) -> int:
|
|
118
|
+
parser = build_parser()
|
|
119
|
+
args = parser.parse_args(argv)
|
|
120
|
+
try:
|
|
121
|
+
if args.setup:
|
|
122
|
+
if args.output or args.force:
|
|
123
|
+
parser.error("--setup cannot be combined with -o or -f")
|
|
124
|
+
return _setup(args.config)
|
|
125
|
+
|
|
126
|
+
config = load_config(args.config)
|
|
127
|
+
if args.input:
|
|
128
|
+
if not args.output:
|
|
129
|
+
parser.error("-o/--output is required with -i/--input")
|
|
130
|
+
if args.force:
|
|
131
|
+
parser.error("-f/--force is only valid with -d/--delete")
|
|
132
|
+
progress = ProgressBar(args.input.stat().st_size)
|
|
133
|
+
try:
|
|
134
|
+
upload(config, args.input, args.output, progress.update)
|
|
135
|
+
finally:
|
|
136
|
+
progress.close()
|
|
137
|
+
print(f"Uploaded {args.input} to sore/{config.username}/{args.output}")
|
|
138
|
+
return 0
|
|
139
|
+
|
|
140
|
+
if args.output:
|
|
141
|
+
parser.error("-o/--output is only valid with -i/--input")
|
|
142
|
+
if not args.force and not _confirm_delete(args.delete):
|
|
143
|
+
print("Delete cancelled.")
|
|
144
|
+
return 0
|
|
145
|
+
delete(config, args.delete)
|
|
146
|
+
print(f"Deleted sore/{config.username}/{args.delete}")
|
|
147
|
+
return 0
|
|
148
|
+
except (OSError, PermissionError, ValueError, ValidationError, StorageError) as exc:
|
|
149
|
+
print(f"sore: {exc}", file=sys.stderr)
|
|
150
|
+
return 1
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
if __name__ == "__main__":
|
|
154
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Secure local configuration storage for the SORE client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import stat
|
|
9
|
+
import tempfile
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from pydantic import AnyHttpUrl, BaseModel, ConfigDict, SecretStr, field_validator
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ClientConfig(BaseModel):
|
|
16
|
+
model_config = ConfigDict(extra="forbid")
|
|
17
|
+
|
|
18
|
+
endpoint: AnyHttpUrl
|
|
19
|
+
username: str
|
|
20
|
+
password: SecretStr
|
|
21
|
+
|
|
22
|
+
@field_validator("username")
|
|
23
|
+
@classmethod
|
|
24
|
+
def normalize_username(cls, value: str) -> str:
|
|
25
|
+
value = value.strip().lower()
|
|
26
|
+
if not re.fullmatch(r"[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38}", value):
|
|
27
|
+
raise ValueError("username is not a valid GitHub/S3 bucket name")
|
|
28
|
+
return value
|
|
29
|
+
|
|
30
|
+
@field_validator("endpoint")
|
|
31
|
+
@classmethod
|
|
32
|
+
def require_secure_endpoint(cls, value: AnyHttpUrl) -> AnyHttpUrl:
|
|
33
|
+
hostname = value.host or ""
|
|
34
|
+
is_loopback = hostname in {
|
|
35
|
+
"localhost",
|
|
36
|
+
"127.0.0.1",
|
|
37
|
+
"::1",
|
|
38
|
+
} or hostname.endswith(".localhost")
|
|
39
|
+
if value.scheme != "https" and not is_loopback:
|
|
40
|
+
raise ValueError("endpoint must use HTTPS except on localhost")
|
|
41
|
+
return value
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def config_path() -> Path:
|
|
45
|
+
base = os.environ.get("XDG_CONFIG_HOME")
|
|
46
|
+
return (Path(base) if base else Path.home() / ".config") / "sore.json"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def save_config(config: ClientConfig, path: Path | None = None) -> Path:
|
|
50
|
+
destination = path or config_path()
|
|
51
|
+
destination.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
52
|
+
if destination.is_symlink():
|
|
53
|
+
raise ValueError(f"refusing to overwrite symlink: {destination}")
|
|
54
|
+
payload = json.dumps(
|
|
55
|
+
{
|
|
56
|
+
"endpoint": str(config.endpoint).rstrip("/"),
|
|
57
|
+
"username": config.username,
|
|
58
|
+
"password": config.password.get_secret_value(),
|
|
59
|
+
},
|
|
60
|
+
indent=2,
|
|
61
|
+
)
|
|
62
|
+
descriptor, temporary_name = tempfile.mkstemp(
|
|
63
|
+
prefix=".sore-", suffix=".tmp", dir=destination.parent, text=True
|
|
64
|
+
)
|
|
65
|
+
temporary = Path(temporary_name)
|
|
66
|
+
try:
|
|
67
|
+
os.fchmod(descriptor, 0o600)
|
|
68
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
69
|
+
handle.write(payload)
|
|
70
|
+
handle.write("\n")
|
|
71
|
+
handle.flush()
|
|
72
|
+
os.fsync(handle.fileno())
|
|
73
|
+
os.replace(temporary, destination)
|
|
74
|
+
destination.chmod(0o600)
|
|
75
|
+
finally:
|
|
76
|
+
temporary.unlink(missing_ok=True)
|
|
77
|
+
return destination
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def load_config(path: Path | None = None) -> ClientConfig:
|
|
81
|
+
source = path or config_path()
|
|
82
|
+
if source.is_symlink():
|
|
83
|
+
raise ValueError(f"refusing to read symlink: {source}")
|
|
84
|
+
mode = stat.S_IMODE(source.stat().st_mode)
|
|
85
|
+
if mode & 0o077:
|
|
86
|
+
raise PermissionError(
|
|
87
|
+
f"{source} has unsafe permissions {mode:o}; run chmod 600 {source}"
|
|
88
|
+
)
|
|
89
|
+
return ClientConfig.model_validate_json(source.read_text(encoding="utf-8"))
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""S3 operations used by the command-line client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
8
|
+
from math import ceil
|
|
9
|
+
from pathlib import Path, PurePosixPath
|
|
10
|
+
|
|
11
|
+
import boto3
|
|
12
|
+
from botocore.config import Config
|
|
13
|
+
from botocore.exceptions import BotoCoreError, ClientError
|
|
14
|
+
|
|
15
|
+
from soreplicator.client.config import ClientConfig
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class StorageError(RuntimeError):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
ProgressCallback = Callable[[int, int], None]
|
|
23
|
+
|
|
24
|
+
MIB = 1024 * 1024
|
|
25
|
+
MULTIPART_THRESHOLD = 16 * MIB
|
|
26
|
+
DEFAULT_PART_SIZE = 8 * MIB
|
|
27
|
+
MIN_PART_SIZE = 5 * MIB
|
|
28
|
+
MAX_PARTS = 10_000
|
|
29
|
+
MAX_CONCURRENCY = 4
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def normalize_key(value: str) -> str:
|
|
33
|
+
if (
|
|
34
|
+
not value
|
|
35
|
+
or value.startswith("/")
|
|
36
|
+
or "\\" in value
|
|
37
|
+
or any(ord(character) < 32 or ord(character) == 127 for character in value)
|
|
38
|
+
):
|
|
39
|
+
raise ValueError("remote path must be a non-empty relative POSIX path")
|
|
40
|
+
if any(part in {"", ".", ".."} for part in value.split("/")):
|
|
41
|
+
raise ValueError("remote path cannot contain empty, '.' or '..' components")
|
|
42
|
+
path = PurePosixPath(value)
|
|
43
|
+
normalized = str(path)
|
|
44
|
+
if len(normalized.encode("utf-8")) > 1024:
|
|
45
|
+
raise ValueError("remote path is longer than S3's 1024-byte limit")
|
|
46
|
+
return normalized
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _client(config: ClientConfig):
|
|
50
|
+
return boto3.client(
|
|
51
|
+
"s3",
|
|
52
|
+
endpoint_url=str(config.endpoint).rstrip("/"),
|
|
53
|
+
aws_access_key_id=config.username,
|
|
54
|
+
aws_secret_access_key=config.password.get_secret_value(),
|
|
55
|
+
config=Config(
|
|
56
|
+
signature_version="s3v4",
|
|
57
|
+
s3={"addressing_style": "path"},
|
|
58
|
+
retries={"mode": "standard", "max_attempts": 4},
|
|
59
|
+
),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _is_conflict(exc: ClientError) -> bool:
|
|
64
|
+
code = exc.response.get("Error", {}).get("Code", "")
|
|
65
|
+
status = exc.response.get("ResponseMetadata", {}).get("HTTPStatusCode")
|
|
66
|
+
return code in {"PreconditionFailed", "ConditionalRequestConflict"} or status in {
|
|
67
|
+
409,
|
|
68
|
+
412,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _is_missing(exc: ClientError) -> bool:
|
|
73
|
+
code = exc.response.get("Error", {}).get("Code", "")
|
|
74
|
+
status = exc.response.get("ResponseMetadata", {}).get("HTTPStatusCode")
|
|
75
|
+
return code in {"404", "NoSuchKey", "NotFound"} or status == 404
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _part_size(file_size: int) -> int:
|
|
79
|
+
required = ceil(file_size / MAX_PARTS)
|
|
80
|
+
target = max(DEFAULT_PART_SIZE, MIN_PART_SIZE, required)
|
|
81
|
+
return ceil(target / MIB) * MIB
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _upload_part(
|
|
85
|
+
client,
|
|
86
|
+
source: Path,
|
|
87
|
+
bucket: str,
|
|
88
|
+
key: str,
|
|
89
|
+
upload_id: str,
|
|
90
|
+
part_number: int,
|
|
91
|
+
offset: int,
|
|
92
|
+
length: int,
|
|
93
|
+
) -> tuple[int, str, int]:
|
|
94
|
+
with source.open("rb") as handle:
|
|
95
|
+
handle.seek(offset)
|
|
96
|
+
body = handle.read(length)
|
|
97
|
+
if len(body) != length:
|
|
98
|
+
raise StorageError(f"input changed while it was being uploaded: {source}")
|
|
99
|
+
response = client.upload_part(
|
|
100
|
+
Bucket=bucket,
|
|
101
|
+
Key=key,
|
|
102
|
+
UploadId=upload_id,
|
|
103
|
+
PartNumber=part_number,
|
|
104
|
+
Body=body,
|
|
105
|
+
ContentLength=length,
|
|
106
|
+
)
|
|
107
|
+
return part_number, response["ETag"], length
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _multipart_upload(
|
|
111
|
+
client,
|
|
112
|
+
source: Path,
|
|
113
|
+
bucket: str,
|
|
114
|
+
key: str,
|
|
115
|
+
file_size: int,
|
|
116
|
+
progress: ProgressCallback | None,
|
|
117
|
+
) -> None:
|
|
118
|
+
upload_id: str | None = None
|
|
119
|
+
completed = False
|
|
120
|
+
try:
|
|
121
|
+
upload_id = client.create_multipart_upload(Bucket=bucket, Key=key)["UploadId"]
|
|
122
|
+
part_size = _part_size(file_size)
|
|
123
|
+
part_count = ceil(file_size / part_size)
|
|
124
|
+
uploaded = 0
|
|
125
|
+
parts: list[dict[str, int | str]] = []
|
|
126
|
+
workers = min(MAX_CONCURRENCY, part_count)
|
|
127
|
+
with ThreadPoolExecutor(max_workers=workers) as executor:
|
|
128
|
+
futures = []
|
|
129
|
+
for index in range(part_count):
|
|
130
|
+
offset = index * part_size
|
|
131
|
+
length = min(part_size, file_size - offset)
|
|
132
|
+
futures.append(
|
|
133
|
+
executor.submit(
|
|
134
|
+
_upload_part,
|
|
135
|
+
client,
|
|
136
|
+
source,
|
|
137
|
+
bucket,
|
|
138
|
+
key,
|
|
139
|
+
upload_id,
|
|
140
|
+
index + 1,
|
|
141
|
+
offset,
|
|
142
|
+
length,
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
try:
|
|
146
|
+
for future in as_completed(futures):
|
|
147
|
+
part_number, etag, length = future.result()
|
|
148
|
+
parts.append({"PartNumber": part_number, "ETag": etag})
|
|
149
|
+
uploaded += length
|
|
150
|
+
if progress:
|
|
151
|
+
progress(uploaded, file_size)
|
|
152
|
+
except Exception:
|
|
153
|
+
for future in futures:
|
|
154
|
+
future.cancel()
|
|
155
|
+
raise
|
|
156
|
+
|
|
157
|
+
parts.sort(key=lambda part: int(part["PartNumber"]))
|
|
158
|
+
client.complete_multipart_upload(
|
|
159
|
+
Bucket=bucket,
|
|
160
|
+
Key=key,
|
|
161
|
+
UploadId=upload_id,
|
|
162
|
+
MultipartUpload={"Parts": parts},
|
|
163
|
+
IfNoneMatch="*",
|
|
164
|
+
)
|
|
165
|
+
completed = True
|
|
166
|
+
finally:
|
|
167
|
+
if upload_id and not completed:
|
|
168
|
+
try:
|
|
169
|
+
client.abort_multipart_upload(
|
|
170
|
+
Bucket=bucket, Key=key, UploadId=upload_id
|
|
171
|
+
)
|
|
172
|
+
except (BotoCoreError, ClientError):
|
|
173
|
+
pass
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def upload(
|
|
177
|
+
config: ClientConfig,
|
|
178
|
+
source: Path,
|
|
179
|
+
destination: str,
|
|
180
|
+
progress: ProgressCallback | None = None,
|
|
181
|
+
) -> None:
|
|
182
|
+
if not source.is_file():
|
|
183
|
+
raise ValueError(f"input is not a regular file: {source}")
|
|
184
|
+
key = normalize_key(destination)
|
|
185
|
+
file_size = source.stat().st_size
|
|
186
|
+
client = _client(config)
|
|
187
|
+
try:
|
|
188
|
+
if file_size >= MULTIPART_THRESHOLD:
|
|
189
|
+
_multipart_upload(client, source, config.username, key, file_size, progress)
|
|
190
|
+
else:
|
|
191
|
+
with source.open("rb") as handle:
|
|
192
|
+
client.put_object(
|
|
193
|
+
Bucket=config.username,
|
|
194
|
+
Key=key,
|
|
195
|
+
Body=handle,
|
|
196
|
+
ContentLength=os.fstat(handle.fileno()).st_size,
|
|
197
|
+
IfNoneMatch="*",
|
|
198
|
+
)
|
|
199
|
+
if progress:
|
|
200
|
+
progress(file_size, file_size)
|
|
201
|
+
except ClientError as exc:
|
|
202
|
+
code = exc.response.get("Error", {}).get("Code", "")
|
|
203
|
+
detail = exc.response.get("Error", {}).get("Message", "")
|
|
204
|
+
if _is_conflict(exc):
|
|
205
|
+
raise StorageError(
|
|
206
|
+
f"{key} already exists; SORE never overwrites objects"
|
|
207
|
+
) from exc
|
|
208
|
+
raise StorageError(f"upload failed ({code or 'gateway error'}, {detail or 'no detail'})") from exc
|
|
209
|
+
except BotoCoreError as exc:
|
|
210
|
+
raise StorageError("upload failed because the gateway is unavailable") from exc
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def delete(config: ClientConfig, destination: str) -> None:
|
|
214
|
+
key = normalize_key(destination)
|
|
215
|
+
client = _client(config)
|
|
216
|
+
try:
|
|
217
|
+
client.head_object(Bucket=config.username, Key=key)
|
|
218
|
+
client.delete_object(Bucket=config.username, Key=key)
|
|
219
|
+
except ClientError as exc:
|
|
220
|
+
if _is_missing(exc):
|
|
221
|
+
raise StorageError(f"{key} does not exist") from exc
|
|
222
|
+
code = exc.response.get("Error", {}).get("Code", "gateway error")
|
|
223
|
+
raise StorageError(f"delete failed ({code})") from exc
|
|
224
|
+
except BotoCoreError as exc:
|
|
225
|
+
raise StorageError("delete failed because the gateway is unavailable") from exc
|