Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions CHANGES.rst
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
Changelog
=========

Version 0.3.1 (not released yet)
--------------------------------
Version 0.3.1 (2026-02-09)
--------------------------

Bug fixes:

Expand All @@ -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:

Expand Down Expand Up @@ -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:
Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion LICENSE.rst
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
Copyright (C) 2025 Thomas Waldmann <tw@waldmann-edv.de>
Copyright (C) 2026 Thomas Waldmann <tw@waldmann-edv.de>
All rights reserved.

Redistribution and use in source and binary forms, with or without
Expand Down
75 changes: 47 additions & 28 deletions src/borgstore/backends/s3.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""
BorgStore backend for S3-compatible services (including Backblaze B2) using boto3.
"""

try:
import boto3
from botocore.client import Config
Expand Down Expand Up @@ -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"""
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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("")
Expand All @@ -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}")
Expand Down Expand Up @@ -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):
Expand All @@ -225,24 +239,29 @@ 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):
continue
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}")
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 4 additions & 2 deletions src/borgstore/backends/sftp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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

Expand Down
1 change: 1 addition & 0 deletions tests/test_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down