Skip to content

Commit c50ad1e

Browse files
committed
feat(dagster-backblaze): add Backblaze B2 library
Signed-off-by: Gonzalo Peña-Castellanos <goanpeca@gmail.com>
1 parent 5b11a97 commit c50ad1e

11 files changed

Lines changed: 2147 additions & 7 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
install:
2+
uv sync
3+
4+
build:
5+
uv build
6+
7+
test:
8+
uv run pytest
9+
10+
ruff:
11+
uv run ruff check --fix
12+
uv run ruff format
13+
14+
check:
15+
uv run ty check .
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# dagster-backblaze
2+
3+
A Dagster integration for [Backblaze B2](https://www.backblaze.com/cloud-storage) object storage.
4+
5+
`B2Resource` is a typed Dagster resource for Backblaze B2's S3-compatible API. It builds on `dagster_aws.s3.S3Resource`, pre-fills the B2 endpoint, and accepts B2-native credential field names so you do not have to remember the endpoint URL or the AWS-named credential fields.
6+
7+
Backblaze B2 speaks the same S3 API as AWS S3, so under the hood `B2Resource` hands you a regular boto3 S3 client pointed at your B2 region. Anything you can do with a boto3 S3 client works against B2.
8+
9+
## Installation
10+
11+
```sh
12+
uv add dagster-backblaze
13+
```
14+
15+
## Usage
16+
17+
```python
18+
from dagster import Definitions, asset
19+
from dagster_backblaze import B2Resource
20+
21+
22+
@asset
23+
def my_asset(b2: B2Resource):
24+
client = b2.get_client()
25+
client.put_object(Bucket="my-bucket", Key="hello", Body=b"world")
26+
27+
28+
defs = Definitions(
29+
assets=[my_asset],
30+
resources={
31+
"b2": B2Resource(
32+
application_key_id="...",
33+
application_key="...",
34+
)
35+
},
36+
)
37+
```
38+
39+
### Configuration
40+
41+
| Field | Description |
42+
| --- | --- |
43+
| `application_key_id` | Backblaze B2 application key ID. Mapped onto the boto3 `aws_access_key_id`. |
44+
| `application_key` | Backblaze B2 application key. Mapped onto the boto3 `aws_secret_access_key`. |
45+
| `endpoint_url` | B2 S3-compatible endpoint. Defaults to `https://s3.us-west-004.backblazeb2.com`. Override to target a different B2 region. |
46+
47+
Credentials and endpoint can also be supplied through environment variables, which the resource reads when the matching field is not set:
48+
49+
- `B2_APPLICATION_KEY_ID`
50+
- `B2_APPLICATION_KEY`
51+
- `B2_ENDPOINT_URL`
52+
53+
Explicit constructor values always take precedence over environment variables. Because `B2Resource` subclasses `S3Resource`, every field on `S3Resource` (such as `region_name`, `use_ssl`, and `max_attempts`) is available as well.
54+
55+
### Targeting a different region
56+
57+
```python
58+
B2Resource(
59+
application_key_id="...",
60+
application_key="...",
61+
endpoint_url="https://s3.eu-central-003.backblazeb2.com",
62+
)
63+
```
64+
65+
### Using AWS S3 or another S3-compatible target
66+
67+
`B2Resource` defaults to B2 but is a thin layer over `S3Resource`. If you already use `dagster-aws`, you can point `dagster_aws.s3.S3Resource` at B2 directly by setting `endpoint_url` to your B2 endpoint. `B2Resource` simply gives you that path with typed B2 config and B2-flavored field names.
68+
69+
## Test
70+
71+
```sh
72+
make test
73+
```
74+
75+
## Build
76+
77+
```sh
78+
make build
79+
```
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from dagster._core.libraries import DagsterLibraryRegistry
2+
3+
from dagster_backblaze.b2.resources import B2Resource
4+
5+
__version__ = "0.0.1"
6+
7+
DagsterLibraryRegistry.register(
8+
"dagster-backblaze", __version__, is_dagster_package=False
9+
)
10+
11+
__all__ = ["B2Resource"]

libraries/dagster-backblaze/dagster_backblaze/b2/__init__.py

Whitespace-only changes.
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
"""Backblaze B2 resources for Dagster.
2+
3+
`B2Resource` is a thin Pydantic wrapper around `dagster_aws.s3.S3Resource` that:
4+
5+
- defaults `endpoint_url` to the Backblaze B2 S3-compatible endpoint
6+
- accepts B2-native field names (`application_key_id`, `application_key`) as
7+
aliases for the underlying boto3 `aws_access_key_id` / `aws_secret_access_key`
8+
9+
Backblaze B2 is fully S3-compatible, so under the hood this resource produces a
10+
boto3 S3 client equivalent to the one produced by ``S3Resource``. The wrapper
11+
exists so users get typed B2 config and B2-flavored field names without having
12+
to remember the endpoint URL or the AWS-named credential fields.
13+
"""
14+
15+
import os
16+
from importlib.metadata import PackageNotFoundError, version
17+
from typing import Any
18+
19+
import boto3
20+
from botocore.config import Config
21+
from botocore.handlers import disable_signing
22+
from dagster_aws.s3.resources import S3Resource
23+
from dagster_aws.utils import construct_boto_client_retry_config
24+
from pydantic import Field, model_validator
25+
26+
DEFAULT_B2_ENDPOINT_URL = "https://s3.us-west-004.backblazeb2.com"
27+
28+
# Backblaze publishes application keys under these env vars; honored as a
29+
# fallback so users following the documented B2 env-var convention don't
30+
# have to set AWS-flavored env vars on a non-AWS deployment.
31+
B2_APPLICATION_KEY_ID_ENV_VAR = "B2_APPLICATION_KEY_ID"
32+
B2_APPLICATION_KEY_ENV_VAR = "B2_APPLICATION_KEY"
33+
B2_ENDPOINT_URL_ENV_VAR = "B2_ENDPOINT_URL"
34+
35+
36+
def _user_agent_suffix() -> str:
37+
try:
38+
pkg_version = version("dagster-backblaze")
39+
except PackageNotFoundError:
40+
pkg_version = "dev"
41+
return f"dagster-backblaze/{pkg_version}"
42+
43+
44+
class B2Resource(S3Resource):
45+
"""Resource that gives access to Backblaze B2 (S3-compatible) storage.
46+
47+
Subclasses :py:class:`dagster_aws.s3.S3Resource` and pre-fills `endpoint_url`
48+
with the Backblaze B2 default. Accepts B2-native credential field names
49+
(`application_key_id`, `application_key`) which map to the underlying
50+
`aws_access_key_id` / `aws_secret_access_key` boto3 parameters.
51+
52+
Example:
53+
.. code-block:: python
54+
55+
from dagster import Definitions, asset
56+
from dagster_backblaze import B2Resource
57+
58+
@asset
59+
def my_asset(b2: B2Resource):
60+
client = b2.get_client()
61+
client.put_object(Bucket="my-bucket", Key="hello", Body=b"world")
62+
63+
Definitions(
64+
assets=[my_asset],
65+
resources={
66+
"b2": B2Resource(
67+
application_key_id="...",
68+
application_key="...",
69+
)
70+
},
71+
)
72+
73+
The default endpoint is ``https://s3.us-west-004.backblazeb2.com``. Override
74+
`endpoint_url` to point at a different B2 region (for example
75+
``https://s3.eu-central-003.backblazeb2.com``).
76+
"""
77+
78+
endpoint_url: str | None = Field(
79+
default=DEFAULT_B2_ENDPOINT_URL,
80+
description=(
81+
"Backblaze B2 S3-compatible endpoint URL. Defaults to "
82+
"https://s3.us-west-004.backblazeb2.com. Override to target a "
83+
"different B2 region."
84+
),
85+
)
86+
application_key_id: str | None = Field(
87+
default=None,
88+
description=(
89+
"Backblaze B2 application key ID. Aliased onto `aws_access_key_id` "
90+
"for the underlying boto3 client."
91+
),
92+
)
93+
application_key: str | None = Field(
94+
default=None,
95+
description=(
96+
"Backblaze B2 application key. Aliased onto `aws_secret_access_key` "
97+
"for the underlying boto3 client."
98+
),
99+
)
100+
101+
@classmethod
102+
def _is_dagster_maintained(cls) -> bool:
103+
return False
104+
105+
@model_validator(mode="after")
106+
def _map_b2_credentials_to_aws_fields(self) -> "B2Resource":
107+
# Pick up Backblaze-flavored env vars when the corresponding B2 field
108+
# was not provided. Explicit constructor values always win, env vars
109+
# are only a default for unconfigured fields.
110+
application_key_id = self.application_key_id or os.environ.get(
111+
B2_APPLICATION_KEY_ID_ENV_VAR
112+
)
113+
application_key = self.application_key or os.environ.get(
114+
B2_APPLICATION_KEY_ENV_VAR
115+
)
116+
117+
# Map the B2-named fields onto the AWS-named fields that the parent
118+
# S3Resource forwards to boto3. We only set the AWS field if the user
119+
# provided a B2 field (or B2 env var) and did not also provide the AWS
120+
# field directly, so explicit `aws_access_key_id=...` still wins.
121+
if application_key_id is not None and self.aws_access_key_id is None:
122+
object.__setattr__(self, "aws_access_key_id", application_key_id)
123+
if application_key is not None and self.aws_secret_access_key is None:
124+
object.__setattr__(self, "aws_secret_access_key", application_key)
125+
126+
# B2_ENDPOINT_URL env var overrides the per-region default but never
127+
# an explicit constructor value.
128+
if self.endpoint_url == DEFAULT_B2_ENDPOINT_URL:
129+
env_endpoint = os.environ.get(B2_ENDPOINT_URL_ENV_VAR)
130+
if env_endpoint:
131+
object.__setattr__(self, "endpoint_url", env_endpoint)
132+
133+
return self
134+
135+
def get_client(self) -> Any:
136+
"""Construct a boto3 S3 client pre-configured for Backblaze B2.
137+
138+
Mirrors ``dagster_aws.s3.utils.construct_s3_client`` and merges the
139+
retry config with the package identifier so it is not lost when both
140+
are present.
141+
"""
142+
retry_config = construct_boto_client_retry_config(self.max_attempts)
143+
config = retry_config.merge(Config(user_agent_extra=_user_agent_suffix()))
144+
145+
session = boto3.session.Session(profile_name=self.profile_name)
146+
s3_client = session.resource(
147+
"s3",
148+
region_name=self.region_name,
149+
use_ssl=self.use_ssl,
150+
verify=self.verify,
151+
endpoint_url=self.endpoint_url,
152+
aws_access_key_id=self.aws_access_key_id,
153+
aws_secret_access_key=self.aws_secret_access_key,
154+
aws_session_token=self.aws_session_token,
155+
config=config,
156+
).meta.client
157+
158+
if self.use_unsigned_session:
159+
s3_client.meta.events.register("choose-signer.s3.*", disable_signing)
160+
161+
return s3_client
162+
163+
def get_object_to_set_on_execution_context(self) -> Any:
164+
return self.get_client()

libraries/dagster-backblaze/dagster_backblaze_tests/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)