diff --git a/README.md b/README.md index a59f1ac..ad7ceb6 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 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 6cd40a5..b980210 100644 --- a/bin/python/providers/aws.py +++ b/bin/python/providers/aws.py @@ -42,16 +42,17 @@ 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.") + 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/config/aether.php b/config/aether.php index 4fff7db..8db54c2 100644 --- a/config/aether.php +++ b/config/aether.php @@ -129,6 +129,9 @@ 'aws' => [ 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + // 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 ff6ce2e..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,17 +19,57 @@ */ 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'; } /** + * The S3 bucket is optional: without one the Braket SDK writes results to + * its default bucket, amazon-braket--, which it creates on + * first use (so the credentials need s3:CreateBucket). + * * @return list */ protected function requiredConfig(): array { - return ['region', 'device_arn', 'bucket']; + return ['region', 'device_arn']; + } + + /** + * 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) ($config['bucket'] ?? '')); + + if ($bucket === '') { + unset($config['bucket']); + } else { + $config['bucket'] = $bucket; + } + + return $config; } protected function beforeExecution(): void diff --git a/tests/Unit/Drivers/AwsBraketDriverTest.php b/tests/Unit/Drivers/AwsBraketDriverTest.php index 548904b..42278b5 100644 --- a/tests/Unit/Drivers/AwsBraketDriverTest.php +++ b/tests/Unit/Drivers/AwsBraketDriverTest.php @@ -221,10 +221,35 @@ } 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 (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); + $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->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 fc71e58..78b080b 100644 --- a/tests/python/test_providers.py +++ b/tests/python/test_providers.py @@ -138,12 +138,10 @@ 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_returns_the_s3_destination_folder(self): options = provider_run_options(aws_provider, {"bucket": "my-bucket"})