Skip to content

Commit cb55329

Browse files
authored
feat: add --strict option to fail on exising files (gorilla-co#113)
* feat: require python 3.8 or greater * refactor: move arguments to config dataclasses * feat: add --strict option to fail on exising files * feat: install mypy and add type annotations * chore: bump version to 1.2.0 * test: add test case without --strict
1 parent 69bc668 commit cb55329

File tree

13 files changed

+1199
-619
lines changed

13 files changed

+1199
-619
lines changed

.github/workflows/tests.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ jobs:
77
runs-on: ubuntu-latest
88
strategy:
99
matrix:
10-
python-version: ['3.7', '3.8', '3.9', '3.10', '3.11']
10+
python-version: ['3.8', '3.9', '3.10', '3.11', '3.12']
1111

1212
steps:
1313
- uses: actions/checkout@v2

CHANGELOG.md

+11
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [PEP 440](https://www.python.org/dev/peps/pep-0440/).
77

88

9+
## 1.2.0 - 2023-12-30
10+
11+
### Added
12+
13+
- `--strict` option to fail when trying to upload existing files.
14+
15+
### Changed
16+
17+
- Require Python 3.8 or greater.
18+
19+
920
## 1.1.1 - 2023-02-20
1021

1122
### Fixed

poetry.lock

+1,006-497
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

+13-22
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "s3pypi"
3-
version = "1.1.1"
3+
version = "1.2.0"
44
description = "CLI for creating a Python Package Repository in an S3 bucket"
55
authors = [
66
"Matteo De Wint <matteo@gorilla.co>",
@@ -11,28 +11,19 @@ authors = [
1111
s3pypi = "s3pypi.__main__:main"
1212

1313
[tool.poetry.dependencies]
14-
boto3 = "^1.26.32"
15-
python = "^3.7"
14+
boto3 = "^1.34.11"
15+
boto3-stubs = {extras = ["s3"], version = "^1.34.11"}
16+
python = "^3.8"
1617

17-
[tool.poetry.dev-dependencies]
18-
black = "^22.12.0"
19-
flake8 = "^5.0.0"
20-
isort = "^5.11.3"
21-
moto = "^4.0.12"
22-
pytest = "^7.2.0"
23-
pytest-cov = "^4.0.0"
24-
25-
[tool.black]
26-
exclude = '''
27-
\.eggs
28-
| \.git
29-
| \.mypy_cache
30-
| \.tox
31-
| \.venv
32-
| _build
33-
| build
34-
| dist
35-
'''
18+
[tool.poetry.group.dev.dependencies]
19+
black = "^23.12.1"
20+
bump2version = "^1.0.1"
21+
flake8 = "^5.0.4"
22+
isort = "^5.13.2"
23+
moto = "^4.2.12"
24+
mypy = "^1.8.0"
25+
pytest = "^7.4.3"
26+
pytest-cov = "^4.1.0"
3627

3728
[build-system]
3829
requires = ["poetry>=0.12"]

s3pypi/__init__.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
__prog__ = "s3pypi"
2-
__version__ = "1.1.1"
2+
__version__ = "1.2.0"

s3pypi/__main__.py

+39-8
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
from __future__ import print_function
22

3-
import argparse
43
import logging
54
import sys
5+
from argparse import ArgumentParser
66
from pathlib import Path
77
from typing import Dict
88

@@ -16,8 +16,8 @@ def string_dict(text: str) -> Dict[str, str]:
1616
return dict(tuple(item.strip().split("=", 1)) for item in text.split(",")) # type: ignore
1717

1818

19-
def get_arg_parser():
20-
p = argparse.ArgumentParser(prog=__prog__)
19+
def build_arg_parser() -> ArgumentParser:
20+
p = ArgumentParser(prog=__prog__)
2121
p.add_argument(
2222
"dist",
2323
nargs="+",
@@ -33,6 +33,7 @@ def get_arg_parser():
3333
p.add_argument(
3434
"--s3-put-args",
3535
type=string_dict,
36+
default={},
3637
help=(
3738
"Optional extra arguments to S3 PutObject calls. Example: "
3839
"'ServerSideEncryption=aws:kms,SSEKMSKeyId=1234...'"
@@ -67,18 +68,48 @@ def get_arg_parser():
6768
action="store_true",
6869
help="Don't use authentication when communicating with S3.",
6970
)
70-
p.add_argument("-f", "--force", action="store_true", help="Overwrite files.")
71+
72+
g = p.add_mutually_exclusive_group()
73+
g.add_argument(
74+
"--strict",
75+
action="store_true",
76+
help="Fail when trying to upload existing files.",
77+
)
78+
g.add_argument(
79+
"-f", "--force", action="store_true", help="Overwrite existing files."
80+
)
81+
7182
p.add_argument("-v", "--verbose", action="store_true", help="Verbose output.")
7283
p.add_argument("-V", "--version", action="version", version=__version__)
7384
return p
7485

7586

76-
def main(*args):
77-
kwargs = vars(get_arg_parser().parse_args(args or sys.argv[1:]))
78-
log.setLevel(logging.DEBUG if kwargs.pop("verbose") else logging.INFO)
87+
def main(*raw_args: str) -> None:
88+
args = build_arg_parser().parse_args(raw_args or sys.argv[1:])
89+
log.setLevel(logging.DEBUG if args.verbose else logging.INFO)
90+
91+
cfg = core.Config(
92+
dist=args.dist,
93+
s3=core.S3Config(
94+
bucket=args.bucket,
95+
prefix=args.prefix,
96+
endpoint_url=args.s3_endpoint_url,
97+
put_kwargs=args.s3_put_args,
98+
unsafe_s3_website=args.unsafe_s3_website,
99+
no_sign_request=args.no_sign_request,
100+
),
101+
strict=args.strict,
102+
force=args.force,
103+
lock_indexes=args.lock_indexes,
104+
put_root_index=args.put_root_index,
105+
profile=args.profile,
106+
region=args.region,
107+
)
108+
if args.acl:
109+
cfg.s3.put_kwargs["ACL"] = args.acl
79110

80111
try:
81-
core.upload_packages(**kwargs)
112+
core.upload_packages(cfg)
82113
except core.S3PyPiError as e:
83114
sys.exit(f"ERROR: {e}")
84115

s3pypi/core.py

+26-18
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,25 @@
1515
from s3pypi.exceptions import S3PyPiError
1616
from s3pypi.index import Hash
1717
from s3pypi.locking import DummyLocker, DynamoDBLocker
18-
from s3pypi.storage import S3Storage
18+
from s3pypi.storage import S3Config, S3Storage
1919

2020
log = logging.getLogger(__prog__)
2121

2222
PackageMetadata = email.message.Message
2323

2424

25+
@dataclass
26+
class Config:
27+
dist: List[Path]
28+
s3: S3Config
29+
strict: bool = False
30+
force: bool = False
31+
lock_indexes: bool = False
32+
put_root_index: bool = False
33+
profile: Optional[str] = None
34+
region: Optional[str] = None
35+
36+
2537
@dataclass
2638
class Distribution:
2739
name: str
@@ -33,26 +45,18 @@ def normalize_package_name(name: str) -> str:
3345
return re.sub(r"[-_.]+", "-", name.lower())
3446

3547

36-
def upload_packages(
37-
dist: List[Path],
38-
bucket: str,
39-
force: bool = False,
40-
lock_indexes: bool = False,
41-
put_root_index: bool = False,
42-
profile: Optional[str] = None,
43-
region: Optional[str] = None,
44-
**kwargs,
45-
):
46-
session = boto3.Session(profile_name=profile, region_name=region)
47-
storage = S3Storage(session, bucket, **kwargs)
48+
def upload_packages(cfg: Config) -> None:
49+
session = boto3.Session(profile_name=cfg.profile, region_name=cfg.region)
50+
storage = S3Storage(session, cfg.s3)
4851
lock = (
49-
DynamoDBLocker(session, table=f"{bucket}-locks")
50-
if lock_indexes
52+
DynamoDBLocker(session, table=f"{cfg.s3.bucket}-locks")
53+
if cfg.lock_indexes
5154
else DummyLocker()
5255
)
5356

54-
distributions = parse_distributions(dist)
57+
distributions = parse_distributions(cfg.dist)
5558
get_name = attrgetter("name")
59+
existing_files = []
5660

5761
for name, group in groupby(sorted(distributions, key=get_name), get_name):
5862
directory = normalize_package_name(name)
@@ -62,7 +66,8 @@ def upload_packages(
6266
for distr in group:
6367
filename = distr.local_path.name
6468

65-
if not force and filename in index.filenames:
69+
if not cfg.force and filename in index.filenames:
70+
existing_files.append(filename)
6671
msg = "%s already exists! (use --force to overwrite)"
6772
log.warning(msg, filename)
6873
else:
@@ -72,11 +77,14 @@ def upload_packages(
7277

7378
storage.put_index(directory, index)
7479

75-
if put_root_index:
80+
if cfg.put_root_index:
7681
with lock(storage.root):
7782
index = storage.build_root_index()
7883
storage.put_index(storage.root, index)
7984

85+
if cfg.strict and existing_files:
86+
raise S3PyPiError(f"Found {len(existing_files)} existing files on S3")
87+
8088

8189
def parse_distribution(path: Path) -> Distribution:
8290
extensions = (".whl", ".tar.gz", ".tar.bz2", ".tar.xz", ".zip")

s3pypi/locking.py

+7-6
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import logging
66
import time
77
from contextlib import contextmanager
8+
from typing import Iterator
89

910
import boto3
1011

@@ -15,7 +16,7 @@
1516

1617
class Locker(abc.ABC):
1718
@contextmanager
18-
def __call__(self, key: str):
19+
def __call__(self, key: str) -> Iterator[None]:
1920
lock_id = hashlib.sha1(key.encode()).hexdigest()
2021
self._lock(lock_id)
2122
try:
@@ -24,16 +25,16 @@ def __call__(self, key: str):
2425
self._unlock(lock_id)
2526

2627
@abc.abstractmethod
27-
def _lock(self, lock_id: str):
28+
def _lock(self, lock_id: str) -> None:
2829
...
2930

3031
@abc.abstractmethod
31-
def _unlock(self, lock_id: str):
32+
def _unlock(self, lock_id: str) -> None:
3233
...
3334

3435

3536
class DummyLocker(Locker):
36-
def _lock(self, lock_id: str):
37+
def _lock(self, lock_id: str) -> None:
3738
pass
3839

3940
_unlock = _lock
@@ -54,7 +55,7 @@ def __init__(
5455
self.max_attempts = max_attempts
5556
self.caller_id = session.client("sts").get_caller_identity()["Arn"]
5657

57-
def _lock(self, lock_id: str):
58+
def _lock(self, lock_id: str) -> None:
5859
for attempt in range(1, self.max_attempts + 1):
5960
now = dt.datetime.now(dt.timezone.utc)
6061
try:
@@ -76,7 +77,7 @@ def _lock(self, lock_id: str):
7677
item = self.table.get_item(Key={"LockID": lock_id})["Item"]
7778
raise DynamoDBLockTimeoutError(self.table.name, item)
7879

79-
def _unlock(self, lock_id: str):
80+
def _unlock(self, lock_id: str) -> None:
8081
self.table.delete_item(Key={"LockID": lock_id})
8182

8283

0 commit comments

Comments
 (0)