sobe 0.3.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.
- sobe/__init__.py +0 -0
- sobe/aws.py +108 -0
- sobe/config.py +77 -0
- sobe/main.py +108 -0
- sobe-0.3.0.dist-info/METADATA +86 -0
- sobe-0.3.0.dist-info/RECORD +8 -0
- sobe-0.3.0.dist-info/WHEEL +4 -0
- sobe-0.3.0.dist-info/entry_points.txt +3 -0
sobe/__init__.py
ADDED
|
File without changes
|
sobe/aws.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Everything related to AWS. In the future, we may support other cloud providers."""
|
|
2
|
+
|
|
3
|
+
import datetime
|
|
4
|
+
import json
|
|
5
|
+
import pathlib
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
import boto3
|
|
9
|
+
import botocore.exceptions
|
|
10
|
+
|
|
11
|
+
from sobe.config import AWSConfig
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AWS:
|
|
15
|
+
def __init__(self, config: AWSConfig) -> None:
|
|
16
|
+
self.config = config
|
|
17
|
+
self._session = boto3.Session(**self.config.session)
|
|
18
|
+
self._s3_resource = self._session.resource("s3", **self.config.service)
|
|
19
|
+
self._bucket = self._s3_resource.Bucket(self.config.bucket) # type: ignore[attr-defined]
|
|
20
|
+
self._cloudfront = self._session.client("cloudfront", **self.config.service)
|
|
21
|
+
|
|
22
|
+
def upload(self, prefix: str, local_path: pathlib.Path, *, content_type: str | None = None) -> None:
|
|
23
|
+
"""Upload a file."""
|
|
24
|
+
extra_args = {"ContentType": content_type or guess_content_type(local_path)}
|
|
25
|
+
self._bucket.upload_file(str(local_path), f"{prefix}{local_path.name}", ExtraArgs=extra_args)
|
|
26
|
+
|
|
27
|
+
def delete(self, prefix: str, remote_filename: str) -> bool:
|
|
28
|
+
"""Delete a file, if it exists. Returns whether it did."""
|
|
29
|
+
obj = self._bucket.Object(f"{prefix}{remote_filename}")
|
|
30
|
+
try:
|
|
31
|
+
obj.load()
|
|
32
|
+
obj.delete()
|
|
33
|
+
return True
|
|
34
|
+
except botocore.exceptions.ClientError as e:
|
|
35
|
+
if e.response.get("Error", {}).get("Code") == "404":
|
|
36
|
+
return False
|
|
37
|
+
raise
|
|
38
|
+
|
|
39
|
+
def list(self, prefix: str) -> list[str]:
|
|
40
|
+
"""Return a list of object filenames in the given prefix."""
|
|
41
|
+
objects = self._bucket.objects.filter(Prefix=prefix)
|
|
42
|
+
pos1 = len(prefix)
|
|
43
|
+
results = set()
|
|
44
|
+
for obj in objects:
|
|
45
|
+
if len(obj.key) == pos1:
|
|
46
|
+
continue # skip the prefix entry itself
|
|
47
|
+
pos2 = obj.key.find("/", pos1)
|
|
48
|
+
if pos2 == -1:
|
|
49
|
+
results.add(obj.key[pos1:])
|
|
50
|
+
else:
|
|
51
|
+
results.add(obj.key[pos1:pos2] + "/")
|
|
52
|
+
return sorted(results)
|
|
53
|
+
|
|
54
|
+
def invalidate_cache(self):
|
|
55
|
+
"""Create and wait for a full-path CloudFront invalidation. Iterates until completion."""
|
|
56
|
+
ref = datetime.datetime.now().astimezone().isoformat()
|
|
57
|
+
batch = {"Paths": {"Quantity": 1, "Items": ["/*"]}, "CallerReference": ref}
|
|
58
|
+
distribution = self.config.cloudfront
|
|
59
|
+
response = self._cloudfront.create_invalidation(DistributionId=distribution, InvalidationBatch=batch)
|
|
60
|
+
invalidation = response["Invalidation"]["Id"]
|
|
61
|
+
status = "Created"
|
|
62
|
+
while status != "Completed":
|
|
63
|
+
yield status
|
|
64
|
+
time.sleep(3)
|
|
65
|
+
response = self._cloudfront.get_invalidation(DistributionId=distribution, Id=invalidation)
|
|
66
|
+
status = response["Invalidation"]["Status"]
|
|
67
|
+
|
|
68
|
+
def generate_needed_permissions(self) -> str:
|
|
69
|
+
"""Return the minimal IAM policy statement required by the tool."""
|
|
70
|
+
try:
|
|
71
|
+
sts = self._session.client("sts", **self.config.service)
|
|
72
|
+
account_id = sts.get_caller_identity()["Account"]
|
|
73
|
+
except botocore.exceptions.ClientError:
|
|
74
|
+
account_id = "YOUR_ACCOUNT_ID"
|
|
75
|
+
|
|
76
|
+
actions = """
|
|
77
|
+
s3:PutObject s3:GetObject s3:ListBucket s3:DeleteObject
|
|
78
|
+
cloudfront:CreateInvalidation cloudfront:GetInvalidation
|
|
79
|
+
""".split()
|
|
80
|
+
resources = [
|
|
81
|
+
f"arn:aws:s3:::{self.config.bucket}",
|
|
82
|
+
f"arn:aws:s3:::{self.config.bucket}/*",
|
|
83
|
+
f"arn:aws:cloudfront::{account_id}:distribution/{self.config.cloudfront}",
|
|
84
|
+
]
|
|
85
|
+
statement = {"Effect": "Allow", "Action": actions, "Resource": resources}
|
|
86
|
+
policy = {"Version": "2012-10-17", "Statement": [statement]}
|
|
87
|
+
return json.dumps(policy, indent=2)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def guess_content_type(path: pathlib.Path) -> str:
|
|
91
|
+
"""Return a guessed content type for the given file."""
|
|
92
|
+
import mimetypes
|
|
93
|
+
|
|
94
|
+
# Guess based on filename using standard library
|
|
95
|
+
guess, _ = mimetypes.guess_type(path.name)
|
|
96
|
+
if guess:
|
|
97
|
+
return guess
|
|
98
|
+
|
|
99
|
+
import puremagic
|
|
100
|
+
|
|
101
|
+
# Guess based on file content using puremagic
|
|
102
|
+
for result in puremagic.magic_file(path): # result is ordered by confidence
|
|
103
|
+
guess = getattr(result, "mime_type", None)
|
|
104
|
+
if guess:
|
|
105
|
+
return guess
|
|
106
|
+
|
|
107
|
+
# Fallback
|
|
108
|
+
return "application/octet-stream"
|
sobe/config.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Everything related to user configuration file."""
|
|
2
|
+
|
|
3
|
+
import tomllib
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, NamedTuple, Self
|
|
6
|
+
|
|
7
|
+
from platformdirs import PlatformDirs
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AWSConfig(NamedTuple):
|
|
11
|
+
bucket: str
|
|
12
|
+
cloudfront: str
|
|
13
|
+
session: dict[str, Any]
|
|
14
|
+
service: dict[str, Any]
|
|
15
|
+
|
|
16
|
+
@classmethod
|
|
17
|
+
def from_dict(cls, raw: dict[str, Any]) -> Self:
|
|
18
|
+
return cls(
|
|
19
|
+
bucket=raw.get("bucket", "example-bucket"),
|
|
20
|
+
cloudfront=raw.get("cloudfront", "E1111111111111"),
|
|
21
|
+
session=raw.get("session", {}),
|
|
22
|
+
service=raw.get("service", {}),
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Config(NamedTuple):
|
|
27
|
+
url: str
|
|
28
|
+
aws: AWSConfig
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def from_dict(cls, raw: dict[str, Any]) -> Self:
|
|
32
|
+
return cls(
|
|
33
|
+
url=raw.get("url", "https://example.com/"),
|
|
34
|
+
aws=AWSConfig.from_dict(raw.get("aws", {})),
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class MustEditConfig(Exception):
|
|
39
|
+
"""Config file must be edited before this tool can be used."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, path: Path):
|
|
42
|
+
self.path = path
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
DEFAULT_TEMPLATE = """
|
|
46
|
+
# sobe configuration
|
|
47
|
+
|
|
48
|
+
url = "https://example.com/"
|
|
49
|
+
|
|
50
|
+
[aws]
|
|
51
|
+
bucket = "example-bucket"
|
|
52
|
+
cloudfront = "E1111111111111"
|
|
53
|
+
|
|
54
|
+
[aws.session]
|
|
55
|
+
# If you already have AWS CLI set up, don't fill keys here.
|
|
56
|
+
# region_name = "..."
|
|
57
|
+
# profile_name = "..."
|
|
58
|
+
# aws_access_key_id = "..."
|
|
59
|
+
# aws_secret_access_key = "..."
|
|
60
|
+
|
|
61
|
+
[aws.service]
|
|
62
|
+
# verify = true
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def load_config() -> Config:
|
|
67
|
+
path = PlatformDirs("sobe").user_config_path / "config.toml"
|
|
68
|
+
if path.exists():
|
|
69
|
+
with path.open("rb") as f:
|
|
70
|
+
payload = tomllib.load(f)
|
|
71
|
+
if payload.get("aws", {}).get("bucket", "example-bucket") != "example-bucket":
|
|
72
|
+
return Config.from_dict(payload)
|
|
73
|
+
|
|
74
|
+
# create default file and exit for user to customize
|
|
75
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
76
|
+
path.write_text(DEFAULT_TEMPLATE.lstrip())
|
|
77
|
+
raise MustEditConfig(path)
|
sobe/main.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Command-line interface entry point: input validation and output to user."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import datetime
|
|
5
|
+
import functools
|
|
6
|
+
import pathlib
|
|
7
|
+
import warnings
|
|
8
|
+
|
|
9
|
+
import urllib3.exceptions
|
|
10
|
+
|
|
11
|
+
from sobe.aws import AWS
|
|
12
|
+
from sobe.config import MustEditConfig, load_config
|
|
13
|
+
|
|
14
|
+
write = functools.partial(print, flush=True, end="")
|
|
15
|
+
print = functools.partial(print, flush=True) # type: ignore
|
|
16
|
+
warnings.filterwarnings("ignore", category=urllib3.exceptions.InsecureRequestWarning)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def main() -> None:
|
|
20
|
+
try:
|
|
21
|
+
config = load_config()
|
|
22
|
+
except MustEditConfig as err:
|
|
23
|
+
print("Created config file at the path below. You must edit it before use.")
|
|
24
|
+
print(err.path)
|
|
25
|
+
raise SystemExit(1) from err
|
|
26
|
+
|
|
27
|
+
args = parse_args()
|
|
28
|
+
aws = AWS(config.aws)
|
|
29
|
+
|
|
30
|
+
if args.policy:
|
|
31
|
+
print(aws.generate_needed_permissions())
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
if args.list:
|
|
35
|
+
files = aws.list(args.prefix)
|
|
36
|
+
if not files:
|
|
37
|
+
print(f"No files under {config.url}{args.prefix}")
|
|
38
|
+
return
|
|
39
|
+
for name in files:
|
|
40
|
+
print(f"{config.url}{args.prefix}{name}")
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
for path in args.paths:
|
|
44
|
+
write(f"{config.url}{args.prefix}{path.name} ...")
|
|
45
|
+
if args.delete:
|
|
46
|
+
existed = aws.delete(args.prefix, path.name)
|
|
47
|
+
print("deleted." if existed else "didn't exist.")
|
|
48
|
+
else:
|
|
49
|
+
aws.upload(args.prefix, path, content_type=args.content_type )
|
|
50
|
+
print("ok.")
|
|
51
|
+
if args.invalidate:
|
|
52
|
+
write("Clearing cache...")
|
|
53
|
+
for _ in aws.invalidate_cache():
|
|
54
|
+
write(".")
|
|
55
|
+
print("complete.")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def parse_args(argv=None) -> argparse.Namespace:
|
|
59
|
+
parser = argparse.ArgumentParser(description="Upload files to your AWS drop box.")
|
|
60
|
+
parser.add_argument("-y", "--year", type=str, help="set remote directory (usually a year)")
|
|
61
|
+
parser.add_argument("-t", "--content-type", type=str, help="override detected MIME type for uploaded files")
|
|
62
|
+
parser.add_argument("-l", "--list", action="store_true", help="list all files in the year")
|
|
63
|
+
parser.add_argument("-d", "--delete", action="store_true", help="delete instead of upload")
|
|
64
|
+
parser.add_argument("-i", "--invalidate", action="store_true", help="invalidate CloudFront cache")
|
|
65
|
+
parser.add_argument("-p", "--policy", action="store_true", help="generate IAM policy requirements and exit")
|
|
66
|
+
parser.add_argument("files", nargs="*", help="Source files.")
|
|
67
|
+
args = parser.parse_args(argv)
|
|
68
|
+
num_arg_types = sum(map(bool, args.__dict__.values()))
|
|
69
|
+
|
|
70
|
+
if num_arg_types == 0:
|
|
71
|
+
parser.print_help()
|
|
72
|
+
raise SystemExit(0)
|
|
73
|
+
|
|
74
|
+
if args.policy:
|
|
75
|
+
if num_arg_types != 1:
|
|
76
|
+
parser.error("--policy cannot be used with other arguments")
|
|
77
|
+
return args
|
|
78
|
+
|
|
79
|
+
if args.year is None:
|
|
80
|
+
args.year = str(datetime.date.today().year)
|
|
81
|
+
elif not (args.files or args.list):
|
|
82
|
+
parser.error("--year requires files or --list to be specified")
|
|
83
|
+
args.prefix = args.year if args.year == "" or args.year.endswith("/") else f"{args.year}/"
|
|
84
|
+
|
|
85
|
+
if args.content_type:
|
|
86
|
+
if args.delete or args.list:
|
|
87
|
+
parser.error("--content-type cannot be used with --delete or --list")
|
|
88
|
+
if not args.files:
|
|
89
|
+
parser.error("--content-type requires files to be specified")
|
|
90
|
+
elif args.list:
|
|
91
|
+
if args.delete:
|
|
92
|
+
parser.error("--list and --delete cannot be used at the same time")
|
|
93
|
+
if args.files:
|
|
94
|
+
parser.error("--list does not support file filtering yet")
|
|
95
|
+
elif args.delete:
|
|
96
|
+
if not args.files:
|
|
97
|
+
parser.error("--delete requires files to be specified")
|
|
98
|
+
|
|
99
|
+
args.paths = [pathlib.Path(p) for p in args.files]
|
|
100
|
+
if not (args.delete or args.list):
|
|
101
|
+
missing = [p for p in args.paths if not p.exists()]
|
|
102
|
+
if missing:
|
|
103
|
+
print("The following files do not exist:")
|
|
104
|
+
for p in missing:
|
|
105
|
+
print(f" {p}")
|
|
106
|
+
raise SystemExit(1)
|
|
107
|
+
|
|
108
|
+
return args
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sobe
|
|
3
|
+
Version: 0.3.0
|
|
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
|
|
17
|
+
Requires-Dist: boto3>=1.40.49
|
|
18
|
+
Requires-Dist: platformdirs>=4.5.0
|
|
19
|
+
Requires-Dist: puremagic>=1.30
|
|
20
|
+
Requires-Dist: furo>=2024.8.6 ; extra == 'docs'
|
|
21
|
+
Requires-Dist: sphinx>=7.0.0 ; extra == 'docs'
|
|
22
|
+
Requires-Dist: sphinx-autodoc-typehints>=2.0.0 ; extra == 'docs'
|
|
23
|
+
Requires-Python: >=3.11
|
|
24
|
+
Project-URL: Changelog, https://github.com/Liz4v/sobe/releases
|
|
25
|
+
Project-URL: Documentation, https://github.com/Liz4v/sobe/blob/main/README.md
|
|
26
|
+
Project-URL: Homepage, https://github.com/Liz4v/sobe
|
|
27
|
+
Project-URL: Issues, https://github.com/Liz4v/sobe/issues
|
|
28
|
+
Project-URL: Repository, https://github.com/Liz4v/sobe.git
|
|
29
|
+
Provides-Extra: docs
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# sobe
|
|
33
|
+
|
|
34
|
+
[](https://sobe.readthedocs.io/en/latest/)
|
|
35
|
+
|
|
36
|
+
A simple command-line tool to upload files to an AWS S3 bucket that is publicly available through a CloudFront distribution. This is the traditional "drop box" use case that existed long before the advent of modern file sharing services.
|
|
37
|
+
|
|
38
|
+
Full documentation: https://sobe.readthedocs.io/en/latest/
|
|
39
|
+
|
|
40
|
+
It will upload any files you give it to your bucket, defaulting to a current year directory, because that's the only easy way to organize chaos.
|
|
41
|
+
|
|
42
|
+
"Sobe" is Portuguese for "take it up" (in the imperative), as in "upload".
|
|
43
|
+
|
|
44
|
+
## Installation
|
|
45
|
+
|
|
46
|
+
Use [uv](https://docs.astral.sh/uv/) to manage it.
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
uv tool install sobe
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
If you have Python ≥ 3.11, you can also install it via pip:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pip install sobe
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Configuration
|
|
59
|
+
|
|
60
|
+
On first run, `sobe` will create its config file as appropriate to the platform and tell you its location. You'll need to edit this file with your AWS bucket and CloudFront details.
|
|
61
|
+
|
|
62
|
+
Here's a minimal set up.
|
|
63
|
+
|
|
64
|
+
```toml
|
|
65
|
+
url = "https://example.com/"
|
|
66
|
+
[aws]
|
|
67
|
+
bucket = "your-bucket-name"
|
|
68
|
+
cloudfront = "your-cloudfront-distribution-id"
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
[More information in the docs.](https://sobe.readthedocs.io/en/latest/configuration.html)
|
|
72
|
+
|
|
73
|
+
## Usage
|
|
74
|
+
|
|
75
|
+
The basic example is uploading files to current year directory:
|
|
76
|
+
```bash
|
|
77
|
+
$ sobe file1.jpg file2.pdf
|
|
78
|
+
https://example.com/2025/file1.jpg ...ok.
|
|
79
|
+
https://example.com/2025/file2.pdf ...ok.
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
You can call it with `--help` for all available options. You can list files, delete them, clear the CloudFront cache (cached objects stay for 1 day by default), select a different upload directory. [The documentation contains better examples.](https://sobe.readthedocs.io/en/latest/usage.html#command-line-interface)
|
|
83
|
+
|
|
84
|
+
## License
|
|
85
|
+
|
|
86
|
+
See the [LICENSE](LICENSE) file for details.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
sobe/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
sobe/aws.py,sha256=qHgUOsh5Qu369nt8BjhCMNe248pqCK2zqjpjO0jbscc,4263
|
|
3
|
+
sobe/config.py,sha256=A1T0rMHCZYF2yr_WygkURrv6vh1gOs2nH12OhNgehp4,1916
|
|
4
|
+
sobe/main.py,sha256=BbxykyogqDEifhCXntrOWkxkdTbymlTJG315gWAa8Mw,3949
|
|
5
|
+
sobe-0.3.0.dist-info/WHEEL,sha256=DpNsHFUm_gffZe1FgzmqwuqiuPC6Y-uBCzibcJcdupM,78
|
|
6
|
+
sobe-0.3.0.dist-info/entry_points.txt,sha256=a_cKExqUEzJ-t2MRWbxAHc8OavQIaL8a7JQ3obR2b-c,41
|
|
7
|
+
sobe-0.3.0.dist-info/METADATA,sha256=TO_vHtu2PptBOy7MuWf70jws-AiSQqSh3YpP19DLmGY,3118
|
|
8
|
+
sobe-0.3.0.dist-info/RECORD,,
|