sobe 0.1__py3-none-any.whl → 0.2__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.
Potentially problematic release.
This version of sobe might be problematic. Click here for more details.
- sobe/aws.py +72 -0
- sobe/config.py +73 -0
- sobe/main.py +17 -106
- {sobe-0.1.dist-info → sobe-0.2.dist-info}/METADATA +27 -4
- sobe-0.2.dist-info/RECORD +8 -0
- {sobe-0.1.dist-info → sobe-0.2.dist-info}/WHEEL +1 -1
- sobe-0.1.dist-info/RECORD +0 -6
- {sobe-0.1.dist-info → sobe-0.2.dist-info}/entry_points.txt +0 -0
sobe/aws.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import json
|
|
3
|
+
import mimetypes
|
|
4
|
+
import pathlib
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
import boto3
|
|
8
|
+
import botocore.exceptions
|
|
9
|
+
|
|
10
|
+
from .config import AWSConfig
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AWS:
|
|
14
|
+
def __init__(self, config: AWSConfig) -> None:
|
|
15
|
+
self.config = config
|
|
16
|
+
self._session = boto3.Session(**self.config.session)
|
|
17
|
+
self._s3_resource = self._session.resource("s3", **self.config.service)
|
|
18
|
+
self._bucket = self._s3_resource.Bucket(self.config.bucket) # type: ignore[attr-defined]
|
|
19
|
+
self._cloudfront = self._session.client("cloudfront", **self.config.service)
|
|
20
|
+
|
|
21
|
+
def upload(self, year: str, local_path: pathlib.Path) -> None:
|
|
22
|
+
"""Uploads a file."""
|
|
23
|
+
type_guess, _ = mimetypes.guess_type(local_path)
|
|
24
|
+
extra_args = {"ContentType": type_guess or "application/octet-stream"}
|
|
25
|
+
self._bucket.upload_file(str(local_path), f"{year}/{local_path.name}", ExtraArgs=extra_args)
|
|
26
|
+
|
|
27
|
+
def delete(self, year: str, remote_filename: str) -> bool:
|
|
28
|
+
"""Delete a file, if it exists. Returns whether it did."""
|
|
29
|
+
obj = self._bucket.Object(f"{year}/{remote_filename}")
|
|
30
|
+
try:
|
|
31
|
+
obj.load()
|
|
32
|
+
obj.delete()
|
|
33
|
+
return True
|
|
34
|
+
except botocore.exceptions.ClientError as e: # pragma: no cover - network edge
|
|
35
|
+
if e.response.get("Error", {}).get("Code") == "404":
|
|
36
|
+
return False
|
|
37
|
+
raise
|
|
38
|
+
|
|
39
|
+
def invalidate_cache(self):
|
|
40
|
+
"""Create and wait for a full-path CloudFront invalidation. Iterates until completion."""
|
|
41
|
+
ref = datetime.datetime.now().astimezone().isoformat()
|
|
42
|
+
batch = {"Paths": {"Quantity": 1, "Items": ["/*"]}, "CallerReference": ref}
|
|
43
|
+
distribution = self.config.cloudfront
|
|
44
|
+
response = self._cloudfront.create_invalidation(DistributionId=distribution, InvalidationBatch=batch)
|
|
45
|
+
invalidation = response["Invalidation"]["Id"]
|
|
46
|
+
status = "Created"
|
|
47
|
+
while status != "Completed": # pragma: no cover - polling side effect
|
|
48
|
+
yield status
|
|
49
|
+
time.sleep(3)
|
|
50
|
+
response = self._cloudfront.get_invalidation(DistributionId=distribution, Id=invalidation)
|
|
51
|
+
status = response["Invalidation"]["Status"]
|
|
52
|
+
|
|
53
|
+
def generate_needed_permissions(self) -> str:
|
|
54
|
+
"""Return the minimal IAM policy statement required by the tool."""
|
|
55
|
+
try:
|
|
56
|
+
sts = self._session.client("sts", **self.config.service)
|
|
57
|
+
account_id = sts.get_caller_identity()["Account"]
|
|
58
|
+
except botocore.exceptions.ClientError: # pragma: no cover - network edge
|
|
59
|
+
account_id = "YOUR_ACCOUNT_ID"
|
|
60
|
+
|
|
61
|
+
actions = """
|
|
62
|
+
s3:PutObject s3:GetObject s3:ListBucket s3:DeleteObject
|
|
63
|
+
cloudfront:CreateInvalidation cloudfront:GetInvalidation
|
|
64
|
+
""".split()
|
|
65
|
+
resources = [
|
|
66
|
+
f"arn:aws:s3:::{self.config.bucket}",
|
|
67
|
+
f"arn:aws:s3:::{self.config.bucket}/*",
|
|
68
|
+
f"arn:aws:cloudfront::{account_id}:distribution/{self.config.cloudfront}",
|
|
69
|
+
]
|
|
70
|
+
statement = {"Effect": "Allow", "Action": actions, "Resource": resources}
|
|
71
|
+
policy = {"Version": "2012-10-17", "Statement": [statement]}
|
|
72
|
+
return json.dumps(policy, indent=2)
|
sobe/config.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import tomllib
|
|
2
|
+
from typing import Any, NamedTuple, Self
|
|
3
|
+
|
|
4
|
+
from platformdirs import PlatformDirs
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class AWSConfig(NamedTuple):
|
|
8
|
+
bucket: str
|
|
9
|
+
cloudfront: str
|
|
10
|
+
session: dict[str, Any]
|
|
11
|
+
service: dict[str, Any]
|
|
12
|
+
|
|
13
|
+
@classmethod
|
|
14
|
+
def from_dict(cls, raw: dict[str, Any]) -> Self:
|
|
15
|
+
return cls(
|
|
16
|
+
bucket=raw.get("bucket", "example-bucket"),
|
|
17
|
+
cloudfront=raw.get("cloudfront", "E1111111111111"),
|
|
18
|
+
session=raw.get("session", {}),
|
|
19
|
+
service=raw.get("service", {}),
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Config(NamedTuple):
|
|
24
|
+
url: str
|
|
25
|
+
aws: AWSConfig
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
def from_dict(cls, raw: dict[str, Any]) -> Self:
|
|
29
|
+
return cls(
|
|
30
|
+
url=raw.get("url", "https://example.com/"),
|
|
31
|
+
aws=AWSConfig.from_dict(raw.get("aws", {})),
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
DEFAULT_TEMPLATE = """
|
|
36
|
+
# sobe configuration
|
|
37
|
+
|
|
38
|
+
url = "https://example.com/"
|
|
39
|
+
|
|
40
|
+
[aws]
|
|
41
|
+
bucket = "example-bucket"
|
|
42
|
+
cloudfront = "E1111111111111"
|
|
43
|
+
|
|
44
|
+
[aws.session]
|
|
45
|
+
# If you already have AWS CLI set up, don't fill keys here.
|
|
46
|
+
# region_name = "..."
|
|
47
|
+
# profile_name = "..."
|
|
48
|
+
# aws_access_key_id = "..."
|
|
49
|
+
# aws_secret_access_key = "..."
|
|
50
|
+
|
|
51
|
+
[aws.service]
|
|
52
|
+
verify = true
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def load_config() -> Config:
|
|
57
|
+
path = PlatformDirs("sobe", "balbuena.ca").user_config_path / "config.toml"
|
|
58
|
+
if path.exists():
|
|
59
|
+
with path.open("rb") as f:
|
|
60
|
+
payload = tomllib.load(f)
|
|
61
|
+
if payload.get("aws", {}).get("bucket", "example-bucket") != "example-bucket":
|
|
62
|
+
return Config.from_dict(payload)
|
|
63
|
+
|
|
64
|
+
# create default file and exit for user to customize
|
|
65
|
+
defaults = "\n".join(line.strip() for line in DEFAULT_TEMPLATE.lstrip().splitlines())
|
|
66
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
path.write_text(defaults)
|
|
68
|
+
print("Created config file at the path below. You must edit it before use.")
|
|
69
|
+
print(path)
|
|
70
|
+
raise SystemExit(1)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
CONFIG: Config = load_config()
|
sobe/main.py
CHANGED
|
@@ -1,53 +1,15 @@
|
|
|
1
1
|
import argparse
|
|
2
2
|
import datetime
|
|
3
3
|
import functools
|
|
4
|
-
import json
|
|
5
|
-
import mimetypes
|
|
6
4
|
import pathlib
|
|
7
5
|
import sys
|
|
8
|
-
import time
|
|
9
|
-
import tomllib
|
|
10
6
|
import warnings
|
|
11
7
|
|
|
12
|
-
import boto3
|
|
13
|
-
import botocore.exceptions
|
|
14
|
-
import platformdirs
|
|
15
8
|
import urllib3.exceptions
|
|
16
9
|
|
|
10
|
+
from .aws import AWS
|
|
11
|
+
from .config import CONFIG
|
|
17
12
|
|
|
18
|
-
def load_config():
|
|
19
|
-
path = platformdirs.PlatformDirs("sobe", "balbuena.ca").user_config_path / "config.toml"
|
|
20
|
-
if path.exists():
|
|
21
|
-
with path.open("rb") as f:
|
|
22
|
-
payload = tomllib.load(f)
|
|
23
|
-
if payload["bucket"] != "example-bucket":
|
|
24
|
-
return payload
|
|
25
|
-
|
|
26
|
-
defaults = """
|
|
27
|
-
# sobe configuration
|
|
28
|
-
bucket = "example-bucket"
|
|
29
|
-
url = "https://example.com/"
|
|
30
|
-
cloudfront = "E1111111111111"
|
|
31
|
-
|
|
32
|
-
[aws_session]
|
|
33
|
-
# If you already have AWS CLI set up, don't fill keys here.
|
|
34
|
-
# region_name = "..."
|
|
35
|
-
# profile_name = "..."
|
|
36
|
-
# aws_access_key_id = "..."
|
|
37
|
-
# aws_secret_access_key = "..."
|
|
38
|
-
|
|
39
|
-
[aws_client]
|
|
40
|
-
verify = true
|
|
41
|
-
"""
|
|
42
|
-
defaults = "\n".join(line.strip() for line in defaults.lstrip().splitlines())
|
|
43
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
44
|
-
path.write_text(defaults)
|
|
45
|
-
print("Created config file at the path below. You must edit it before use.")
|
|
46
|
-
print(path)
|
|
47
|
-
sys.exit(1)
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
CONFIG = load_config()
|
|
51
13
|
write = functools.partial(print, flush=True, end="")
|
|
52
14
|
print = functools.partial(print, flush=True) # type: ignore
|
|
53
15
|
warnings.filterwarnings("ignore", category=urllib3.exceptions.InsecureRequestWarning)
|
|
@@ -55,53 +17,21 @@ warnings.filterwarnings("ignore", category=urllib3.exceptions.InsecureRequestWar
|
|
|
55
17
|
|
|
56
18
|
def main() -> None:
|
|
57
19
|
args = parse_args()
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
for path
|
|
20
|
+
aws = AWS(CONFIG.aws)
|
|
21
|
+
|
|
22
|
+
for path in args.paths:
|
|
23
|
+
write(f"{CONFIG.url}{args.year}/{path.name} ...")
|
|
61
24
|
if args.delete:
|
|
62
|
-
delete(
|
|
25
|
+
existed = aws.delete(args.year, path.name)
|
|
26
|
+
print("deleted." if existed else "didn't exist.")
|
|
63
27
|
else:
|
|
64
|
-
upload(
|
|
28
|
+
aws.upload(args.year, path)
|
|
29
|
+
print("ok.")
|
|
65
30
|
if args.invalidate:
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
write(f"{CONFIG['url']}{remote_path} ...")
|
|
71
|
-
type_guess, _ = mimetypes.guess_type(path)
|
|
72
|
-
extra_args = {"ContentType": type_guess or "application/octet-stream"}
|
|
73
|
-
bucket.upload_file(str(path), remote_path, ExtraArgs=extra_args)
|
|
74
|
-
print("ok.")
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
def delete(bucket, remote_path: str) -> None:
|
|
78
|
-
write(f"{CONFIG['url']}{remote_path} ...")
|
|
79
|
-
obj = bucket.Object(remote_path)
|
|
80
|
-
try:
|
|
81
|
-
obj.load()
|
|
82
|
-
obj.delete()
|
|
83
|
-
print("deleted.")
|
|
84
|
-
except botocore.exceptions.ClientError as e:
|
|
85
|
-
if e.response["Error"]["Code"] != "404":
|
|
86
|
-
raise
|
|
87
|
-
print("didn't exist.")
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
def invalidate(session: boto3.Session) -> None:
|
|
91
|
-
write("Clearing cache ...")
|
|
92
|
-
ref = datetime.datetime.now().astimezone().isoformat()
|
|
93
|
-
cloudfront = session.client("cloudfront", **CONFIG["aws_client"])
|
|
94
|
-
batch = {"Paths": {"Quantity": 1, "Items": ["/*"]}, "CallerReference": ref}
|
|
95
|
-
invalidation = cloudfront.create_invalidation(DistributionId=CONFIG["cloudfront"], InvalidationBatch=batch)
|
|
96
|
-
write("ok.")
|
|
97
|
-
invalidation_id = invalidation["Invalidation"]["Id"]
|
|
98
|
-
status = ""
|
|
99
|
-
while status != "Completed":
|
|
100
|
-
time.sleep(3)
|
|
101
|
-
write(".")
|
|
102
|
-
response = cloudfront.get_invalidation(DistributionId=CONFIG["cloudfront"], Id=invalidation_id)
|
|
103
|
-
status = response["Invalidation"]["Status"]
|
|
104
|
-
print("complete.")
|
|
31
|
+
write("Clearing cache...")
|
|
32
|
+
for _ in aws.invalidate_cache():
|
|
33
|
+
write(".")
|
|
34
|
+
print("complete.")
|
|
105
35
|
|
|
106
36
|
|
|
107
37
|
def parse_args() -> argparse.Namespace:
|
|
@@ -109,12 +39,13 @@ def parse_args() -> argparse.Namespace:
|
|
|
109
39
|
parser.add_argument("-y", "--year", type=int, default=datetime.date.today().year, help="change year directory")
|
|
110
40
|
parser.add_argument("-i", "--invalidate", action="store_true", help="invalidate CloudFront cache")
|
|
111
41
|
parser.add_argument("-d", "--delete", action="store_true", help="delete instead of upload")
|
|
112
|
-
parser.add_argument("--policy", action="store_true", help="
|
|
42
|
+
parser.add_argument("--policy", action="store_true", help="generate IAM policy requirements and exit")
|
|
113
43
|
parser.add_argument("files", nargs="*", help="Source files.")
|
|
114
44
|
args = parser.parse_args()
|
|
115
45
|
|
|
116
46
|
if args.policy:
|
|
117
|
-
|
|
47
|
+
aws = AWS(CONFIG.aws)
|
|
48
|
+
print(aws.generate_needed_permissions())
|
|
118
49
|
sys.exit(0)
|
|
119
50
|
|
|
120
51
|
if not args.files and not args.invalidate:
|
|
@@ -122,7 +53,6 @@ def parse_args() -> argparse.Namespace:
|
|
|
122
53
|
sys.exit(0)
|
|
123
54
|
|
|
124
55
|
args.paths = [pathlib.Path(p) for p in args.files]
|
|
125
|
-
args.keys = [f"{args.year}/{p.name}" for p in args.paths]
|
|
126
56
|
if not args.delete:
|
|
127
57
|
missing = [p for p in args.paths if not p.exists()]
|
|
128
58
|
if missing:
|
|
@@ -132,22 +62,3 @@ def parse_args() -> argparse.Namespace:
|
|
|
132
62
|
sys.exit(1)
|
|
133
63
|
|
|
134
64
|
return args
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
def dump_policy() -> None:
|
|
138
|
-
session = boto3.Session(**CONFIG["aws_session"])
|
|
139
|
-
sts = session.client("sts", **CONFIG["aws_client"])
|
|
140
|
-
caller = sts.get_caller_identity()["Arn"]
|
|
141
|
-
account_id = caller.split(":")[4]
|
|
142
|
-
actions = """
|
|
143
|
-
s3:PutObject s3:GetObject s3:ListBucket s3:DeleteObject
|
|
144
|
-
cloudfront:CreateInvalidation cloudfront:GetInvalidation
|
|
145
|
-
""".split()
|
|
146
|
-
resources = [
|
|
147
|
-
f"arn:aws:s3:::{CONFIG['bucket']}",
|
|
148
|
-
f"arn:aws:s3:::{CONFIG['bucket']}/*",
|
|
149
|
-
f"arn:aws:cloudfront::{account_id}:distribution/{CONFIG['cloudfront']}",
|
|
150
|
-
]
|
|
151
|
-
statement = {"Effect": "Allow", "Action": actions, "Resource": resources}
|
|
152
|
-
policy = {"Version": "2012-10-17", "Statement": [statement]}
|
|
153
|
-
print(json.dumps(policy, indent=2))
|
|
@@ -1,10 +1,27 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: sobe
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2
|
|
4
4
|
Summary: AWS-based drop box uploader
|
|
5
|
+
Author: Liz Balbuena
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
8
|
+
Classifier: Environment :: Console
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Programming Language :: Python
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Topic :: Communications :: File Sharing
|
|
16
|
+
Classifier: Topic :: Utilities
|
|
5
17
|
Requires-Dist: boto3>=1.40.49
|
|
6
18
|
Requires-Dist: platformdirs>=4.5.0
|
|
7
|
-
Requires-Python:
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Project-URL: Changelog, https://github.com/Liz4v/sobe/releases
|
|
21
|
+
Project-URL: Documentation, https://github.com/Liz4v/sobe/blob/main/README.md
|
|
22
|
+
Project-URL: Homepage, https://github.com/Liz4v/sobe
|
|
23
|
+
Project-URL: Issues, https://github.com/Liz4v/sobe/issues
|
|
24
|
+
Project-URL: Repository, https://github.com/Liz4v/sobe.git
|
|
8
25
|
Description-Content-Type: text/markdown
|
|
9
26
|
|
|
10
27
|
# sobe
|
|
@@ -18,7 +35,13 @@ It will upload any files you give it to your bucket, in a current year subdirect
|
|
|
18
35
|
Use [uv](https://docs.astral.sh/uv/) to manage it.
|
|
19
36
|
|
|
20
37
|
```bash
|
|
21
|
-
uv tool install
|
|
38
|
+
uv tool install sobe
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
If you have Python ≥ 3.11, you can also install it via pip:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install sobe
|
|
22
45
|
```
|
|
23
46
|
|
|
24
47
|
## Configuration
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
sobe/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
sobe/aws.py,sha256=X5r0MTCfSQPhqRMHun0710ecZSV-6eOd_4E38oxBSsY,3172
|
|
3
|
+
sobe/config.py,sha256=a4lc7Rlw5Zs4QGdNRIhB8pscJxBn-ze2WHnzWV4d7ro,1882
|
|
4
|
+
sobe/main.py,sha256=M40CqclSwlCZeJvFi52kiyBIqcQ8Z1C9b7QsWk-tvt4,2079
|
|
5
|
+
sobe-0.2.dist-info/WHEEL,sha256=X16MKk8bp2DRsAuyteHJ-9qOjzmnY0x1aj0P1ftqqWA,78
|
|
6
|
+
sobe-0.2.dist-info/entry_points.txt,sha256=a_cKExqUEzJ-t2MRWbxAHc8OavQIaL8a7JQ3obR2b-c,41
|
|
7
|
+
sobe-0.2.dist-info/METADATA,sha256=vapxWrCS_zDIlXosohc-Px3k48Q_KG7KG-xswlWEqMM,3105
|
|
8
|
+
sobe-0.2.dist-info/RECORD,,
|
sobe-0.1.dist-info/RECORD
DELETED
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
sobe/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
-
sobe/main.py,sha256=3x3wsfo4l_rh7Nr-GEsL3jiqU8jTrYINkojzvKTB1eI,5232
|
|
3
|
-
sobe-0.1.dist-info/WHEEL,sha256=ZbtZh9LqsQoZs-WmwRO6z-tavdkb5LzNxvrOv2F_OXE,78
|
|
4
|
-
sobe-0.1.dist-info/entry_points.txt,sha256=a_cKExqUEzJ-t2MRWbxAHc8OavQIaL8a7JQ3obR2b-c,41
|
|
5
|
-
sobe-0.1.dist-info/METADATA,sha256=pd-fXFiH_aB6x8dr-tav4oUE4vtgE4FSlQ-_1FJ0ZSw,2219
|
|
6
|
-
sobe-0.1.dist-info/RECORD,,
|
|
File without changes
|