diff --git a/CLAUDE.md b/CLAUDE.md index bdc8121..ec2b4a0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,7 +50,7 @@ Quantum (Facade) - **Tests use Pest PHP**, not raw PHPUnit classes. Use `it()` / `test()` with `expect()`. - **Python scripts** live in `bin/python/`, not `resources/`. Each script is self-contained (reads JSON stdin, writes JSON stdout). - **Exceptions** all extend `AetherException` with static factory methods (`::fromPythonError()`, `::forDriver()`, etc.) -- **PythonBridge** only passes non-null env vars to preserve boto3 credential chain (IAM Roles). +- **PythonBridge** adds no env vars of its own: the child gets Symfony Process's default inherited environment (boto3 credential chain, IAM Roles included, works as-is). Driver settings reach the scripts only through `driver_config` in the JSON payload. - **QPU safety:** Drivers with `synchronous_safe: false` throw on `->run()` to prevent HTTP timeouts. - **EntropyGenerator::integer()** uses rejection sampling on a 256-bit batch buffer — never modulo. diff --git a/src/Bridge/PythonBridge.php b/src/Bridge/PythonBridge.php index a7a0a0d..edab3b9 100644 --- a/src/Bridge/PythonBridge.php +++ b/src/Bridge/PythonBridge.php @@ -32,21 +32,23 @@ public function __construct( /** * Execute a Python script with the given payload. * + * The bridge adds no environment variables of its own: the child receives + * Symfony Process's default inherited environment, so boto3's credential + * chain (IAM roles, profiles, AWS_* variables) behaves exactly as it does + * for the PHP process. Driver settings travel inside the JSON payload under + * `driver_config`, which is the only channel the bin/python scripts read. + * * @param array $payload - * @param array $driverConfig * @return array * * @throws PythonEnvironmentException * @throws QuantumExecutionException */ - public function execute(string $script, array $payload, array $driverConfig = []): array + public function execute(string $script, array $payload): array { $scriptPath = $this->scriptsPath.DIRECTORY_SEPARATOR.$script; - $process = new Process( - command: [$this->pythonPath, $scriptPath], - env: $this->buildEnvironment($driverConfig), - ); + $process = new Process([$this->pythonPath, $scriptPath]); $process->setInput(json_encode($payload, JSON_THROW_ON_ERROR)); $process->setTimeout($this->timeout); @@ -148,31 +150,4 @@ public function bitstringToBytes(string $bitstring): string return $bytes; } - - /** - * Build the environment variable array for the child process. - * - * Only non-null values are included to preserve boto3's credential chain. - * - * @param array $driverConfig - * @return array - */ - public function buildEnvironment(array $driverConfig): array - { - $map = [ - 'region' => 'AWS_DEFAULT_REGION', - 'bucket' => 'AETHER_S3_BUCKET', - 'device_arn' => 'AETHER_DEVICE_ARN', - ]; - - $env = []; - - foreach ($map as $configKey => $envKey) { - if (isset($driverConfig[$configKey])) { - $env[$envKey] = (string) $driverConfig[$configKey]; - } - } - - return $env; - } } diff --git a/src/Contracts/PythonExecutor.php b/src/Contracts/PythonExecutor.php index 22ff647..74c92ab 100644 --- a/src/Contracts/PythonExecutor.php +++ b/src/Contracts/PythonExecutor.php @@ -12,11 +12,13 @@ interface PythonExecutor /** * Execute a Python script with the given payload. * + * Driver settings are part of the payload (`driver_config`); implementations + * must not need a second channel for them. + * * @param array $payload - * @param array $driverConfig * @return array */ - public function execute(string $script, array $payload, array $driverConfig = []): array; + public function execute(string $script, array $payload): array; /** * Convert a binary digit string into raw bytes. diff --git a/src/Drivers/AbstractQuantumDriver.php b/src/Drivers/AbstractQuantumDriver.php index af06efa..b0c3dc9 100644 --- a/src/Drivers/AbstractQuantumDriver.php +++ b/src/Drivers/AbstractQuantumDriver.php @@ -187,7 +187,7 @@ public function executeBatch(array $circuits): BatchResult 'circuits' => array_map(static fn (CircuitBuilder $c): array => $c->toArray(), $circuits), ]); - $response = $this->bridge->execute('batch.py', $payload, $this->config); + $response = $this->bridge->execute('batch.py', $payload); if (! array_key_exists('results', $response) || ! is_array($response['results'])) { throw QuantumExecutionException::malformedResponse( @@ -273,7 +273,7 @@ protected function runCircuit(CircuitBuilder $circuit): CircuitResult */ private function runDefinition(array $definition): CircuitResult { - $response = $this->bridge->execute('circuit.py', $this->payload($definition), $this->config); + $response = $this->bridge->execute('circuit.py', $this->payload($definition)); if (! array_key_exists('counts', $response) || ! is_array($response['counts'])) { throw QuantumExecutionException::malformedResponse( @@ -303,7 +303,7 @@ protected function submitTask(CircuitBuilder $circuit): string $this->assertConfigured(); $this->validateCircuits([$circuit]); - $response = $this->bridge->execute('submit.py', $this->payload($circuit->toArray()), $this->config); + $response = $this->bridge->execute('submit.py', $this->payload($circuit->toArray())); $taskArn = $response['task_arn'] ?? null; @@ -330,7 +330,7 @@ protected function pollTask(string $taskArn): TaskSnapshot { $this->assertConfigured(); - $response = $this->bridge->execute('check.py', $this->payload(['task_arn' => $taskArn]), $this->config); + $response = $this->bridge->execute('check.py', $this->payload(['task_arn' => $taskArn])); $status = $response['status'] ?? null; @@ -364,7 +364,7 @@ public function generateEntropy(int $bits): string 'shots' => $shots, ]); - $response = $this->bridge->execute('entropy.py', $payload, $this->config); + $response = $this->bridge->execute('entropy.py', $payload); if (! array_key_exists('bits', $response) || ! is_string($response['bits'])) { throw QuantumExecutionException::malformedResponse( diff --git a/tests/Unit/Bridge/PythonBridgeTest.php b/tests/Unit/Bridge/PythonBridgeTest.php index bcdb1b4..80c7a6f 100644 --- a/tests/Unit/Bridge/PythonBridgeTest.php +++ b/tests/Unit/Bridge/PythonBridgeTest.php @@ -188,71 +188,25 @@ function fakePython(string $shBody): string }); // ------------------------------------------------------------------------- -// buildEnvironment() +// execute() — environment // ------------------------------------------------------------------------- -it('includes only non-null values in environment', function () { - $bridge = new PythonBridge('python3'); - - $env = $bridge->buildEnvironment([ - 'region' => 'us-east-1', - 'bucket' => null, - 'device_arn' => 'arn:aws:braket:::device/quantum-simulator/amazon/sv1', - ]); - - expect($env)->toHaveKey('AWS_DEFAULT_REGION'); - expect($env['AWS_DEFAULT_REGION'])->toBe('us-east-1'); - expect($env)->not->toHaveKey('AETHER_S3_BUCKET'); - expect($env)->toHaveKey('AETHER_DEVICE_ARN'); - expect($env['AETHER_DEVICE_ARN'])->toBe('arn:aws:braket:::device/quantum-simulator/amazon/sv1'); -}); - -it('does not include AWS credentials in environment', function () { - $bridge = new PythonBridge('python3'); - - $env = $bridge->buildEnvironment([ - 'region' => 'eu-west-1', - 'AWS_ACCESS_KEY_ID' => 'AKIAIOSFODNN7EXAMPLE', - 'AWS_SECRET_ACCESS_KEY' => 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', - ]); - - expect($env)->not->toHaveKey('AWS_ACCESS_KEY_ID'); - expect($env)->not->toHaveKey('AWS_SECRET_ACCESS_KEY'); -}); - -it('returns empty array when config is empty', function () { - $bridge = new PythonBridge('python3'); +it('lets the child process see the parent environment', function () { + // Pins the guarantee execute()'s docblock makes: a variable set in the PHP + // process (AWS_PROFILE, AWS_*, IAM metadata hints boto3 relies on) reaches + // the interpreter. It asserts the observable behaviour, not how the + // Process is built. + $_ENV['AETHER_TEST_MARKER'] = 'inherited'; - $env = $bridge->buildEnvironment([]); - - expect($env)->toBe([]); -}); - -it('maps all supported keys in environment', function () { - $bridge = new PythonBridge('python3'); - - $env = $bridge->buildEnvironment([ - 'region' => 'ap-southeast-2', - 'bucket' => 'my-braket-bucket', - 'device_arn' => 'arn:aws:braket:::device/qpu/ionq/ionQdevice', - ]); - - expect($env)->toBe([ - 'AWS_DEFAULT_REGION' => 'ap-southeast-2', - 'AETHER_S3_BUCKET' => 'my-braket-bucket', - 'AETHER_DEVICE_ARN' => 'arn:aws:braket:::device/qpu/ionq/ionQdevice', - ]); -}); - -it('omits unset keys from environment', function () { - $bridge = new PythonBridge('python3'); + try { + $python = fakePython('printf \'{"marker":"%s"}\' "$AETHER_TEST_MARKER"'); - // Only region provided — bucket and device_arn not present at all. - $env = $bridge->buildEnvironment(['region' => 'us-west-2']); + $result = (new PythonBridge($python))->execute('circuit.py', ['qubits' => 1]); + } finally { + unset($_ENV['AETHER_TEST_MARKER']); + } - expect($env)->toBe(['AWS_DEFAULT_REGION' => 'us-west-2']); - expect($env)->not->toHaveKey('AETHER_S3_BUCKET'); - expect($env)->not->toHaveKey('AETHER_DEVICE_ARN'); + expect($result)->toBe(['marker' => 'inherited']); }); // ------------------------------------------------------------------------- diff --git a/tests/Unit/Drivers/AbstractQuantumDriverTest.php b/tests/Unit/Drivers/AbstractQuantumDriverTest.php index 1c79b88..2007478 100644 --- a/tests/Unit/Drivers/AbstractQuantumDriverTest.php +++ b/tests/Unit/Drivers/AbstractQuantumDriverTest.php @@ -42,8 +42,7 @@ protected function driverName(): string ->method('execute') ->with( 'circuit.py', - $this->callback(fn (array $p) => $p['driver'] === 'test' && $p['driver_config'] === ['key' => 'value']), - ['key' => 'value'] + $this->callback(fn (array $p) => $p['driver'] === 'test' && $p['driver_config'] === ['key' => 'value']) ) ->willReturn(['counts' => ['0' => 500, '1' => 500]]); @@ -74,8 +73,7 @@ protected function driverName(): string && $p['qubits'] === 16 && $p['shots'] === 1 && ! array_key_exists('bits', $p); - }), - ['key' => 'value', 'entropy_qubits' => 16] + }) ) ->willReturn(['bits' => str_repeat('1', 16)]); @@ -96,8 +94,7 @@ protected function driverName(): string ->method('execute') ->with( 'entropy.py', - $this->callback(fn (array $p) => $p['qubits'] === 16 && $p['shots'] === 16), - ['key' => 'value', 'entropy_qubits' => 16] + $this->callback(fn (array $p) => $p['qubits'] === 16 && $p['shots'] === 16) ) ->willReturn(['bits' => str_repeat('10', 128)]); @@ -110,8 +107,7 @@ protected function driverName(): string ->method('execute') ->with( 'entropy.py', - $this->callback(fn (array $p) => $p['qubits'] === 16 && $p['shots'] === 1), - ['key' => 'value'] + $this->callback(fn (array $p) => $p['qubits'] === 16 && $p['shots'] === 1) ) ->willReturn(['bits' => str_repeat('1', 16)]); @@ -250,8 +246,7 @@ protected function driverName(): string ->method('execute') ->with( 'entropy.py', - $this->callback(fn (array $p) => $p['qubits'] === 16 && $p['shots'] === 1), - ['entropy_qubits' => 0] + $this->callback(fn (array $p) => $p['qubits'] === 16 && $p['shots'] === 1) ) ->willReturn(['bits' => str_repeat('1', 16)]); @@ -272,8 +267,7 @@ protected function driverName(): string ->method('execute') ->with( 'entropy.py', - $this->callback(fn (array $p) => $p['qubits'] === 16), - ['entropy_qubits' => -4] + $this->callback(fn (array $p) => $p['qubits'] === 16) ) ->willReturn(['bits' => str_repeat('1', 16)]); @@ -294,8 +288,7 @@ protected function driverName(): string && $p['circuits'] === [ ['qubits' => 1, 'gates' => [], 'shots' => 1000], ['qubits' => 2, 'gates' => [], 'shots' => 10], - ]), - ['key' => 'value'] + ]) ) ->willReturn(['results' => [ ['counts' => ['0' => 500, '1' => 500]], diff --git a/tests/Unit/Drivers/AwsBraketDriverTest.php b/tests/Unit/Drivers/AwsBraketDriverTest.php index 548904b..8403cb7 100644 --- a/tests/Unit/Drivers/AwsBraketDriverTest.php +++ b/tests/Unit/Drivers/AwsBraketDriverTest.php @@ -79,7 +79,7 @@ $this->bridge->expects($this->once()) ->method('execute') - ->with('circuit.py', $expectedPayload, $this->config) + ->with('circuit.py', $expectedPayload) ->willReturn(['counts' => ['00' => 512, '11' => 512]]); $result = $driver->executeCircuit($circuit); @@ -132,8 +132,7 @@ ->method('execute') ->with( 'entropy.py', - ['qubits' => 16, 'shots' => 1, 'driver' => 'aws', 'driver_config' => $this->config], - $this->config + ['qubits' => 16, 'shots' => 1, 'driver' => 'aws', 'driver_config' => $this->config] ) ->willReturn(['bits' => '1011001110100101']); @@ -265,7 +264,7 @@ $this->bridge->expects($this->once()) ->method('execute') - ->with('submit.py', $expectedPayload, $this->config) + ->with('submit.py', $expectedPayload) ->willReturn(['task_arn' => 'arn:aws:braket:us-east-1:123456789012:quantum-task/abc']); $taskArn = $driver->submitCircuit($circuit); @@ -282,7 +281,7 @@ $this->bridge->expects($this->once()) ->method('execute') - ->with('submit.py', $this->anything(), $config) + ->with('submit.py', $this->anything()) ->willReturn(['task_arn' => 'arn:aws:braket:us-east-1:123456789012:quantum-task/async']); $taskArn = $driver->submitCircuit($circuit); @@ -356,8 +355,7 @@ ->method('execute') ->with( 'check.py', - ['task_arn' => 'arn:aws:braket:us-east-1:123456789012:quantum-task/abc', 'driver' => 'aws', 'driver_config' => $this->config], - $this->config + ['task_arn' => 'arn:aws:braket:us-east-1:123456789012:quantum-task/abc', 'driver' => 'aws', 'driver_config' => $this->config] ) ->willReturn(['status' => 'RUNNING']); diff --git a/tests/Unit/Drivers/LocalSimulatorDriverTest.php b/tests/Unit/Drivers/LocalSimulatorDriverTest.php index 8dc0b81..c293dfc 100644 --- a/tests/Unit/Drivers/LocalSimulatorDriverTest.php +++ b/tests/Unit/Drivers/LocalSimulatorDriverTest.php @@ -85,7 +85,7 @@ $this->bridge->expects($this->once()) ->method('execute') - ->with('circuit.py', $expectedPayload, $this->config) + ->with('circuit.py', $expectedPayload) ->willReturn(['counts' => ['00' => 512, '11' => 512]]); $result = $this->driver->executeCircuit($circuit); @@ -114,8 +114,7 @@ ->method('execute') ->with( 'entropy.py', - ['qubits' => 16, 'shots' => 1, 'driver' => 'local', 'driver_config' => $this->config], - $this->config + ['qubits' => 16, 'shots' => 1, 'driver' => 'local', 'driver_config' => $this->config] ) ->willReturn(['bits' => '1011001110100101']); @@ -187,7 +186,7 @@ $this->bridge->expects($this->once()) ->method('execute') - ->with('circuit.py', $this->anything(), $this->config) + ->with('circuit.py', $this->anything()) ->willReturn(['counts' => ['0' => 100]]); $this->driver->submitCircuit($circuit);