From 560cea968a1138771cb29a3e9842bae77356a1fe Mon Sep 17 00:00:00 2001 From: corgab Date: Sat, 5 Sep 2026 21:18:38 +0000 Subject: [PATCH 1/2] fix: make aether:install safe to re-run and truthful about the environment The command no longer overwrites an existing config/aether.php: it keeps the file, asks first when the terminal is interactive, and takes --force to overwrite deliberately. The smoke test always runs on the local simulator, never on the configured default driver, so an AETHER_DRIVER=aws installation cannot submit a billable task; after the command creates a virtual environment the smoke test runs through that interpreter instead of the one configured at boot, so the documented happy path succeeds. The SDK probe reads importlib.metadata.version("amazon-braket-sdk"), since the braket namespace package has no __version__ and the old probe reported NOT INSTALLED on every machine. The installed version is compared with the floor pinned in bin/python/requirements.txt and an upgrade hint is printed when it is too old. A failed venv creation now falls back to the manual instructions instead of advertising an interpreter that does not exist. Closes #40 Closes #41 Closes #71 --- CLAUDE.md | 1 + README.md | 7 +- src/Commands/AetherInstallCommand.php | 181 ++++++++++++++--- .../Commands/AetherInstallCommandTest.php | 186 +++++++++++++++++- 4 files changed, 345 insertions(+), 30 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index bdc8121..027e3fe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,6 +53,7 @@ Quantum (Facade) - **PythonBridge** only passes non-null env vars to preserve boto3 credential chain (IAM Roles). - **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. +- **aether:install never touches the default driver:** the smoke test runs on the local simulator, through the venv interpreter when the command just created one; an existing config/aether.php is kept unless --force. ## Config diff --git a/README.md b/README.md index a59f1ac..11ee23a 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,12 @@ Run the install command to publish the config, check Python dependencies, and ve php artisan aether:install ``` -This will optionally create a `.aether-venv` virtual environment and install the required Python packages for you. +This will: + +- Publish `config/aether.php` — kept as-is if it already exists, unless you pass `--force` to overwrite it. +- Check the configured Python interpreter and compare the installed `amazon-braket-sdk` version against the floor pinned in `bin/python/requirements.txt`. +- Optionally create a `.aether-venv` virtual environment and install the required Python packages for you. +- Run a one-qubit smoke test on the local simulator, through the interpreter it just verified. This never runs on your configured default driver, so it never submits a billable task even if `AETHER_DRIVER` is set to `aws`. ## Configuration diff --git a/src/Commands/AetherInstallCommand.php b/src/Commands/AetherInstallCommand.php index d373a2a..fc7999a 100644 --- a/src/Commands/AetherInstallCommand.php +++ b/src/Commands/AetherInstallCommand.php @@ -4,6 +4,10 @@ namespace Aether\Commands; +use Aether\Bridge\PythonBridge; +use Aether\Circuit\CircuitBuilder; +use Aether\Contracts\QuantumDevice; +use Aether\Drivers\LocalSimulatorDriver; use Aether\QuantumManager; use Illuminate\Console\Command; use Symfony\Component\Process\Process; @@ -19,7 +23,7 @@ class AetherInstallCommand extends Command * * @var string */ - protected $signature = 'aether:install'; + protected $signature = 'aether:install {--force : Overwrite an existing config/aether.php}'; /** * The console command description. @@ -40,18 +44,19 @@ public function handle(QuantumManager $manager): int $pythonPath = (string) config('aether.python_path', 'python3'); $pythonOk = $this->checkPython($pythonPath); + $venvPython = null; if ($pythonOk) { $depsOk = $this->checkDependencies($pythonPath); if (! $depsOk) { - $this->handleMissingDependencies($pythonPath); + $venvPython = $this->handleMissingDependencies($pythonPath); } } $this->suggestGitignore(); - if ($pythonOk && ! $this->runTestCircuit($manager)) { + if ($pythonOk && ! $this->verifyInstallation($manager, $venvPython)) { $this->components->warn( 'The test circuit failed to run. Check that your Python dependencies are ' .'installed correctly, or that AETHER_PYTHON_PATH points to a valid interpreter.' @@ -67,9 +72,30 @@ public function handle(QuantumManager $manager): int /** * Publish the Aether configuration file. + * + * An existing config/aether.php is kept unless --force is given. When + * kept, and the input is interactive, the user is asked whether to + * overwrite it; a non-interactive run keeps it without asking. */ protected function publishConfig(): void { + $configExists = file_exists(config_path('aether.php')); + + $shouldPublish = ! $configExists || $this->option('force'); + + if ($configExists && ! $this->option('force') && $this->input->isInteractive()) { + $shouldPublish = $this->components->confirm( + 'config/aether.php already exists. Overwrite it with the package default?', + false + ); + } + + if (! $shouldPublish) { + $this->components->twoColumnDetail('Config file', 'KEPT (pass --force to overwrite)'); + + return; + } + $this->call('vendor:publish', [ '--tag' => 'aether-config', '--force' => true, @@ -103,36 +129,73 @@ protected function checkPython(string $pythonPath): bool } /** - * Check whether the required Python dependencies (braket) are installed. + * Check whether the required Python dependencies (amazon-braket-sdk) are + * installed, and whether the installed version meets the floor pinned in + * bin/python/requirements.txt. + * + * braket itself is a namespace package with no __version__ attribute, so + * the probe reads the installed distribution's version through + * importlib.metadata instead of importing braket directly. */ protected function checkDependencies(string $pythonPath): bool { $process = new Process([ - $pythonPath, '-c', 'import braket; print(braket.__version__)', + $pythonPath, '-c', 'import importlib.metadata as m; print(m.version("amazon-braket-sdk"))', ]); $process->run(); - if ($process->isSuccessful()) { - $version = trim($process->getOutput()); - $this->components->twoColumnDetail('amazon-braket-sdk', "{$version}"); + if (! $process->isSuccessful()) { + $this->components->twoColumnDetail('amazon-braket-sdk', 'NOT INSTALLED'); + + return false; + } + + $version = trim($process->getOutput()); + $floor = static::parseRequirementsFloor((string) file_get_contents(self::requirementsPath())); + + if ($floor !== null && ! version_compare($version, $floor, '>=')) { + $this->components->twoColumnDetail( + 'amazon-braket-sdk', + "{$version} (requires >= {$floor})" + ); + + $this->components->warn( + 'An upgrade is available. Run: pip install --upgrade -r '.self::requirementsPath() + ); return true; } - $this->components->twoColumnDetail('amazon-braket-sdk', 'NOT INSTALLED'); + $this->components->twoColumnDetail('amazon-braket-sdk', "{$version}"); - return false; + return true; + } + + /** + * Parse the minimum amazon-braket-sdk version pinned in a + * requirements.txt file's contents, or null when no floor is pinned. + */ + protected static function parseRequirementsFloor(string $contents): ?string + { + if (preg_match('/^amazon-braket-sdk\s*>=\s*([\d.]+)/m', $contents, $matches) === 1) { + return $matches[1]; + } + + return null; } /** * Handle missing Python dependencies. + * + * Returns the venv interpreter path when a virtual environment was + * created and its dependencies installed successfully, otherwise null. */ - protected function handleMissingDependencies(string $pythonPath): void + protected function handleMissingDependencies(string $pythonPath): ?string { if (! $this->input->isInteractive()) { $this->showManualInstructions(); - return; + return null; } $createVenv = $this->components->confirm( @@ -140,10 +203,12 @@ protected function handleMissingDependencies(string $pythonPath): void ); if ($createVenv) { - $this->createVenv($pythonPath); - } else { - $this->showManualInstructions(); + return $this->createVenv($pythonPath); } + + $this->showManualInstructions(); + + return null; } /** @@ -167,17 +232,49 @@ protected function suggestGitignore(): void } /** - * Run a minimal test circuit to verify the installation end-to-end. + * Resolve the local-simulator device to run the smoke test through, and + * run it. Never resolves the configured default driver — through the + * venv interpreter when createVenv() just produced one, otherwise + * through the local driver directly — so it never submits a billable + * task even when AETHER_DRIVER is set to 'aws'. + * + * Resolving the device is wrapped in the same failure handling as the + * test circuit itself: a device that fails to resolve counts as a + * failed smoke test rather than an uncaught exception. */ - protected function runTestCircuit(QuantumManager $manager): bool + protected function verifyInstallation(QuantumManager $manager, ?string $venvPython): bool + { + try { + $device = $venvPython !== null + ? new LocalSimulatorDriver( + new PythonBridge($venvPython, (int) config('aether.process_timeout', 300)), + (array) config('aether.drivers.local', []), + ) + : $manager->driver('local'); + } catch (Throwable) { + return false; + } + + return $this->runTestCircuit($device); + } + + /** + * Run a minimal test circuit on the local simulator to verify the + * installation end-to-end. + * + * This never touches the configured default driver — it verifies the + * Python bridge, nothing else — so it never submits a billable task even + * when AETHER_DRIVER is set to 'aws'. + */ + protected function runTestCircuit(QuantumDevice $device): bool { $succeeded = false; // components->task() renders the DONE/FAIL line but does not return // the callback's result, so it is captured via reference instead. - $this->components->task('Running test circuit', function () use ($manager, &$succeeded): bool { + $this->components->task('Running test circuit', function () use ($device, &$succeeded): bool { try { - $manager->circuit()->qubits(1)->h(0)->measure()->run(); + (new CircuitBuilder($device, 'local'))->qubits(1)->h(0)->measure()->run(); $succeeded = true; } catch (Throwable) { $succeeded = false; @@ -191,28 +288,50 @@ protected function runTestCircuit(QuantumManager $manager): bool /** * Create a Python virtual environment and install Aether's dependencies. + * + * Returns the venv's interpreter path when both steps succeeded, so the + * caller can run the rest of the installation through it instead of the + * interpreter that was configured when the process started; returns null + * when either step failed. */ - protected function createVenv(string $pythonPath): void + protected function createVenv(string $pythonPath): ?string { $venvPath = base_path('.aether-venv'); $venvPython = $this->venvPythonPath($venvPath); - $requirementsPath = __DIR__.'/../../bin/python/requirements.txt'; + $requirementsPath = self::requirementsPath(); + + $venvCreated = false; - $this->components->task('Creating virtual environment', function () use ($pythonPath, $venvPath): bool { + $this->components->task('Creating virtual environment', function () use ($pythonPath, $venvPath, &$venvCreated): bool { $process = new Process([$pythonPath, '-m', 'venv', $venvPath]); $process->run(); - return $process->isSuccessful(); + $venvCreated = $process->isSuccessful(); + + return $venvCreated; }); - $this->components->task('Installing Python dependencies', function () use ($venvPython, $requirementsPath): bool { + $depsInstalled = false; + + $this->components->task('Installing Python dependencies', function () use ($venvPython, $requirementsPath, &$depsInstalled): bool { $process = new Process([$venvPython, '-m', 'pip', 'install', '-r', $requirementsPath]); $process->setTimeout(300); $process->run(); - return $process->isSuccessful(); + $depsInstalled = $process->isSuccessful(); + + return $depsInstalled; }); + if (! $venvCreated || ! $depsInstalled) { + $this->components->warn( + 'The virtual environment could not be prepared. Fix the error above, or follow the manual steps:' + ); + $this->showManualInstructions(); + + return null; + } + $this->components->twoColumnDetail( 'Add to .env', "AETHER_PYTHON_PATH={$venvPython}" @@ -222,6 +341,8 @@ protected function createVenv(string $pythonPath): void "Never commit your .env file. Add the following line manually:\n" ." AETHER_PYTHON_PATH={$venvPython}" ); + + return $venvPython; } /** @@ -229,7 +350,7 @@ protected function createVenv(string $pythonPath): void */ protected function showManualInstructions(): void { - $requirementsPath = __DIR__.'/../../bin/python/requirements.txt'; + $requirementsPath = self::requirementsPath(); $this->components->warn('Manual installation required. Run the following commands:'); $this->line(''); @@ -252,4 +373,12 @@ protected function venvPythonPath(string $venvPath): string return $venvPath.'/bin/python'; } + + /** + * Return the absolute path to the package's bin/python/requirements.txt. + */ + private static function requirementsPath(): string + { + return __DIR__.'/../../bin/python/requirements.txt'; + } } diff --git a/tests/Feature/Commands/AetherInstallCommandTest.php b/tests/Feature/Commands/AetherInstallCommandTest.php index 95a0a8d..d93c407 100644 --- a/tests/Feature/Commands/AetherInstallCommandTest.php +++ b/tests/Feature/Commands/AetherInstallCommandTest.php @@ -2,25 +2,71 @@ declare(strict_types=1); +use Aether\Commands\AetherInstallCommand; use Aether\Facades\Quantum; use Aether\QuantumManager; +use Illuminate\Console\Command; +use Illuminate\Support\Facades\Artisan; /** - * Bind a QuantumManager double whose circuit() call always throws, simulating - * a failed test circuit without depending on a real Python/braket install. + * Bind a QuantumManager double whose driver('local') call always throws, + * simulating a failed test circuit without depending on a real Python/braket + * install. */ function bindFailingQuantumManager(): void { $manager = Mockery::mock(QuantumManager::class); - $manager->shouldReceive('circuit')->andThrow(new RuntimeException('boom')); + $manager->shouldReceive('driver')->with('local')->andThrow(new RuntimeException('boom')); app()->instance(QuantumManager::class, $manager); } +/** + * Create a throwaway executable that stands in for the python interpreter + * aether:install shells out to. Answers `--version` with "Python 3.12.0" on + * stdout, and `-c` (the amazon-braket-sdk version probe) with the given + * stdout/exit code so checkDependencies()'s branches can be exercised + * without a real Python/braket environment. + */ +function fakeInstallInterpreter(string $probeOutput, int $probeExitCode = 0): string +{ + $path = tempnam(sys_get_temp_dir(), 'aether_install_fakepy_'); + $body = <<assertSuccessful() ->expectsOutputToContain('Aether installation complete'); }); + +// ------------------------------------------------------------------------- +// Issue #40 — config overwritten without confirmation +// ------------------------------------------------------------------------- + +it('keeps an existing config file when no --force is given', function () { + file_put_contents(config_path('aether.php'), " 'kept'];\n"); + + try { + $this->artisan('aether:install', ['--no-interaction' => true]) + ->assertSuccessful() + ->expectsOutputToContain('KEPT'); + + expect(file_get_contents(config_path('aether.php')))->toContain('marker'); + } finally { + @unlink(config_path('aether.php')); + } +}); + +it('overwrites an existing config file when --force is given', function () { + file_put_contents(config_path('aether.php'), " 'kept'];\n"); + + try { + $this->artisan('aether:install', ['--no-interaction' => true, '--force' => true]) + ->assertSuccessful() + ->expectsOutputToContain('PUBLISHED'); + + expect(file_get_contents(config_path('aether.php')))->not->toContain('marker'); + } finally { + @unlink(config_path('aether.php')); + } +}); + +it('publishes the config file when it does not exist yet', function () { + expect(file_exists(config_path('aether.php')))->toBeFalse(); + + $this->artisan('aether:install', ['--no-interaction' => true]) + ->assertSuccessful() + ->expectsOutputToContain('PUBLISHED'); +}); + +it('asks for confirmation and keeps the config file when the user declines to overwrite', function () { + file_put_contents(config_path('aether.php'), " 'kept'];\n"); + + // Reports amazon-braket-sdk as already installed and up to date, so the + // (also interactive) "create a virtual environment?" question never + // comes up — this test is only about the config confirmation. + $floor = RequirementsFloorProbe::floor( + (string) file_get_contents(__DIR__.'/../../../bin/python/requirements.txt') + ); + config(['aether.python_path' => fakeInstallInterpreter($floor)]); + + try { + $this->artisan('aether:install') + ->expectsConfirmation('config/aether.php already exists. Overwrite it with the package default?', 'no') + ->assertSuccessful() + ->expectsOutputToContain('KEPT'); + + expect(file_get_contents(config_path('aether.php')))->toContain('marker'); + } finally { + @unlink(config_path('aether.php')); + } +}); + +// ------------------------------------------------------------------------- +// Issue #71 — braket detection +// ------------------------------------------------------------------------- + +afterEach(function () { + foreach (glob(sys_get_temp_dir().'/aether_install_fakepy_*') ?: [] as $tmp) { + @unlink($tmp); + } +}); + +it('reports amazon-braket-sdk as installed when the version meets the required floor', function () { + $floor = RequirementsFloorProbe::floor( + (string) file_get_contents(__DIR__.'/../../../bin/python/requirements.txt') + ); + + config(['aether.python_path' => fakeInstallInterpreter($floor)]); + + $this->artisan('aether:install', ['--no-interaction' => true]) + ->assertSuccessful() + ->expectsOutputToContain($floor) + ->doesntExpectOutputToContain('NOT INSTALLED'); +}); + +it('warns when the installed amazon-braket-sdk version is older than the required floor', function () { + $floor = RequirementsFloorProbe::floor( + (string) file_get_contents(__DIR__.'/../../../bin/python/requirements.txt') + ); + + config(['aether.python_path' => fakeInstallInterpreter('1.0.0')]); + + // Uses Artisan::call()/output() instead of $this->artisan()->expectsOutputToContain(): + // that assertion checks each write() call individually, and the + // terminal-width-aware rendering of components->twoColumnDetail() can + // word-wrap this long value across more than one write, splitting the + // floor version across two chunks and making a substring check flake. + $exitCode = Artisan::call('aether:install', ['--no-interaction' => true]); + $output = Artisan::output(); + + expect($exitCode)->toBe(Command::SUCCESS); + expect($output)->toContain('requires'); + expect($output)->toContain($floor); +}); + +it('reports amazon-braket-sdk as not installed when the probe fails', function () { + config(['aether.python_path' => fakeInstallInterpreter('', 1)]); + + $this->artisan('aether:install', ['--no-interaction' => true]) + ->assertSuccessful() + ->expectsOutputToContain('NOT INSTALLED') + ->expectsOutputToContain('python3 -m venv .aether-venv'); +}); + +it('parses the amazon-braket-sdk floor from a requirements.txt-shaped string', function () { + expect(RequirementsFloorProbe::floor("amazon-braket-sdk>=1.80.0\nnumpy>=2.5.2\n"))->toBe('1.80.0'); + expect(RequirementsFloorProbe::floor("amazon-braket-sdk >= 2.0.0\n"))->toBe('2.0.0'); + expect(RequirementsFloorProbe::floor("numpy>=2.5.2\n"))->toBeNull(); +}); + +// ------------------------------------------------------------------------- +// Issue #41 — smoke test never touches the configured default driver +// ------------------------------------------------------------------------- + +it('runs the smoke test on the local simulator even when the default driver is aws', function () { + config(['aether.default' => 'aws']); + + $this->artisan('aether:install', ['--no-interaction' => true]) + ->assertSuccessful(); + + Quantum::assertCircuitRan(fn ($circuit) => $circuit->driverName() === 'local'); +}); From c9f76a5dbbc85586f8b18998199ababfcba3f669 Mon Sep 17 00:00:00 2001 From: corgab Date: Sat, 5 Sep 2026 21:26:31 +0000 Subject: [PATCH 2/2] fix: tighten aether:install around the configured interpreter The upgrade hint and the manual instructions now name the configured interpreter and the platform's venv layout instead of a bare pip, python3 and a Unix-only path. A missing interpreter makes the command fail instead of reporting a complete installation, and a failed venv creation no longer runs the dependency install against an interpreter that does not exist. QuantumManager::localDriver() and bridge() accept an interpreter path, so the smoke test after venv creation reuses the manager's own wiring rather than a second copy of it. The tests assert that the smoke test resolves driver('local') on the manager, cover a smoke test whose run throws and a missing interpreter, and remove only the fake interpreters they created. --- src/Commands/AetherInstallCommand.php | 56 +++++++++------- src/QuantumManager.php | 32 ++++++--- .../Commands/AetherInstallCommandTest.php | 67 +++++++++++++++++-- 3 files changed, 117 insertions(+), 38 deletions(-) diff --git a/src/Commands/AetherInstallCommand.php b/src/Commands/AetherInstallCommand.php index fc7999a..8ac4eee 100644 --- a/src/Commands/AetherInstallCommand.php +++ b/src/Commands/AetherInstallCommand.php @@ -4,10 +4,8 @@ namespace Aether\Commands; -use Aether\Bridge\PythonBridge; use Aether\Circuit\CircuitBuilder; use Aether\Contracts\QuantumDevice; -use Aether\Drivers\LocalSimulatorDriver; use Aether\QuantumManager; use Illuminate\Console\Command; use Symfony\Component\Process\Process; @@ -56,7 +54,13 @@ public function handle(QuantumManager $manager): int $this->suggestGitignore(); - if ($pythonOk && ! $this->verifyInstallation($manager, $venvPython)) { + if (! $pythonOk) { + $this->components->warn('Installation incomplete: no Python interpreter to run the circuits with.'); + + return self::FAILURE; + } + + if (! $this->verifyInstallation($manager, $venvPython)) { $this->components->warn( 'The test circuit failed to run. Check that your Python dependencies are ' .'installed correctly, or that AETHER_PYTHON_PATH points to a valid interpreter.' @@ -160,7 +164,8 @@ protected function checkDependencies(string $pythonPath): bool ); $this->components->warn( - 'An upgrade is available. Run: pip install --upgrade -r '.self::requirementsPath() + 'The installed amazon-braket-sdk is older than this package requires. Upgrade it with:' + ."\n {$pythonPath} -m pip install --upgrade -r ".self::requirementsPath() ); return true; @@ -193,7 +198,7 @@ protected static function parseRequirementsFloor(string $contents): ?string protected function handleMissingDependencies(string $pythonPath): ?string { if (! $this->input->isInteractive()) { - $this->showManualInstructions(); + $this->showManualInstructions($pythonPath); return null; } @@ -206,7 +211,7 @@ protected function handleMissingDependencies(string $pythonPath): ?string return $this->createVenv($pythonPath); } - $this->showManualInstructions(); + $this->showManualInstructions($pythonPath); return null; } @@ -246,10 +251,7 @@ protected function verifyInstallation(QuantumManager $manager, ?string $venvPyth { try { $device = $venvPython !== null - ? new LocalSimulatorDriver( - new PythonBridge($venvPython, (int) config('aether.process_timeout', 300)), - (array) config('aether.drivers.local', []), - ) + ? $manager->localDriver($venvPython) : $manager->driver('local'); } catch (Throwable) { return false; @@ -313,21 +315,23 @@ protected function createVenv(string $pythonPath): ?string $depsInstalled = false; - $this->components->task('Installing Python dependencies', function () use ($venvPython, $requirementsPath, &$depsInstalled): bool { - $process = new Process([$venvPython, '-m', 'pip', 'install', '-r', $requirementsPath]); - $process->setTimeout(300); - $process->run(); + if ($venvCreated) { + $this->components->task('Installing Python dependencies', function () use ($venvPython, $requirementsPath, &$depsInstalled): bool { + $process = new Process([$venvPython, '-m', 'pip', 'install', '-r', $requirementsPath]); + $process->setTimeout(300); + $process->run(); - $depsInstalled = $process->isSuccessful(); + $depsInstalled = $process->isSuccessful(); - return $depsInstalled; - }); + return $depsInstalled; + }); + } - if (! $venvCreated || ! $depsInstalled) { + if (! $depsInstalled) { $this->components->warn( 'The virtual environment could not be prepared. Fix the error above, or follow the manual steps:' ); - $this->showManualInstructions(); + $this->showManualInstructions($pythonPath); return null; } @@ -346,19 +350,21 @@ protected function createVenv(string $pythonPath): ?string } /** - * Display manual installation instructions for Python dependencies. + * Display manual installation instructions for Python dependencies, + * built around the configured interpreter and this platform's venv layout. */ - protected function showManualInstructions(): void + protected function showManualInstructions(string $pythonPath): void { - $requirementsPath = self::requirementsPath(); + $venvPath = base_path('.aether-venv'); + $venvPython = $this->venvPythonPath($venvPath); $this->components->warn('Manual installation required. Run the following commands:'); $this->line(''); - $this->line(' python3 -m venv .aether-venv'); - $this->line(' .aether-venv/bin/pip install -r '.$requirementsPath); + $this->line(" {$pythonPath} -m venv {$venvPath}"); + $this->line(" {$venvPython} -m pip install -r ".self::requirementsPath()); $this->line(''); $this->line('Then add to your .env:'); - $this->line(' AETHER_PYTHON_PATH='.base_path('.aether-venv').'/bin/python'); + $this->line(" AETHER_PYTHON_PATH={$venvPython}"); $this->line(''); } diff --git a/src/QuantumManager.php b/src/QuantumManager.php index b288eaa..c97e49e 100644 --- a/src/QuantumManager.php +++ b/src/QuantumManager.php @@ -129,9 +129,25 @@ public function fake(array|CircuitResult|Closure|ResultSequence|null $stub = nul * config('aether.drivers.ionq'), * )); */ - public function bridge(): PythonBridge + public function bridge(?string $pythonPath = null): PythonBridge { - return $this->createBridge(); + return $this->createBridge($pythonPath); + } + + /** + * Create the local simulator driver over the given interpreter, or over + * the configured one when none is given. + * + * Bypasses the fake and the resolved-driver cache on purpose: this is how + * aether:install verifies an interpreter it has just created, with the + * exact wiring the application will use afterwards. + */ + public function localDriver(?string $pythonPath = null): LocalSimulatorDriver + { + return new LocalSimulatorDriver( + $this->createBridge($pythonPath), + $this->config->get('aether.drivers.local', []), + ); } /** @@ -157,10 +173,7 @@ protected function createDriver($driver) */ protected function createLocalDriver(): LocalSimulatorDriver { - return new LocalSimulatorDriver( - $this->createBridge(), - $this->config->get('aether.drivers.local', []), - ); + return $this->localDriver(); } /** @@ -175,12 +188,13 @@ protected function createAwsDriver(): AwsBraketDriver } /** - * Create a PythonBridge configured with the python_path from config. + * Create a PythonBridge over the given interpreter, or over the + * python_path from config when none is given. */ - private function createBridge(): PythonBridge + private function createBridge(?string $pythonPath = null): PythonBridge { return new PythonBridge( - $this->config->get('aether.python_path', 'python3'), + $pythonPath ?? $this->config->get('aether.python_path', 'python3'), (int) $this->config->get('aether.process_timeout', 300), ); } diff --git a/tests/Feature/Commands/AetherInstallCommandTest.php b/tests/Feature/Commands/AetherInstallCommandTest.php index d93c407..1177a2f 100644 --- a/tests/Feature/Commands/AetherInstallCommandTest.php +++ b/tests/Feature/Commands/AetherInstallCommandTest.php @@ -31,6 +31,7 @@ function bindFailingQuantumManager(): void function fakeInstallInterpreter(string $probeOutput, int $probeExitCode = 0): string { $path = tempnam(sys_get_temp_dir(), 'aether_install_fakepy_'); + FakeInterpreters::$paths[] = $path; $body = << */ + public static array $paths = []; +} + afterEach(function () { - foreach (glob(sys_get_temp_dir().'/aether_install_fakepy_*') ?: [] as $tmp) { + foreach (FakeInterpreters::$paths as $tmp) { @unlink($tmp); } + + FakeInterpreters::$paths = []; }); it('reports amazon-braket-sdk as installed when the version meets the required floor', function () { @@ -219,7 +232,8 @@ public static function floor(string $contents): ?string $this->artisan('aether:install', ['--no-interaction' => true]) ->assertSuccessful() ->expectsOutputToContain('NOT INSTALLED') - ->expectsOutputToContain('python3 -m venv .aether-venv'); + ->expectsOutputToContain('-m venv') + ->expectsOutputToContain('AETHER_PYTHON_PATH='); }); it('parses the amazon-braket-sdk floor from a requirements.txt-shaped string', function () { @@ -232,11 +246,56 @@ public static function floor(string $contents): ?string // Issue #41 — smoke test never touches the configured default driver // ------------------------------------------------------------------------- -it('runs the smoke test on the local simulator even when the default driver is aws', function () { +it('runs the smoke test on the local driver even when the default driver is aws', function () { config(['aether.default' => 'aws']); + $fake = Quantum::fake(); + + $manager = Mockery::mock(QuantumManager::class); + $manager->shouldReceive('driver')->once()->with('local')->andReturn($fake); + $manager->shouldNotReceive('driver')->withNoArgs(); + $manager->shouldNotReceive('driver')->with('aws'); + $manager->shouldNotReceive('circuit'); + app()->instance(QuantumManager::class, $manager); $this->artisan('aether:install', ['--no-interaction' => true]) ->assertSuccessful(); - Quantum::assertCircuitRan(fn ($circuit) => $circuit->driverName() === 'local'); + $fake->assertCircuitRan(); +}); + +it('returns FAILURE when the smoke test circuit itself throws', function () { + Quantum::fake(fn () => throw new RuntimeException('boom')); + + $this->artisan('aether:install', ['--no-interaction' => true]) + ->assertFailed() + ->expectsOutputToContain('test circuit failed'); +}); + +it('returns FAILURE when the configured Python interpreter cannot be found', function () { + config(['aether.python_path' => sys_get_temp_dir().'/aether-missing-python']); + + $this->artisan('aether:install', ['--no-interaction' => true]) + ->assertFailed() + ->expectsOutputToContain('NOT FOUND') + ->doesntExpectOutputToContain('Aether installation complete'); +}); + +it('tells the user to upgrade the SDK through the configured interpreter', function () { + $python = fakeInstallInterpreter('1.0.0'); + config(['aether.python_path' => $python]); + + Artisan::call('aether:install', ['--no-interaction' => true]); + + expect(Artisan::output())->toContain("{$python} -m pip install --upgrade -r"); +}); + +it('builds the manual instructions around the configured interpreter', function () { + $python = fakeInstallInterpreter('', 1); + config(['aether.python_path' => $python]); + + Artisan::call('aether:install', ['--no-interaction' => true]); + + expect(Artisan::output()) + ->toContain("{$python} -m venv") + ->toContain('-m pip install -r'); });