From 2d1fff019d9d8fdebd23fafb1c5dddcc6ac6befd Mon Sep 17 00:00:00 2001 From: corgab Date: Tue, 8 Sep 2026 18:08:51 +0000 Subject: [PATCH 1/4] feat: make the S3 bucket optional and fall back to Braket's default bucket The aws driver refused to run without a bucket, on the PHP side through requiredConfig() and on the Python side through run_options(), even though AwsDevice.run() and run_batch() default the S3 destination to the SDK's own amazon-braket-- bucket, created on demand. A first run against the free SV1 simulator therefore failed until the user created a bucket by hand. The bucket is now an optional override: when set, results go to s3:///results as before; when unset, run_options() returns no destination and the SDK applies its default. Config comment, README and both test suites describe and cover the two branches. Closes #68 --- README.md | 4 ++-- bin/python/providers/aws.py | 10 ++++++---- config/aether.php | 2 ++ src/Drivers/AwsBraketDriver.php | 5 ++++- tests/Unit/Drivers/AwsBraketDriverTest.php | 21 ++++++++++++++++++++- tests/python/test_providers.py | 12 ++++++------ 6 files changed, 40 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index a59f1ac..c2f5172 100644 --- a/README.md +++ b/README.md @@ -45,11 +45,11 @@ For AWS Braket: ```env AETHER_DRIVER=aws AWS_DEFAULT_REGION=us-east-1 -AETHER_S3_BUCKET=your-bucket +AETHER_S3_BUCKET= # optional; leave blank to use Braket's default bucket AETHER_DEVICE_ARN=arn:aws:braket:::device/quantum-simulator/amazon/sv1 ``` -`AETHER_S3_BUCKET` is required by the `aws` driver, together with the region and the device ARN: a missing or empty value throws an `InvalidDriverConfigException` on every call. Braket writes the task results to `s3:///results`. +The `aws` driver needs the region and the device ARN; a missing or empty value for either throws an `InvalidDriverConfigException` on every call. `AETHER_S3_BUCKET` is optional: when set, Braket writes the task results to `s3:///results`; when unset, the SDK uses its own default bucket (`amazon-braket--`, created on first use), so a first run against the SV1 simulator needs no S3 setup at all. See [Choosing a Driver](#choosing-a-driver) for a comparison of the available backends. diff --git a/bin/python/providers/aws.py b/bin/python/providers/aws.py index 6cd40a5..13ba079 100644 --- a/bin/python/providers/aws.py +++ b/bin/python/providers/aws.py @@ -42,14 +42,16 @@ def resolve_device(config: dict[str, Any]) -> Any: def run_options(config: dict[str, Any]) -> dict[str, Any]: - """Return the extra ``device.run()`` kwargs: the S3 destination folder. + """Return the extra ``device.run()`` kwargs: the S3 destination folder, if any. - Raises: - ValueError: When *config* has no non-empty ``bucket``. + A configured ``bucket`` routes results to ``s3:///results``. + Without one the kwargs stay empty and the SDK falls back to its default + bucket (``amazon-braket--``, created on demand) and its + ``tasks`` folder, exactly as ``AwsDevice.run()`` does on its own. """ bucket = config.get("bucket") if not bucket: - raise ValueError("Driver 'aws' requires a non-empty 'bucket' in driver_config.") + return {} return {"s3_destination_folder": (bucket, "results")} diff --git a/config/aether.php b/config/aether.php index 4fff7db..fc7ebf8 100644 --- a/config/aether.php +++ b/config/aether.php @@ -129,6 +129,8 @@ 'aws' => [ 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + // Optional. Results go to s3:///results when set; when unset the + // Braket SDK uses its default bucket (amazon-braket--). 'bucket' => env('AETHER_S3_BUCKET'), 'device_arn' => env('AETHER_DEVICE_ARN', 'arn:aws:braket:::device/quantum-simulator/amazon/sv1'), 'synchronous_safe' => true, diff --git a/src/Drivers/AwsBraketDriver.php b/src/Drivers/AwsBraketDriver.php index ff6ce2e..cc0d496 100644 --- a/src/Drivers/AwsBraketDriver.php +++ b/src/Drivers/AwsBraketDriver.php @@ -24,11 +24,14 @@ protected function driverName(): string } /** + * The S3 bucket is optional: without one the Braket SDK writes results to + * its default bucket, amazon-braket--, created on demand. + * * @return list */ protected function requiredConfig(): array { - return ['region', 'device_arn', 'bucket']; + return ['region', 'device_arn']; } protected function beforeExecution(): void diff --git a/tests/Unit/Drivers/AwsBraketDriverTest.php b/tests/Unit/Drivers/AwsBraketDriverTest.php index 548904b..30f288f 100644 --- a/tests/Unit/Drivers/AwsBraketDriverTest.php +++ b/tests/Unit/Drivers/AwsBraketDriverTest.php @@ -221,10 +221,29 @@ } catch (InvalidDriverConfigException $e) { expect($e->getMessage())->toContain('region'); expect($e->getMessage())->toContain('device_arn'); - expect($e->getMessage())->toContain('bucket'); + expect($e->getMessage())->not->toContain('bucket'); } }); +it('runs without a bucket and leaves the S3 destination to the SDK default', function () { + $driver = new AwsBraketDriver($this->bridge, [ + 'region' => 'us-east-1', + 'device_arn' => 'arn:aws:braket:::device/quantum-simulator/amazon/sv1', + ]); + + $circuit = $this->createMock(CircuitBuilder::class); + $circuit->method('qubitCount')->willReturn(1); + $circuit->method('shotCount')->willReturn(10); + $circuit->method('toArray')->willReturn(['qubits' => 1, 'gates' => [], 'shots' => 10]); + + $this->bridge->expects($this->once()) + ->method('execute') + ->with('circuit.py', $this->anything(), $this->callback(fn (array $config): bool => ! array_key_exists('bucket', $config))) + ->willReturn(['counts' => ['0' => 10]]); + + expect($driver->executeCircuit($circuit))->toBeInstanceOf(CircuitResult::class); +}); + it('validates config on generateEntropy as well as executeCircuit', function () { $driver = new AwsBraketDriver($this->bridge, ['region' => 'us-east-1', 'bucket' => 'test-bucket']); diff --git a/tests/python/test_providers.py b/tests/python/test_providers.py index fc71e58..1bffe05 100644 --- a/tests/python/test_providers.py +++ b/tests/python/test_providers.py @@ -138,12 +138,12 @@ def test_returns_the_hook_result_as_a_dict(self): assert provider_run_options(provider, {"tag": "abc"}) == {"tag": "abc"} - def test_aws_run_options_without_bucket_raises(self): - with pytest.raises( - ValueError, - match=r"^Driver 'aws' requires a non-empty 'bucket' in driver_config\.$", - ): - provider_run_options(aws_provider, {}) + def test_aws_run_options_without_bucket_lets_the_sdk_pick_its_default(self): + assert provider_run_options(aws_provider, {}) == {} + + def test_aws_run_options_treats_a_blank_bucket_as_unset(self): + assert provider_run_options(aws_provider, {"bucket": ""}) == {} + assert provider_run_options(aws_provider, {"bucket": None}) == {} def test_aws_run_options_returns_the_s3_destination_folder(self): options = provider_run_options(aws_provider, {"bucket": "my-bucket"}) From 24593a9238167e715df7456b33a5c3328e363900 Mon Sep 17 00:00:00 2001 From: corgab Date: Tue, 8 Sep 2026 18:13:21 +0000 Subject: [PATCH 2/4] fix: treat a blank bucket as unset and document the default bucket's IAM need A bucket made of whitespace slipped past both sides once the PHP required key check was gone, so run_options() now strips the value and treats an empty result as "use the SDK default"; the PHP tests cover the null and empty-string shapes config/aether.php actually produces. The README, the config comment and the driver docblock say that the default bucket is created on first use and therefore needs s3:CreateBucket. --- README.md | 2 +- bin/python/providers/aws.py | 2 +- config/aether.php | 5 +++-- src/Drivers/AwsBraketDriver.php | 3 ++- tests/Unit/Drivers/AwsBraketDriverTest.php | 12 +++++++++--- tests/python/test_providers.py | 6 ++++++ 6 files changed, 22 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index c2f5172..ad7ceb6 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ AETHER_S3_BUCKET= # optional; leave blank to use Braket's default buc AETHER_DEVICE_ARN=arn:aws:braket:::device/quantum-simulator/amazon/sv1 ``` -The `aws` driver needs the region and the device ARN; a missing or empty value for either throws an `InvalidDriverConfigException` on every call. `AETHER_S3_BUCKET` is optional: when set, Braket writes the task results to `s3:///results`; when unset, the SDK uses its own default bucket (`amazon-braket--`, created on first use), so a first run against the SV1 simulator needs no S3 setup at all. +The `aws` driver needs the region and the device ARN; a missing or empty value for either throws an `InvalidDriverConfigException` on every call. `AETHER_S3_BUCKET` is optional: when set, Braket writes the task results to `s3:///results`; when unset or blank, the SDK uses its own default bucket (`amazon-braket--`), creating it on first use. That fallback needs `s3:CreateBucket` on the calling credentials in addition to the Braket and S3 object permissions; with a locked-down role, create the bucket yourself and set `AETHER_S3_BUCKET`. See [Choosing a Driver](#choosing-a-driver) for a comparison of the available backends. diff --git a/bin/python/providers/aws.py b/bin/python/providers/aws.py index 13ba079..2593dfe 100644 --- a/bin/python/providers/aws.py +++ b/bin/python/providers/aws.py @@ -49,7 +49,7 @@ def run_options(config: dict[str, Any]) -> dict[str, Any]: bucket (``amazon-braket--``, created on demand) and its ``tasks`` folder, exactly as ``AwsDevice.run()`` does on its own. """ - bucket = config.get("bucket") + bucket = str(config.get("bucket") or "").strip() if not bucket: return {} diff --git a/config/aether.php b/config/aether.php index fc7ebf8..8db54c2 100644 --- a/config/aether.php +++ b/config/aether.php @@ -129,8 +129,9 @@ 'aws' => [ 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), - // Optional. Results go to s3:///results when set; when unset the - // Braket SDK uses its default bucket (amazon-braket--). + // Optional. Results go to s3:///results when set; when unset or + // blank the Braket SDK uses its default bucket (amazon-braket--), + // which it creates on first use and therefore needs s3:CreateBucket for. 'bucket' => env('AETHER_S3_BUCKET'), 'device_arn' => env('AETHER_DEVICE_ARN', 'arn:aws:braket:::device/quantum-simulator/amazon/sv1'), 'synchronous_safe' => true, diff --git a/src/Drivers/AwsBraketDriver.php b/src/Drivers/AwsBraketDriver.php index cc0d496..d7c2804 100644 --- a/src/Drivers/AwsBraketDriver.php +++ b/src/Drivers/AwsBraketDriver.php @@ -25,7 +25,8 @@ protected function driverName(): string /** * The S3 bucket is optional: without one the Braket SDK writes results to - * its default bucket, amazon-braket--, created on demand. + * its default bucket, amazon-braket--, which it creates on + * first use (so the credentials need s3:CreateBucket). * * @return list */ diff --git a/tests/Unit/Drivers/AwsBraketDriverTest.php b/tests/Unit/Drivers/AwsBraketDriverTest.php index 30f288f..42278b5 100644 --- a/tests/Unit/Drivers/AwsBraketDriverTest.php +++ b/tests/Unit/Drivers/AwsBraketDriverTest.php @@ -225,10 +225,12 @@ } }); -it('runs without a bucket and leaves the S3 destination to the SDK default', function () { +it('runs without a bucket and leaves the S3 destination to the SDK default', function (array $bucket) { + // config/aether.php yields null when AETHER_S3_BUCKET is unset and '' when the line is blank. $driver = new AwsBraketDriver($this->bridge, [ 'region' => 'us-east-1', 'device_arn' => 'arn:aws:braket:::device/quantum-simulator/amazon/sv1', + ...$bucket, ]); $circuit = $this->createMock(CircuitBuilder::class); @@ -238,11 +240,15 @@ $this->bridge->expects($this->once()) ->method('execute') - ->with('circuit.py', $this->anything(), $this->callback(fn (array $config): bool => ! array_key_exists('bucket', $config))) + ->with('circuit.py', $this->anything(), $this->anything()) ->willReturn(['counts' => ['0' => 10]]); expect($driver->executeCircuit($circuit))->toBeInstanceOf(CircuitResult::class); -}); +})->with([ + 'key absent' => [[]], + 'null from an unset env var' => [['bucket' => null]], + 'empty string from a blank env line' => [['bucket' => '']], +]); it('validates config on generateEntropy as well as executeCircuit', function () { $driver = new AwsBraketDriver($this->bridge, ['region' => 'us-east-1', 'bucket' => 'test-bucket']); diff --git a/tests/python/test_providers.py b/tests/python/test_providers.py index 1bffe05..7e30eee 100644 --- a/tests/python/test_providers.py +++ b/tests/python/test_providers.py @@ -144,6 +144,12 @@ def test_aws_run_options_without_bucket_lets_the_sdk_pick_its_default(self): def test_aws_run_options_treats_a_blank_bucket_as_unset(self): assert provider_run_options(aws_provider, {"bucket": ""}) == {} assert provider_run_options(aws_provider, {"bucket": None}) == {} + assert provider_run_options(aws_provider, {"bucket": " "}) == {} + + def test_aws_run_options_trims_the_bucket_name(self): + options = provider_run_options(aws_provider, {"bucket": " my-bucket "}) + + assert options == {"s3_destination_folder": ("my-bucket", "results")} def test_aws_run_options_returns_the_s3_destination_folder(self): options = provider_run_options(aws_provider, {"bucket": "my-bucket"}) From 56fbf6b510918c7f710e859bda0432c23b9d685c Mon Sep 17 00:00:00 2001 From: corgab Date: Thu, 10 Sep 2026 13:09:04 +0200 Subject: [PATCH 3/4] Apply review feedback: normalize bucket config in PHP --- bin/python/providers/aws.py | 5 ++--- src/Drivers/AwsBraketDriver.php | 11 +++++++++++ tests/python/test_providers.py | 8 -------- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/bin/python/providers/aws.py b/bin/python/providers/aws.py index 2593dfe..b980210 100644 --- a/bin/python/providers/aws.py +++ b/bin/python/providers/aws.py @@ -49,11 +49,10 @@ def run_options(config: dict[str, Any]) -> dict[str, Any]: bucket (``amazon-braket--``, created on demand) and its ``tasks`` folder, exactly as ``AwsDevice.run()`` does on its own. """ - bucket = str(config.get("bucket") or "").strip() - if not bucket: + if "bucket" not in config: return {} - return {"s3_destination_folder": (bucket, "results")} + return {"s3_destination_folder": (config["bucket"], "results")} def run_batch( diff --git a/src/Drivers/AwsBraketDriver.php b/src/Drivers/AwsBraketDriver.php index d7c2804..73696cc 100644 --- a/src/Drivers/AwsBraketDriver.php +++ b/src/Drivers/AwsBraketDriver.php @@ -35,8 +35,19 @@ protected function requiredConfig(): array return ['region', 'device_arn']; } + protected function normalizeConfig(): void + { + $bucket = trim((string) ($this->config['bucket'] ?? '')); + if ($bucket === '') { + unset($this->config['bucket']); + } else { + $this->config['bucket'] = $bucket; + } + } + protected function beforeExecution(): void { + $this->normalizeConfig(); if (($this->config['synchronous_safe'] ?? true) === false) { throw QuantumExecutionException::synchronousUnsafe('aws'); } diff --git a/tests/python/test_providers.py b/tests/python/test_providers.py index 7e30eee..78b080b 100644 --- a/tests/python/test_providers.py +++ b/tests/python/test_providers.py @@ -141,15 +141,7 @@ def test_returns_the_hook_result_as_a_dict(self): def test_aws_run_options_without_bucket_lets_the_sdk_pick_its_default(self): assert provider_run_options(aws_provider, {}) == {} - def test_aws_run_options_treats_a_blank_bucket_as_unset(self): - assert provider_run_options(aws_provider, {"bucket": ""}) == {} - assert provider_run_options(aws_provider, {"bucket": None}) == {} - assert provider_run_options(aws_provider, {"bucket": " "}) == {} - def test_aws_run_options_trims_the_bucket_name(self): - options = provider_run_options(aws_provider, {"bucket": " my-bucket "}) - - assert options == {"s3_destination_folder": ("my-bucket", "results")} def test_aws_run_options_returns_the_s3_destination_folder(self): options = provider_run_options(aws_provider, {"bucket": "my-bucket"}) From 1c1493d1a3cd76a1e606d66a5cd90c30e76603bc Mon Sep 17 00:00:00 2001 From: corgab Date: Thu, 10 Sep 2026 11:14:51 +0000 Subject: [PATCH 4/4] fix: normalize the bucket in the constructor instead of mutating readonly $config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit added normalizeConfig(), called from beforeExecution(), which wrote into $this->config['bucket'] after construction. $config is declared readonly on AbstractQuantumDriver, so that write is illegal from a subclass method regardless of when it runs — PHPStan caught it (property.readOnlyAssignNotInConstructor) and it would also throw a real Error at runtime the first time an aws driver executed. Normalizing now happens once, in AwsBraketDriver's own constructor, before the array is ever handed to the parent (and thus stored): trim a configured bucket, or drop the key entirely when it is blank. This also fixes a gap in the previous approach — beforeExecution() only runs on the synchronous preflight, so submitCircuit()/checkTask() were sending an untrimmed or blank bucket key straight to Python. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01E8zT5sUCTC8TgcpsME4WP5 --- src/Drivers/AwsBraketDriver.php | 36 ++++++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/src/Drivers/AwsBraketDriver.php b/src/Drivers/AwsBraketDriver.php index 73696cc..7f227dd 100644 --- a/src/Drivers/AwsBraketDriver.php +++ b/src/Drivers/AwsBraketDriver.php @@ -7,6 +7,7 @@ use Aether\Circuit\CircuitBuilder; use Aether\Contracts\AsynchronousDevice; use Aether\Contracts\EstimatesCost; +use Aether\Contracts\PythonExecutor; use Aether\Exceptions\InvalidCircuitException; use Aether\Exceptions\InvalidDriverConfigException; use Aether\Exceptions\QuantumExecutionException; @@ -18,6 +19,20 @@ */ class AwsBraketDriver extends AbstractQuantumDriver implements AsynchronousDevice, EstimatesCost { + /** + * Normalizes the bucket once, before the (readonly) config array is ever + * stored, so every path — synchronous or asynchronous — sees the same + * shape. Doing this later, by writing into $this->config from a method, + * cannot work: $config is declared readonly on AbstractQuantumDriver, and + * only that class's own constructor may ever assign it. + * + * @param array $config + */ + public function __construct(PythonExecutor $bridge, array $config) + { + parent::__construct($bridge, self::normalizeBucket($config)); + } + protected function driverName(): string { return 'aws'; @@ -35,19 +50,30 @@ protected function requiredConfig(): array return ['region', 'device_arn']; } - protected function normalizeConfig(): void + /** + * Trim the configured bucket, or drop the key entirely once it is blank + * (absent, null, or whitespace-only — what env() yields for + * `AETHER_S3_BUCKET=`), so the Python side's `"bucket" not in config` + * check is the single place that decides whether one was given. + * + * @param array $config + * @return array + */ + private static function normalizeBucket(array $config): array { - $bucket = trim((string) ($this->config['bucket'] ?? '')); + $bucket = trim((string) ($config['bucket'] ?? '')); + if ($bucket === '') { - unset($this->config['bucket']); + unset($config['bucket']); } else { - $this->config['bucket'] = $bucket; + $config['bucket'] = $bucket; } + + return $config; } protected function beforeExecution(): void { - $this->normalizeConfig(); if (($this->config['synchronous_safe'] ?? true) === false) { throw QuantumExecutionException::synchronousUnsafe('aws'); }