From 269c1f0e92766e008601c611c3a34945f2977576 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 23 May 2026 20:28:29 +0900 Subject: [PATCH 1/4] =?UTF-8?q?chore:=20PLAN03-1=20PR3=20Draft=20PR=20?= =?UTF-8?q?=E4=BD=9C=E6=88=90=20(S3=20backend)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 1ed32afbff7c34e1eab644aff3c7de0a61ea2642 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 23 May 2026 20:54:52 +0900 Subject: [PATCH 2/4] feat(env): PLAN03-1 PR3 devbase env export/import S3 backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `s3://bucket/key` を `devbase env export` / `devbase env import` の 入出力先として指定できるようにする - export 時は ServerSideEncryption (`aws:kms` 既定, `AES256` 切替可) を 常に PutObject に付与し、加えて GetBucketEncryption で **バケット側の 既定暗号化** も事前確認する - 暗号化未設定 / 確認不可 (AccessDenied) のバケットへは `--unsafe-allow-unencrypted-bucket` を明示しない限り export を拒否する (オブジェクト単位の SSE はこのフラグに関係なく常に付与される) - SSE 種別 / KMS 鍵 / エンドポイント / リージョンは環境変数 (`DEVBASE_S3_SSE`, `DEVBASE_S3_SSE_KMS_KEY_ID`, `DEVBASE_S3_ENDPOINT_URL`, `DEVBASE_S3_REGION`) で上書きできる - `boto3` は optional dep として `[project.optional-dependencies] s3` に追加 (`pip install 'devbase[s3]'` でインストール) - `gs://` (GCS) は PLAN03-1 PR4 廃案のため明示エラーで拒否する Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 1 + CHANGELOG.md | 10 ++ lib/devbase/cli.py | 4 + lib/devbase/commands/env.py | 3 + lib/devbase/env/io_export.py | 10 +- lib/devbase/env/storage.py | 223 ++++++++++++++++++++++++- pyproject.toml | 6 + tests/env/test_storage.py | 313 ++++++++++++++++++++++++++++++++++- uv.lock | 88 +++++++++- 9 files changed, 645 insertions(+), 13 deletions(-) diff --git a/.gitignore b/.gitignore index d945644..b11348e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ __pycache__/ .venv/ .env .env.backup +.gemini/ .docker-compose.scale.yml plugins.yml plugins/*/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 04229bf..60bb713 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ ## [Unreleased] +### Added +- `devbase env export` / `devbase env import` で **S3 URI (`s3://bucket/key`) を入出力先として指定**できるようになりました (PLAN03-1 PR3)。 + - 既定でオブジェクト単位の SSE (`aws:kms` または `AES256`) を強制し、export 時はバケット側のデフォルト暗号化も `GetBucketEncryption` で事前確認します。 + - 暗号化が未設定のバケットへ export する場合は `--unsafe-allow-unencrypted-bucket` の明示が必要です (オブジェクト単位の SSE はこのフラグに関係なく常に付与されます)。 + - SSE 種別 (`DEVBASE_S3_SSE`) / KMS 鍵 (`DEVBASE_S3_SSE_KMS_KEY_ID`) / エンドポイント (`DEVBASE_S3_ENDPOINT_URL`) / リージョン (`DEVBASE_S3_REGION`) は環境変数で上書きできます。MinIO / LocalStack の利用も可能です。 + - `boto3` は `pip install 'devbase[s3]'` で導入される optional 依存です。 + +### Changed +- `gs://` (GCS) スキームは **PLAN03-1 PR4 廃案** により対応しません。指定すると明示的なエラーメッセージで失敗します (旧: "未実装")。 + ## [2.2.0] - 2026-04-20 OSS 化に伴う初回リリース。devbase は本バージョンより `devbasex` Organization 配下で公開されます。 diff --git a/lib/devbase/cli.py b/lib/devbase/cli.py index d519db4..e2fd4be 100644 --- a/lib/devbase/cli.py +++ b/lib/devbase/cli.py @@ -151,6 +151,10 @@ def _add_env_parser(subparsers): env_export.add_argument('--force-unencrypted', action='store_true', help='Write as plaintext tar.gz (rejected by default; ' 'warns when sensitive keys are detected)') + env_export.add_argument('--unsafe-allow-unencrypted-bucket', action='store_true', + help='Allow S3 export to buckets without default encryption ' + '(per-object SSE is always applied regardless of this flag). ' + 'Has no effect for non-s3:// destinations.') env_import = env_sub.add_parser( 'import', diff --git a/lib/devbase/commands/env.py b/lib/devbase/commands/env.py index 3b43598..2c46b9c 100644 --- a/lib/devbase/commands/env.py +++ b/lib/devbase/commands/env.py @@ -398,6 +398,9 @@ def cmd_env_export(devbase_root: Path, args) -> int: passphrase_env=getattr(args, 'passphrase_env', None), passphrase_stdin=getattr(args, 'passphrase_stdin', False), force_unencrypted=getattr(args, 'force_unencrypted', False), + unsafe_allow_unencrypted_bucket=getattr( + args, 'unsafe_allow_unencrypted_bucket', False + ), ) return export(devbase_root, opts) diff --git a/lib/devbase/env/io_export.py b/lib/devbase/env/io_export.py index 101ceb2..08645a6 100644 --- a/lib/devbase/env/io_export.py +++ b/lib/devbase/env/io_export.py @@ -39,6 +39,9 @@ class ExportOptions: passphrase_env: Optional[str] = None passphrase_stdin: bool = False force_unencrypted: bool = False + # S3 backend 専用: バケット既定暗号化が未設定でも export を許可するか + # (オブジェクト単位の SSE はこのフラグに関係なく常に付与される) + unsafe_allow_unencrypted_bucket: bool = False def _default_dest(force_unencrypted: bool) -> str: @@ -167,7 +170,12 @@ def export(devbase_root: Path, opts: ExportOptions) -> int: logger.debug("暗号化後サイズ: %d bytes", len(payload)) dest = opts.dest or _default_dest(opts.force_unencrypted) - backend = _storage.resolve(dest) + # S3 など backend 固有のオプションを渡したい場合は s3_options を組み立てる。 + # それ以外 (local/stdio) では未使用なので無害。 + s3_options = _storage.S3Options.from_env( + unsafe_allow_unencrypted_bucket=opts.unsafe_allow_unencrypted_bucket, + ) if _storage.is_s3(dest) else None + backend = _storage.resolve(dest, s3_options=s3_options) backend.write_bytes(dest, payload) if _storage.is_stdio(dest): diff --git a/lib/devbase/env/storage.py b/lib/devbase/env/storage.py index 114d0b4..9607561 100644 --- a/lib/devbase/env/storage.py +++ b/lib/devbase/env/storage.py @@ -1,14 +1,18 @@ -"""env バンドルの入出力先 (local / stdio / 将来 s3, gcs) を抽象化する""" +"""env バンドルの入出力先 (local / stdio / s3) を抽象化する""" from __future__ import annotations import os import sys +from dataclasses import dataclass from pathlib import Path -from typing import Protocol +from typing import Optional, Protocol, Tuple from urllib.parse import urlparse from devbase.errors import DevbaseError +from devbase.log import get_logger + +logger = get_logger(__name__) class StorageError(DevbaseError): @@ -98,8 +102,205 @@ def read_bytes(self, source: str) -> bytes: return sys.stdin.buffer.read() -def resolve(uri: str) -> StorageBackend: - """URI スキームから対応する backend を返す""" +@dataclass +class S3Options: + """S3Backend の挙動パラメータ。 + + `unsafe_allow_unencrypted_bucket` は **export 専用**: True にすると + バケット側のデフォルト暗号化未設定でも export を許可する。 + オブジェクト個別の SSE は `sse` / `sse_kms_key_id` で常に強制される。 + """ + unsafe_allow_unencrypted_bucket: bool = False + sse: str = 'aws:kms' # 'aws:kms' or 'AES256' + sse_kms_key_id: Optional[str] = None + endpoint_url: Optional[str] = None + region: Optional[str] = None + + @classmethod + def from_env( + cls, + *, + unsafe_allow_unencrypted_bucket: bool = False, + ) -> 'S3Options': + """環境変数から既定値を読み取って組み立てる。 + + env vars (任意): + DEVBASE_S3_SSE -> sse (既定: aws:kms) + DEVBASE_S3_SSE_KMS_KEY_ID -> sse_kms_key_id + DEVBASE_S3_ENDPOINT_URL -> endpoint_url (MinIO/LocalStack 用) + DEVBASE_S3_REGION -> region + + boto3 が認識する AWS_PROFILE / AWS_REGION / AWS_ENDPOINT_URL[_S3] / + AWS_ACCESS_KEY_ID 等はそのまま尊重される。 + """ + sse = os.environ.get('DEVBASE_S3_SSE', 'aws:kms') + if sse not in ('aws:kms', 'AES256'): + raise StorageError( + f"DEVBASE_S3_SSE は 'aws:kms' か 'AES256' を指定してください: {sse!r}" + ) + return cls( + unsafe_allow_unencrypted_bucket=unsafe_allow_unencrypted_bucket, + sse=sse, + sse_kms_key_id=os.environ.get('DEVBASE_S3_SSE_KMS_KEY_ID'), + endpoint_url=os.environ.get('DEVBASE_S3_ENDPOINT_URL'), + region=os.environ.get('DEVBASE_S3_REGION'), + ) + + +def _parse_s3_uri(uri: str) -> Tuple[str, str]: + """s3://bucket/key/path を (bucket, key) に分解する""" + parsed = urlparse(uri) + if parsed.scheme.lower() != 's3': + raise StorageError(f"S3 URI が期待されますが: {uri!r}") + bucket = parsed.netloc + key = parsed.path.lstrip('/') + if not bucket: + raise StorageError( + f"S3 URI のバケット名が空です: {uri!r} " + "(s3://bucket/key の形式で指定してください)" + ) + if not key: + raise StorageError( + f"S3 URI のキーが空です: {uri!r} " + "(s3://bucket/key の形式で指定してください)" + ) + return bucket, key + + +class S3Backend: + """AWS S3 / S3 互換ストレージ (MinIO 等)。boto3 を optional dep として遅延 import する。 + + - write_bytes: PutObject 時に ServerSideEncryption を常に付与し、 + `unsafe_allow_unencrypted_bucket=False` のときは + GetBucketEncryption で**バケット側の既定暗号化**も事前確認する。 + - read_bytes: GetObject (暗号化はバケット/オブジェクト側設定に従う)。 + """ + + def __init__(self, options: Optional[S3Options] = None): + self._options = options or S3Options() + self._client = None + + def _get_client(self): + if self._client is not None: + return self._client + try: + import boto3 # type: ignore + except ImportError as e: + raise StorageError( + "S3 backend を使うには boto3 が必要です " + "(`pip install boto3` または `uv add boto3` を実行してください)" + ) from e + + kwargs = {} + if self._options.endpoint_url: + kwargs['endpoint_url'] = self._options.endpoint_url + if self._options.region: + kwargs['region_name'] = self._options.region + try: + self._client = boto3.client('s3', **kwargs) + except Exception as e: + raise StorageError(f"S3 クライアントの生成に失敗しました: {e}") from e + return self._client + + @staticmethod + def _error_code(exc: BaseException) -> Optional[str]: + """botocore.exceptions.ClientError から AWS error code を取り出す""" + resp = getattr(exc, 'response', None) + if isinstance(resp, dict): + return resp.get('Error', {}).get('Code') + return None + + def _verify_bucket_encryption(self, client, bucket: str) -> None: + """バケットレベルの既定暗号化を確認。 + + - 暗号化が設定済み: OK + - 暗号化が未設定 (ServerSideEncryptionConfigurationNotFoundError): + unsafe フラグがあれば警告のみ、無ければ StorageError + - AccessDenied 等で確認できなかった場合は事故防止のため拒否 + (`--unsafe-allow-unencrypted-bucket` でのみバイパス可) + """ + try: + client.get_bucket_encryption(Bucket=bucket) + return + except Exception as e: + code = self._error_code(e) + if code == 'ServerSideEncryptionConfigurationNotFoundError': + msg = ( + f"S3 バケット '{bucket}' のデフォルト暗号化が未設定です。" + "バケットポリシーで SSE-KMS or SSE-S3 を有効化するか、" + "明示的に '--unsafe-allow-unencrypted-bucket' を指定してください " + "(オブジェクト単位の SSE はこのオプションに関係なく常に付与されます)" + ) + if self._options.unsafe_allow_unencrypted_bucket: + logger.warning("%s (unsafe フラグにより続行)", msg) + return + raise StorageError(msg) from e + if code in ('AccessDenied', 'AccessDeniedException'): + msg = ( + f"S3 バケット '{bucket}' の暗号化設定を確認できません " + "(GetBucketEncryption 権限がありません)。" + "バケットポリシーの確認が取れないため export を中止します。" + "権限を付与するか、'--unsafe-allow-unencrypted-bucket' を明示してください" + ) + if self._options.unsafe_allow_unencrypted_bucket: + logger.warning("%s (unsafe フラグにより続行)", msg) + return + raise StorageError(msg) from e + raise StorageError( + f"バケット暗号化設定の確認に失敗しました ({bucket}): {e}" + ) from e + + def write_bytes(self, dest: str, data: bytes) -> None: + bucket, key = _parse_s3_uri(dest) + client = self._get_client() + self._verify_bucket_encryption(client, bucket) + + put_kwargs = { + 'Bucket': bucket, + 'Key': key, + 'Body': data, + 'ServerSideEncryption': self._options.sse, + } + if self._options.sse == 'aws:kms' and self._options.sse_kms_key_id: + put_kwargs['SSEKMSKeyId'] = self._options.sse_kms_key_id + + try: + client.put_object(**put_kwargs) + except Exception as e: + raise StorageError( + f"S3 への書き込みに失敗しました ({dest}): {e}" + ) from e + logger.info("S3 へ書き込みました: %s (sse=%s)", dest, self._options.sse) + + def read_bytes(self, source: str) -> bytes: + bucket, key = _parse_s3_uri(source) + client = self._get_client() + try: + response = client.get_object(Bucket=bucket, Key=key) + body = response['Body'] + except Exception as e: + code = self._error_code(e) + if code in ('NoSuchKey', 'NoSuchBucket', '404'): + raise StorageError( + f"S3 オブジェクトが見つかりません: {source}" + ) from e + raise StorageError( + f"S3 からの読み込みに失敗しました ({source}): {e}" + ) from e + try: + return body.read() + except Exception as e: + raise StorageError( + f"S3 レスポンスボディの読み取りに失敗しました ({source}): {e}" + ) from e + + +def resolve(uri: str, *, s3_options: Optional[S3Options] = None) -> StorageBackend: + """URI スキームから対応する backend を返す。 + + s3:// は `s3_options` を受け取れる (省略時は S3Options.from_env())。 + `gs://` は PLAN03-1 PR4 廃案により対応しない。 + """ if uri == '-': return StdioBackend() @@ -109,10 +310,14 @@ def resolve(uri: str) -> StorageBackend: if scheme in ('', 'file'): return LocalBackend() - if scheme in ('s3', 'gs'): + if scheme == 's3': + return S3Backend(s3_options if s3_options is not None else S3Options.from_env()) + + if scheme == 'gs': raise StorageError( - f"スキーム '{scheme}://' は本 PR では未実装です " - "(後続 PR で対応予定)" + "スキーム 'gs://' (GCS) は PLAN03-1 PR4 廃案により対応していません。" + "必要な場合は s3:// 経由 (S3 互換ゲートウェイ) を検討するか、" + "ローカルファイルを介して転送してください" ) # Windows のドライブレター付きパス (例: C:\path, d:/path) は @@ -126,3 +331,7 @@ def resolve(uri: str) -> StorageBackend: def is_stdio(uri: str) -> bool: return uri == '-' + + +def is_s3(uri: str) -> bool: + return urlparse(uri).scheme.lower() == 's3' diff --git a/pyproject.toml b/pyproject.toml index 2f680cb..925b3df 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,12 @@ dependencies = [ "pyrage>=1.2", ] +[project.optional-dependencies] +# `devbase env export/import` の S3 backend (`s3://`) を使う場合のみ必要 +s3 = [ + "boto3>=1.34", +] + [dependency-groups] dev = [ "pytest>=8.0", diff --git a/tests/env/test_storage.py b/tests/env/test_storage.py index 385e5b0..93578a4 100644 --- a/tests/env/test_storage.py +++ b/tests/env/test_storage.py @@ -37,10 +37,30 @@ def test_resolve_stdio_for_dash(): assert not storage.is_stdio("/tmp/foo") -def test_resolve_rejects_unimplemented_schemes(): - for uri in ("s3://bucket/key", "gs://bucket/object"): - with pytest.raises(storage.StorageError, match="未実装"): - storage.resolve(uri) +def test_resolve_rejects_gs_scheme_dropped(): + """PLAN03-1 PR4 廃案により gs:// は対応しない (S3 と紛らわしいので明示メッセージ)""" + with pytest.raises(storage.StorageError, match="廃案"): + storage.resolve("gs://bucket/object") + + +def test_resolve_returns_s3_backend(): + """s3:// は S3Backend を返し、S3Options を引き渡せる""" + opts = storage.S3Options(unsafe_allow_unencrypted_bucket=True, sse='AES256') + backend = storage.resolve("s3://bucket/key", s3_options=opts) + assert isinstance(backend, storage.S3Backend) + assert backend._options is opts + + +def test_resolve_returns_s3_backend_without_opts(monkeypatch): + """s3_options 省略時は from_env で組み立てられる""" + monkeypatch.delenv("DEVBASE_S3_SSE", raising=False) + monkeypatch.delenv("DEVBASE_S3_SSE_KMS_KEY_ID", raising=False) + monkeypatch.delenv("DEVBASE_S3_ENDPOINT_URL", raising=False) + monkeypatch.delenv("DEVBASE_S3_REGION", raising=False) + backend = storage.resolve("s3://bucket/key") + assert isinstance(backend, storage.S3Backend) + assert backend._options.sse == 'aws:kms' + assert backend._options.unsafe_allow_unencrypted_bucket is False def test_resolve_rejects_unknown_scheme(): @@ -48,6 +68,13 @@ def test_resolve_rejects_unknown_scheme(): storage.resolve("ftp://host/x") +def test_is_s3(): + assert storage.is_s3("s3://bucket/key") + assert not storage.is_s3("/tmp/foo") + assert not storage.is_s3("-") + assert not storage.is_s3("file:///tmp/foo") + + @pytest.mark.parametrize("uri", [ r"C:\Users\foo\bundle.tar.gz", r"c:\tmp\out.bin", @@ -145,3 +172,281 @@ def test_local_backend_read_wraps_oserror_as_storage_error(tmp_path): # ディレクトリを read_bytes すると IsADirectoryError with pytest.raises(storage.StorageError): backend.read_bytes(str(tmp_path)) + + +# --------------------------------------------------------------------------- +# S3Backend +# --------------------------------------------------------------------------- + + +class _FakeBody: + def __init__(self, data: bytes): + self._data = data + + def read(self): + return self._data + + +class _FakeS3Client: + """boto3 client のスタブ。呼び出しを記録し、振る舞いをカスタマイズできる""" + + def __init__( + self, + *, + get_encryption_error: Exception | None = None, + put_error: Exception | None = None, + get_object_error: Exception | None = None, + object_payload: bytes = b'', + ): + self.get_encryption_error = get_encryption_error + self.put_error = put_error + self.get_object_error = get_object_error + self.object_payload = object_payload + self.calls: list[tuple[str, dict]] = [] + + def get_bucket_encryption(self, **kwargs): + self.calls.append(('get_bucket_encryption', kwargs)) + if self.get_encryption_error: + raise self.get_encryption_error + return {'ServerSideEncryptionConfiguration': {'Rules': []}} + + def put_object(self, **kwargs): + self.calls.append(('put_object', kwargs)) + if self.put_error: + raise self.put_error + return {'ETag': '"deadbeef"'} + + def get_object(self, **kwargs): + self.calls.append(('get_object', kwargs)) + if self.get_object_error: + raise self.get_object_error + return {'Body': _FakeBody(self.object_payload)} + + +def _make_aws_error(code: str) -> Exception: + """botocore.exceptions.ClientError 相当のダックタイプエラーを作る + (boto3 を実依存に入れず、S3Backend._error_code が response[Error][Code] を + 見るだけなので最小限の構造で再現できる)""" + err = Exception(f"AWS error: {code}") + err.response = {'Error': {'Code': code, 'Message': 'simulated'}} + return err + + +def test_parse_s3_uri_valid(): + assert storage._parse_s3_uri("s3://bucket/key") == ("bucket", "key") + assert storage._parse_s3_uri("s3://bucket/path/to/key.tar.gz") == ( + "bucket", "path/to/key.tar.gz" + ) + + +def test_parse_s3_uri_invalid(): + with pytest.raises(storage.StorageError, match="バケット名が空"): + storage._parse_s3_uri("s3:///key") + with pytest.raises(storage.StorageError, match="キー"): + storage._parse_s3_uri("s3://bucket") + with pytest.raises(storage.StorageError, match="キー"): + storage._parse_s3_uri("s3://bucket/") + with pytest.raises(storage.StorageError, match="S3 URI"): + storage._parse_s3_uri("/tmp/foo") + + +def test_s3_options_from_env_defaults(monkeypatch): + for var in ('DEVBASE_S3_SSE', 'DEVBASE_S3_SSE_KMS_KEY_ID', + 'DEVBASE_S3_ENDPOINT_URL', 'DEVBASE_S3_REGION'): + monkeypatch.delenv(var, raising=False) + opts = storage.S3Options.from_env() + assert opts.sse == 'aws:kms' + assert opts.sse_kms_key_id is None + assert opts.endpoint_url is None + assert opts.region is None + assert opts.unsafe_allow_unencrypted_bucket is False + + +def test_s3_options_from_env_reads_overrides(monkeypatch): + monkeypatch.setenv('DEVBASE_S3_SSE', 'AES256') + monkeypatch.setenv('DEVBASE_S3_SSE_KMS_KEY_ID', 'alias/devbase') + monkeypatch.setenv('DEVBASE_S3_ENDPOINT_URL', 'http://minio:9000') + monkeypatch.setenv('DEVBASE_S3_REGION', 'ap-northeast-1') + opts = storage.S3Options.from_env(unsafe_allow_unencrypted_bucket=True) + assert opts.sse == 'AES256' + assert opts.sse_kms_key_id == 'alias/devbase' + assert opts.endpoint_url == 'http://minio:9000' + assert opts.region == 'ap-northeast-1' + assert opts.unsafe_allow_unencrypted_bucket is True + + +def test_s3_options_from_env_rejects_invalid_sse(monkeypatch): + monkeypatch.setenv('DEVBASE_S3_SSE', 'rot13') + with pytest.raises(storage.StorageError, match="DEVBASE_S3_SSE"): + storage.S3Options.from_env() + + +def _attach_fake_client(backend, fake): + """S3Backend に _get_client をモック付与する""" + backend._client = fake + return fake + + +def test_s3_backend_write_calls_put_object_with_sse(): + backend = storage.S3Backend(storage.S3Options(sse='aws:kms')) + fake = _attach_fake_client(backend, _FakeS3Client()) + + backend.write_bytes("s3://bucket/path/key.bin", b"payload") + + assert ('get_bucket_encryption', {'Bucket': 'bucket'}) in fake.calls + put_calls = [args for name, args in fake.calls if name == 'put_object'] + assert len(put_calls) == 1 + args = put_calls[0] + assert args['Bucket'] == 'bucket' + assert args['Key'] == 'path/key.bin' + assert args['Body'] == b"payload" + assert args['ServerSideEncryption'] == 'aws:kms' + assert 'SSEKMSKeyId' not in args + + +def test_s3_backend_write_passes_kms_key_id_when_specified(): + backend = storage.S3Backend(storage.S3Options( + sse='aws:kms', sse_kms_key_id='alias/devbase', + )) + fake = _attach_fake_client(backend, _FakeS3Client()) + backend.write_bytes("s3://bucket/k", b"x") + args = [a for n, a in fake.calls if n == 'put_object'][0] + assert args['SSEKMSKeyId'] == 'alias/devbase' + + +def test_s3_backend_write_with_aes256_omits_kms_key_id(): + backend = storage.S3Backend(storage.S3Options( + sse='AES256', sse_kms_key_id='alias/should-be-ignored', + )) + fake = _attach_fake_client(backend, _FakeS3Client()) + backend.write_bytes("s3://bucket/k", b"x") + args = [a for n, a in fake.calls if n == 'put_object'][0] + assert args['ServerSideEncryption'] == 'AES256' + assert 'SSEKMSKeyId' not in args + + +def test_s3_backend_write_rejects_unencrypted_bucket(): + backend = storage.S3Backend(storage.S3Options()) + fake = _attach_fake_client(backend, _FakeS3Client( + get_encryption_error=_make_aws_error( + 'ServerSideEncryptionConfigurationNotFoundError' + ), + )) + with pytest.raises(storage.StorageError, match="デフォルト暗号化が未設定"): + backend.write_bytes("s3://bucket/k", b"x") + # PutObject まで到達していない + assert not any(name == 'put_object' for name, _ in fake.calls) + + +def test_s3_backend_write_allows_unencrypted_bucket_with_unsafe_flag(caplog): + backend = storage.S3Backend(storage.S3Options( + unsafe_allow_unencrypted_bucket=True, + )) + fake = _attach_fake_client(backend, _FakeS3Client( + get_encryption_error=_make_aws_error( + 'ServerSideEncryptionConfigurationNotFoundError' + ), + )) + with caplog.at_level('WARNING'): + backend.write_bytes("s3://bucket/k", b"x") + assert any('unsafe' in r.message for r in caplog.records) + assert any(name == 'put_object' for name, _ in fake.calls) + + +def test_s3_backend_write_rejects_access_denied_on_encryption_check(): + backend = storage.S3Backend(storage.S3Options()) + fake = _attach_fake_client(backend, _FakeS3Client( + get_encryption_error=_make_aws_error('AccessDenied'), + )) + with pytest.raises(storage.StorageError, match="GetBucketEncryption"): + backend.write_bytes("s3://bucket/k", b"x") + + +def test_s3_backend_write_allows_access_denied_with_unsafe_flag(): + backend = storage.S3Backend(storage.S3Options( + unsafe_allow_unencrypted_bucket=True, + )) + fake = _attach_fake_client(backend, _FakeS3Client( + get_encryption_error=_make_aws_error('AccessDenied'), + )) + backend.write_bytes("s3://bucket/k", b"x") + assert any(name == 'put_object' for name, _ in fake.calls) + + +def test_s3_backend_write_wraps_put_error(): + backend = storage.S3Backend(storage.S3Options()) + _attach_fake_client(backend, _FakeS3Client( + put_error=_make_aws_error('InternalError'), + )) + with pytest.raises(storage.StorageError, match="書き込みに失敗"): + backend.write_bytes("s3://bucket/k", b"x") + + +def test_s3_backend_read_calls_get_object(): + backend = storage.S3Backend() + fake = _attach_fake_client(backend, _FakeS3Client(object_payload=b"hello")) + data = backend.read_bytes("s3://bucket/path/key") + assert data == b"hello" + args = [a for n, a in fake.calls if n == 'get_object'][0] + assert args == {'Bucket': 'bucket', 'Key': 'path/key'} + + +def test_s3_backend_read_raises_for_missing_object(): + backend = storage.S3Backend() + _attach_fake_client(backend, _FakeS3Client( + get_object_error=_make_aws_error('NoSuchKey'), + )) + with pytest.raises(storage.StorageError, match="見つかりません"): + backend.read_bytes("s3://bucket/no-such") + + +def test_s3_backend_read_wraps_unknown_error(): + backend = storage.S3Backend() + _attach_fake_client(backend, _FakeS3Client( + get_object_error=_make_aws_error('SlowDown'), + )) + with pytest.raises(storage.StorageError, match="読み込みに失敗"): + backend.read_bytes("s3://bucket/k") + + +def test_s3_backend_raises_clear_error_when_boto3_missing(monkeypatch): + """boto3 が無い環境では分かりやすいエラーメッセージで失敗する""" + backend = storage.S3Backend() + + # boto3 import を強制的に ImportError にする + import builtins + real_import = builtins.__import__ + + def fake_import(name, *args, **kwargs): + if name == 'boto3' or name.startswith('boto3.'): + raise ImportError("boto3 not installed (simulated)") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, '__import__', fake_import) + with pytest.raises(storage.StorageError, match="boto3"): + backend.write_bytes("s3://bucket/k", b"x") + + +def test_s3_backend_get_client_passes_endpoint_and_region(monkeypatch): + """S3Options.endpoint_url / region が boto3.client へ正しく渡る""" + backend = storage.S3Backend(storage.S3Options( + endpoint_url='http://minio:9000', + region='ap-northeast-1', + )) + + captured_kwargs = {} + + def fake_client(service, **kwargs): + captured_kwargs['service'] = service + captured_kwargs.update(kwargs) + return _FakeS3Client() + + fake_boto3 = type(sys)('boto3') + fake_boto3.client = fake_client # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, 'boto3', fake_boto3) + + backend._get_client() + + assert captured_kwargs['service'] == 's3' + assert captured_kwargs['endpoint_url'] == 'http://minio:9000' + assert captured_kwargs['region_name'] == 'ap-northeast-1' diff --git a/uv.lock b/uv.lock index 49f3aa8..2909338 100644 --- a/uv.lock +++ b/uv.lock @@ -1,7 +1,35 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.10" +[[package]] +name = "boto3" +version = "1.43.14" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, + { name = "jmespath" }, + { name = "s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/4b/616367e871ce3f1cb3e8545a97736b6331b9fb081497f2d44c5b2aa6959d/boto3-1.43.14.tar.gz", hash = "sha256:5c0a994b3182061ee101812e721100717a4d664f9f4ceaf4a86b6d032ce9fc2d", size = 113142, upload-time = "2026-05-22T19:28:47.861Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/00/59cb9329c18e2d3aa23062ceaa87d065f2e81e7d2931df24d64e9a7815aa/boto3-1.43.14-py3-none-any.whl", hash = "sha256:574335744656cfed0b362a0a0467aaf2eb2bf15526edcd02d31d3c661f4b09e4", size = 140536, upload-time = "2026-05-22T19:28:46.49Z" }, +] + +[[package]] +name = "botocore" +version = "1.43.14" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/3c/798d2f7deb118241930c7c6bcfb0b970d3f0245bf580700663199aeed2c3/botocore-1.43.14.tar.gz", hash = "sha256:b9e500737e43d2f147c9d4e23b54360335e77d4c0ba90a318f51b65e06cb8516", size = 15382604, upload-time = "2026-05-22T19:28:36.363Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/7e/6e64821077cd2efc4aa51b7d638fb6d48e1c7c450201c529fbaf1de8bfd3/botocore-1.43.14-py3-none-any.whl", hash = "sha256:1f4a2a95ea78c10398e78431e98c1fe47adb54a7b10a32975144c1f541186658", size = 15061424, upload-time = "2026-05-22T19:28:32.682Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -20,6 +48,11 @@ dependencies = [ { name = "pyyaml" }, ] +[package.optional-dependencies] +s3 = [ + { name = "boto3" }, +] + [package.dev-dependencies] dev = [ { name = "pytest" }, @@ -27,9 +60,11 @@ dev = [ [package.metadata] requires-dist = [ + { name = "boto3", marker = "extra == 's3'", specifier = ">=1.34" }, { name = "pyrage", specifier = ">=1.2" }, { name = "pyyaml", specifier = ">=6.0" }, ] +provides-extras = ["s3"] [package.metadata.requires-dev] dev = [{ name = "pytest", specifier = ">=8.0" }] @@ -55,6 +90,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "jmespath" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, +] + [[package]] name = "packaging" version = "26.2" @@ -112,6 +156,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -176,6 +232,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "s3transfer" +version = "0.17.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/ec/7c692cde9125b77e84b307354d4fb705f98b8ccad59a036d5957ca75bfc3/s3transfer-0.17.0.tar.gz", hash = "sha256:9edeb6d1c3c2f89d6050348548834ad8289610d886e5bf7b7207728bd43ce33a", size = 155337, upload-time = "2026-04-29T22:07:36.33Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/72/c6c32d2b657fa3dad1de340254e14390b1e334ce38268b7ad51abda3c8c2/s3transfer-0.17.0-py3-none-any.whl", hash = "sha256:ce3801712acf4ad3e89fb9990df97b4972e93f4b3b0004d214be5bce12814c20", size = 86811, upload-time = "2026-04-29T22:07:34.966Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + [[package]] name = "tomli" version = "2.4.1" @@ -238,3 +315,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac8 wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] From ffecd13d2dfeee80d10c47bbf5b5baa80e81152c Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 23 May 2026 21:48:40 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix(env):=20PLAN03-1=20PR3=20storage.py=20m?= =?UTF-8?q?inor=20=E4=BF=AE=E6=AD=A3=20(cross-review=20round=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #19 のクロスレビュー (codex / gemini) で指摘された minor 3 件に対応。 - `_parse_s3_uri`: `urlparse` は S3 キーに含まれる `?` / `#` を query / fragment として落としてしまうため、AWS CLI と同じ挙動になるよう スキームを除去した上で `partition('/')` で分割する。 - boto3 未インストール時のエラーメッセージを `pip install boto3` から 本プロジェクトの optional dependency 経由 (`pip install 'devbase[s3]'` / `uv add 'devbase[s3]'`) に変更。 - `_verify_bucket_encryption`: MinIO / LocalStack 等の S3 互換ストレージで GetBucketEncryption が NotImplemented を返すケースに備え、 `--unsafe-allow-unencrypted-bucket` 指定時は未知エラーも警告のみで続行する 逃げ道として機能させる (CHANGELOG の S3 互換ストレージ対応との整合)。 新規テスト: query/fragment 保持、未知エラーの拒否、unsafe フラグでの続行を追加。 Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/devbase/env/storage.py | 30 ++++++++++++++++++-------- tests/env/test_storage.py | 43 +++++++++++++++++++++++++++++++++++++- 2 files changed, 63 insertions(+), 10 deletions(-) diff --git a/lib/devbase/env/storage.py b/lib/devbase/env/storage.py index 9607561..aa8a36b 100644 --- a/lib/devbase/env/storage.py +++ b/lib/devbase/env/storage.py @@ -148,18 +148,22 @@ def from_env( def _parse_s3_uri(uri: str) -> Tuple[str, str]: - """s3://bucket/key/path を (bucket, key) に分解する""" - parsed = urlparse(uri) - if parsed.scheme.lower() != 's3': + """s3://bucket/key/path を (bucket, key) に分解する + + `urlparse` は S3 キー名に含まれる `?` / `#` を `query` / `fragment` として + 切り落としてしまうため、AWS CLI の挙動に合わせてスキームを除去した上で + 直接 `/` 分割する。 + """ + if not uri[:5].lower() == 's3://': raise StorageError(f"S3 URI が期待されますが: {uri!r}") - bucket = parsed.netloc - key = parsed.path.lstrip('/') + rest = uri[5:] + bucket, sep, key = rest.partition('/') if not bucket: raise StorageError( f"S3 URI のバケット名が空です: {uri!r} " "(s3://bucket/key の形式で指定してください)" ) - if not key: + if not sep or not key: raise StorageError( f"S3 URI のキーが空です: {uri!r} " "(s3://bucket/key の形式で指定してください)" @@ -188,7 +192,7 @@ def _get_client(self): except ImportError as e: raise StorageError( "S3 backend を使うには boto3 が必要です " - "(`pip install boto3` または `uv add boto3` を実行してください)" + "(`pip install 'devbase[s3]'` または `uv add 'devbase[s3]'` を実行してください)" ) from e kwargs = {} @@ -246,9 +250,17 @@ def _verify_bucket_encryption(self, client, bucket: str) -> None: logger.warning("%s (unsafe フラグにより続行)", msg) return raise StorageError(msg) from e - raise StorageError( + # MinIO / LocalStack 等の S3 互換ストレージでは + # GetBucketEncryption が NotImplemented / MethodNotAllowed / 501 等を返す + # ことがある。`--unsafe-allow-unencrypted-bucket` 指定時は逃げ道として + # 警告のみで続行する (オブジェクト個別の SSE は引き続き付与される)。 + msg = ( f"バケット暗号化設定の確認に失敗しました ({bucket}): {e}" - ) from e + ) + if self._options.unsafe_allow_unencrypted_bucket: + logger.warning("%s (unsafe フラグにより続行)", msg) + return + raise StorageError(msg) from e def write_bytes(self, dest: str, data: bytes) -> None: bucket, key = _parse_s3_uri(dest) diff --git a/tests/env/test_storage.py b/tests/env/test_storage.py index 93578a4..8657e65 100644 --- a/tests/env/test_storage.py +++ b/tests/env/test_storage.py @@ -250,6 +250,20 @@ def test_parse_s3_uri_invalid(): storage._parse_s3_uri("/tmp/foo") +def test_parse_s3_uri_preserves_query_and_fragment_in_key(): + """S3 のキー名は `?` / `#` を含めることができる。urlparse 由来の query/fragment + 切り落としに退行していないことを検証する (AWS CLI と同じ挙動)""" + assert storage._parse_s3_uri("s3://bucket/key?with=query") == ( + "bucket", "key?with=query" + ) + assert storage._parse_s3_uri("s3://bucket/path/to#hash") == ( + "bucket", "path/to#hash" + ) + assert storage._parse_s3_uri("s3://bucket/a?b#c/d") == ( + "bucket", "a?b#c/d" + ) + + def test_s3_options_from_env_defaults(monkeypatch): for var in ('DEVBASE_S3_SSE', 'DEVBASE_S3_SSE_KMS_KEY_ID', 'DEVBASE_S3_ENDPOINT_URL', 'DEVBASE_S3_REGION'): @@ -373,6 +387,31 @@ def test_s3_backend_write_allows_access_denied_with_unsafe_flag(): assert any(name == 'put_object' for name, _ in fake.calls) +def test_s3_backend_write_rejects_unknown_encryption_check_error(): + """未知の GetBucketEncryption エラーは、unsafe フラグ無しでは中止する""" + backend = storage.S3Backend(storage.S3Options()) + _attach_fake_client(backend, _FakeS3Client( + get_encryption_error=_make_aws_error('NotImplemented'), + )) + with pytest.raises(storage.StorageError, match="バケット暗号化設定の確認に失敗"): + backend.write_bytes("s3://bucket/k", b"x") + + +def test_s3_backend_write_allows_unknown_encryption_error_with_unsafe_flag(caplog): + """S3 互換ストレージ (MinIO 等) で GetBucketEncryption が NotImplemented を + 返すケース: unsafe フラグ指定時は警告のみで PutObject へ進む""" + backend = storage.S3Backend(storage.S3Options( + unsafe_allow_unencrypted_bucket=True, + )) + fake = _attach_fake_client(backend, _FakeS3Client( + get_encryption_error=_make_aws_error('NotImplemented'), + )) + with caplog.at_level('WARNING'): + backend.write_bytes("s3://bucket/k", b"x") + assert any('unsafe' in r.message for r in caplog.records) + assert any(name == 'put_object' for name, _ in fake.calls) + + def test_s3_backend_write_wraps_put_error(): backend = storage.S3Backend(storage.S3Options()) _attach_fake_client(backend, _FakeS3Client( @@ -423,8 +462,10 @@ def fake_import(name, *args, **kwargs): return real_import(name, *args, **kwargs) monkeypatch.setattr(builtins, '__import__', fake_import) - with pytest.raises(storage.StorageError, match="boto3"): + with pytest.raises(storage.StorageError, match=r"devbase\[s3\]") as ei: backend.write_bytes("s3://bucket/k", b"x") + # boto3 という名前も併記されていること + assert "boto3" in str(ei.value) def test_s3_backend_get_client_passes_endpoint_and_region(monkeypatch): From d57abde81e6e2dce55e084a3ae13cfd2e024bab6 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 23 May 2026 22:25:52 +0900 Subject: [PATCH 4/4] =?UTF-8?q?chore(env):=20PLAN03-1=20PR3=20boto3=20?= =?UTF-8?q?=E3=82=92=20main=20dependency=20=E3=81=AB=E6=98=87=E6=A0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit boto3 を `[project.optional-dependencies].s3` から `[project].dependencies` に移し、ImportError ハンドラとフォローアップ案内文を撤去する。 意図: - S3 URI を初めて指定したユーザに `pip install 'devbase[s3]'` を 打たせる UX を廃する。25MB 程度のコスト増 (botocore 24MB) は 実装複雑度ゼロと引き換え。 - 引数検出 (`s3://` 走査) や lazy 自動 install を採らないのは、 CI / オフライン / read-only コンテナで挙動が安定するため。 storage.py / test_storage.py の boto3-missing 関連コードを削除。 CHANGELOG.md の optional 記述も同期更新。 Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 2 +- lib/devbase/env/storage.py | 10 ++-------- pyproject.toml | 5 ----- tests/env/test_storage.py | 20 -------------------- uv.lock | 9 ++------- 5 files changed, 5 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 60bb713..23027d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ - 既定でオブジェクト単位の SSE (`aws:kms` または `AES256`) を強制し、export 時はバケット側のデフォルト暗号化も `GetBucketEncryption` で事前確認します。 - 暗号化が未設定のバケットへ export する場合は `--unsafe-allow-unencrypted-bucket` の明示が必要です (オブジェクト単位の SSE はこのフラグに関係なく常に付与されます)。 - SSE 種別 (`DEVBASE_S3_SSE`) / KMS 鍵 (`DEVBASE_S3_SSE_KMS_KEY_ID`) / エンドポイント (`DEVBASE_S3_ENDPOINT_URL`) / リージョン (`DEVBASE_S3_REGION`) は環境変数で上書きできます。MinIO / LocalStack の利用も可能です。 - - `boto3` は `pip install 'devbase[s3]'` で導入される optional 依存です。 + - `boto3` は main dependency として常に同梱されます (S3 を使わないユーザにも 25MB 程度入りますが、引数検出や lazy install の複雑さを避けるトレードオフです)。 ### Changed - `gs://` (GCS) スキームは **PLAN03-1 PR4 廃案** により対応しません。指定すると明示的なエラーメッセージで失敗します (旧: "未実装")。 diff --git a/lib/devbase/env/storage.py b/lib/devbase/env/storage.py index aa8a36b..adaa744 100644 --- a/lib/devbase/env/storage.py +++ b/lib/devbase/env/storage.py @@ -172,7 +172,7 @@ def _parse_s3_uri(uri: str) -> Tuple[str, str]: class S3Backend: - """AWS S3 / S3 互換ストレージ (MinIO 等)。boto3 を optional dep として遅延 import する。 + """AWS S3 / S3 互換ストレージ (MinIO 等)。 - write_bytes: PutObject 時に ServerSideEncryption を常に付与し、 `unsafe_allow_unencrypted_bucket=False` のときは @@ -187,13 +187,7 @@ def __init__(self, options: Optional[S3Options] = None): def _get_client(self): if self._client is not None: return self._client - try: - import boto3 # type: ignore - except ImportError as e: - raise StorageError( - "S3 backend を使うには boto3 が必要です " - "(`pip install 'devbase[s3]'` または `uv add 'devbase[s3]'` を実行してください)" - ) from e + import boto3 kwargs = {} if self._options.endpoint_url: diff --git a/pyproject.toml b/pyproject.toml index 925b3df..989c1f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,11 +6,6 @@ requires-python = ">=3.10" dependencies = [ "pyyaml>=6.0", "pyrage>=1.2", -] - -[project.optional-dependencies] -# `devbase env export/import` の S3 backend (`s3://`) を使う場合のみ必要 -s3 = [ "boto3>=1.34", ] diff --git a/tests/env/test_storage.py b/tests/env/test_storage.py index 8657e65..0f32e3e 100644 --- a/tests/env/test_storage.py +++ b/tests/env/test_storage.py @@ -448,26 +448,6 @@ def test_s3_backend_read_wraps_unknown_error(): backend.read_bytes("s3://bucket/k") -def test_s3_backend_raises_clear_error_when_boto3_missing(monkeypatch): - """boto3 が無い環境では分かりやすいエラーメッセージで失敗する""" - backend = storage.S3Backend() - - # boto3 import を強制的に ImportError にする - import builtins - real_import = builtins.__import__ - - def fake_import(name, *args, **kwargs): - if name == 'boto3' or name.startswith('boto3.'): - raise ImportError("boto3 not installed (simulated)") - return real_import(name, *args, **kwargs) - - monkeypatch.setattr(builtins, '__import__', fake_import) - with pytest.raises(storage.StorageError, match=r"devbase\[s3\]") as ei: - backend.write_bytes("s3://bucket/k", b"x") - # boto3 という名前も併記されていること - assert "boto3" in str(ei.value) - - def test_s3_backend_get_client_passes_endpoint_and_region(monkeypatch): """S3Options.endpoint_url / region が boto3.client へ正しく渡る""" backend = storage.S3Backend(storage.S3Options( diff --git a/uv.lock b/uv.lock index 2909338..98b3471 100644 --- a/uv.lock +++ b/uv.lock @@ -44,15 +44,11 @@ name = "devbase" version = "2.2.0" source = { virtual = "." } dependencies = [ + { name = "boto3" }, { name = "pyrage" }, { name = "pyyaml" }, ] -[package.optional-dependencies] -s3 = [ - { name = "boto3" }, -] - [package.dev-dependencies] dev = [ { name = "pytest" }, @@ -60,11 +56,10 @@ dev = [ [package.metadata] requires-dist = [ - { name = "boto3", marker = "extra == 's3'", specifier = ">=1.34" }, + { name = "boto3", specifier = ">=1.34" }, { name = "pyrage", specifier = ">=1.2" }, { name = "pyyaml", specifier = ">=6.0" }, ] -provides-extras = ["s3"] [package.metadata.requires-dev] dev = [{ name = "pytest", specifier = ">=8.0" }]