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 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
+ [![Documentation Status](https://readthedocs.org/projects/sobe/badge/?version=latest)](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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.9.8
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ sobe = sobe.main:main
3
+