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 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
- session = boto3.Session(**CONFIG["aws_session"])
59
- bucket = session.resource("s3", **CONFIG["aws_client"]).Bucket(CONFIG["bucket"])
60
- for path, key in zip(args.paths, args.keys):
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(bucket, key)
25
+ existed = aws.delete(args.year, path.name)
26
+ print("deleted." if existed else "didn't exist.")
63
27
  else:
64
- upload(bucket, path, key)
28
+ aws.upload(args.year, path)
29
+ print("ok.")
65
30
  if args.invalidate:
66
- invalidate(session)
67
-
68
-
69
- def upload(bucket, path: pathlib.Path, remote_path: str) -> None:
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="display IAM policy requirements and exit")
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
- dump_policy()
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.3
1
+ Metadata-Version: 2.4
2
2
  Name: sobe
3
- Version: 0.1
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: ==3.13.*
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 https://github.com/Liz4v/sobe.git
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,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: uv 0.9.1
2
+ Generator: uv 0.9.2
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
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,,