From 9a6033df2a4a012900caf1782308764be6411d6a Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Mon, 9 Feb 2026 12:50:03 +0100 Subject: [PATCH 1/3] 2026 --- LICENSE.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE.rst b/LICENSE.rst index ba63d8b..edabcf4 100644 --- a/LICENSE.rst +++ b/LICENSE.rst @@ -1,4 +1,4 @@ -Copyright (C) 2025 Thomas Waldmann +Copyright (C) 2026 Thomas Waldmann All rights reserved. Redistribution and use in source and binary forms, with or without From e9ba1e5a8f35acdf1ec30afee20e1d506f24d55a Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Mon, 9 Feb 2026 12:52:18 +0100 Subject: [PATCH 2/3] update CHANGES --- CHANGES.rst | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 261a2ca..e0e8843 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,8 +1,8 @@ Changelog ========= -Version 0.3.1 (not released yet) --------------------------------- +Version 0.3.1 (2026-02-09) +-------------------------- Bug fixes: @@ -11,8 +11,8 @@ Bug fixes: Other changes: - add support for Python 3.14, remove 3.9 -- backends: have separate exceptions for invalid url and dependency missing -- posixfs: better exception msg if not absolute path +- backends: have separate exceptions for invalid URL and dependency missing +- posixfs: better exception message if not absolute path - use SPDX license identifier, require a recent setuptools - CI: @@ -58,7 +58,7 @@ Breaking changes: New features: - new s3/b2 backend that uses the boto3 library, #96 -- posixfs/sftp: create missing parent dirs of the base path +- posixfs/sftp: create missing parent directories of the base path - rclone: add a way to specify the path to the rclone binary for custom installations Bug fixes: @@ -84,7 +84,7 @@ Breaking changes: Other changes: -- sftp/posixfs backends: remove ad-hoc mkdir calls, #46 +- sftp/posixfs backends: remove ad hoc mkdir calls, #46 - optimize Sftp._mkdir, #80 - sftp backend is now optional, avoiding dependency issues on some platforms, #74. Use pip install "borgstore[sftp]" to install with the sftp backend. From 0b56df4600be5fa2f04b5722ba5d7e4f7dd0d676 Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Mon, 9 Feb 2026 12:57:58 +0100 Subject: [PATCH 3/3] blacken --- src/borgstore/backends/s3.py | 75 +++++++++++++++++++++------------- src/borgstore/backends/sftp.py | 6 ++- tests/test_store.py | 1 + 3 files changed, 52 insertions(+), 30 deletions(-) diff --git a/src/borgstore/backends/s3.py b/src/borgstore/backends/s3.py index f29982d..20fdd50 100644 --- a/src/borgstore/backends/s3.py +++ b/src/borgstore/backends/s3.py @@ -1,6 +1,7 @@ """ BorgStore backend for S3-compatible services (including Backblaze B2) using boto3. """ + try: import boto3 from botocore.client import Config @@ -29,7 +30,9 @@ def get_s3_backend(url: str): return None if boto3 is None: - raise BackendDoesNotExist("The S3 backend requires dependencies. Install them with: 'pip install borgstore[s3]'") + raise BackendDoesNotExist( + "The S3 backend requires dependencies. Install them with: 'pip install borgstore[s3]'" + ) # (s3|b2):[profile|(access_key_id:access_key_secret)@][schema://hostname[:port]]/bucket/path s3_regex = r""" @@ -73,18 +76,31 @@ def get_s3_backend(url: str): endpoint_url = f"{schema}://{hostname}" if port: endpoint_url += f":{port}" - return S3(bucket=bucket, path=path, is_b2=s3type == "b2", profile=profile, - access_key_id=access_key_id, access_key_secret=access_key_secret, - endpoint_url=endpoint_url) + return S3( + bucket=bucket, + path=path, + is_b2=s3type == "b2", + profile=profile, + access_key_id=access_key_id, + access_key_secret=access_key_secret, + endpoint_url=endpoint_url, + ) class S3(BackendBase): """BorgStore backend for S3 and Backblaze B2 (via boto3).""" - def __init__(self, bucket: str, path: str, is_b2: bool, profile: Optional[str] = None, - access_key_id: Optional[str] = None, access_key_secret: Optional[str] = None, - endpoint_url: Optional[str] = None): - self.delimiter = '/' + def __init__( + self, + bucket: str, + path: str, + is_b2: bool, + profile: Optional[str] = None, + access_key_id: Optional[str] = None, + access_key_secret: Optional[str] = None, + endpoint_url: Optional[str] = None, + ): + self.delimiter = "/" self.bucket = bucket self.base_path = path.rstrip(self.delimiter) + self.delimiter # Ensure it ends with '/' self.opened = False @@ -96,14 +112,11 @@ def __init__(self, bucket: str, path: str, is_b2: bool, profile: Optional[str] = session = boto3.Session() config = None if is_b2: - config = Config( - request_checksum_calculation="when_required", - response_checksum_validation="when_required", - ) + config = Config(request_checksum_calculation="when_required", response_checksum_validation="when_required") self.s3 = session.client("s3", endpoint_url=endpoint_url, config=config) if is_b2: event_system = self.s3.meta.events - event_system.register_first('before-sign.*.*', self._fix_headers) + event_system.register_first("before-sign.*.*", self._fix_headers) def _fix_headers(self, request, **kwargs): if "x-amz-checksum-crc32" in request.headers: @@ -122,8 +135,9 @@ def create(self): if self.opened: raise BackendMustNotBeOpen() try: - objects = self.s3.list_objects_v2(Bucket=self.bucket, Prefix=self.base_path, - Delimiter=self.delimiter, MaxKeys=1) + objects = self.s3.list_objects_v2( + Bucket=self.bucket, Prefix=self.base_path, Delimiter=self.delimiter, MaxKeys=1 + ) if objects["KeyCount"] > 0: raise BackendAlreadyExists(f"Backend already exists: {self.base_path}") self._mkdir("") @@ -136,18 +150,18 @@ def destroy(self): if self.opened: raise BackendMustNotBeOpen() try: - objects = self.s3.list_objects_v2(Bucket=self.bucket, Prefix=self.base_path, - Delimiter=self.delimiter, MaxKeys=1) + objects = self.s3.list_objects_v2( + Bucket=self.bucket, Prefix=self.base_path, Delimiter=self.delimiter, MaxKeys=1 + ) if objects["KeyCount"] == 0: raise BackendDoesNotExist(f"Backend does not exist: {self.base_path}") is_truncated = True while is_truncated: objects = self.s3.list_objects_v2(Bucket=self.bucket, Prefix=self.base_path, MaxKeys=1000) - is_truncated = objects['IsTruncated'] + is_truncated = objects["IsTruncated"] if "Contents" in objects: self.s3.delete_objects( - Bucket=self.bucket, - Delete={"Objects": [{"Key": obj["Key"]} for obj in objects["Contents"]]} + Bucket=self.bucket, Delete={"Objects": [{"Key": obj["Key"]} for obj in objects["Contents"]]} ) except self.s3.exceptions.ClientError as e: raise BackendError(f"S3 error: {e}") @@ -203,7 +217,7 @@ def delete(self, name): except self.s3.exceptions.NoSuchKey: raise ObjectNotFound(name) except self.s3.exceptions.ClientError as e: - if e.response['Error']['Code'] == '404': + if e.response["Error"]["Code"] == "404": raise ObjectNotFound(name) def move(self, curr_name, new_name): @@ -225,16 +239,21 @@ def list(self, name): validate_name(name) base_prefix = (self.base_path + name).rstrip(self.delimiter) + self.delimiter try: - start_after = '' + start_after = "" is_truncated = True while is_truncated: - objects = self.s3.list_objects_v2(Bucket=self.bucket, Prefix=base_prefix, - Delimiter=self.delimiter, MaxKeys=1000, StartAfter=start_after) - if objects['KeyCount'] == 0: + objects = self.s3.list_objects_v2( + Bucket=self.bucket, + Prefix=base_prefix, + Delimiter=self.delimiter, + MaxKeys=1000, + StartAfter=start_after, + ) + if objects["KeyCount"] == 0: raise ObjectNotFound(name) is_truncated = objects["IsTruncated"] for obj in objects.get("Contents", []): - obj_name = obj["Key"][len(base_prefix):] # Remove base_path prefix + obj_name = obj["Key"][len(base_prefix) :] # Remove base_path prefix if obj_name == "": continue if obj_name.endswith(TMP_SUFFIX): @@ -242,7 +261,7 @@ def list(self, name): start_after = obj["Key"] yield ItemInfo(name=obj_name, exists=True, size=obj["Size"], directory=False) for prefix in objects.get("CommonPrefixes", []): - dir_name = prefix["Prefix"][len(base_prefix):-1] # Remove base_path prefix and trailing slash + dir_name = prefix["Prefix"][len(base_prefix) : -1] # Remove base_path prefix and trailing slash yield ItemInfo(name=dir_name, exists=True, size=0, directory=True) except self.s3.exceptions.ClientError as e: raise BackendError(f"S3 error: {e}") @@ -272,7 +291,7 @@ def info(self, name): obj = self.s3.head_object(Bucket=self.bucket, Key=key) return ItemInfo(name=name, exists=True, directory=False, size=obj["ContentLength"]) except self.s3.exceptions.ClientError as e: - if e.response['Error']['Code'] == '404': + if e.response["Error"]["Code"] == "404": try: self.s3.head_object(Bucket=self.bucket, Key=key + self.delimiter) return ItemInfo(name=name, exists=True, directory=True, size=0) diff --git a/src/borgstore/backends/sftp.py b/src/borgstore/backends/sftp.py index 99c1ab4..76d31d3 100644 --- a/src/borgstore/backends/sftp.py +++ b/src/borgstore/backends/sftp.py @@ -26,8 +26,9 @@ def get_sftp_backend(url): return None if paramiko is None: - raise BackendDoesNotExist("The SFTP backend requires dependencies. Install them with: 'pip install borgstore[sftp]'") - + raise BackendDoesNotExist( + "The SFTP backend requires dependencies. Install them with: 'pip install borgstore[sftp]'" + ) # sftp://username@hostname:22/path # Notes: @@ -49,6 +50,7 @@ def get_sftp_backend(url): class Sftp(BackendBase): """BorgStore backend for SFTP.""" + # Sftp implementation supports precreate = True as well as = False. precreate_dirs: bool = False diff --git a/tests/test_store.py b/tests/test_store.py index 397862a..a356131 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -45,6 +45,7 @@ def rclone_store_created(): finally: store.destroy() + @pytest.fixture() def s3_store_created(): store = Store(backend=get_s3_test_backend(), levels=LEVELS_CONFIG)