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..8ac4eee 100644 --- a/src/Commands/AetherInstallCommand.php +++ b/src/Commands/AetherInstallCommand.php @@ -4,6 +4,8 @@ namespace Aether\Commands; +use Aether\Circuit\CircuitBuilder; +use Aether\Contracts\QuantumDevice; use Aether\QuantumManager; use Illuminate\Console\Command; use Symfony\Component\Process\Process; @@ -19,7 +21,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 +42,25 @@ 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->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.' @@ -67,9 +76,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 +133,74 @@ 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( + '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; } - $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(); + $this->showManualInstructions($pythonPath); - return; + return null; } $createVenv = $this->components->confirm( @@ -140,10 +208,12 @@ protected function handleMissingDependencies(string $pythonPath): void ); if ($createVenv) { - $this->createVenv($pythonPath); - } else { - $this->showManualInstructions(); + return $this->createVenv($pythonPath); } + + $this->showManualInstructions($pythonPath); + + return null; } /** @@ -167,17 +237,46 @@ 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 + ? $manager->localDriver($venvPython) + : $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,27 +290,51 @@ 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(); - $this->components->task('Creating virtual environment', function () use ($pythonPath, $venvPath): bool { + $venvCreated = false; + + $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 { - $process = new Process([$venvPython, '-m', 'pip', 'install', '-r', $requirementsPath]); - $process->setTimeout(300); - $process->run(); + $depsInstalled = false; - return $process->isSuccessful(); - }); + 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(); + + return $depsInstalled; + }); + } + + if (! $depsInstalled) { + $this->components->warn( + 'The virtual environment could not be prepared. Fix the error above, or follow the manual steps:' + ); + $this->showManualInstructions($pythonPath); + + return null; + } $this->components->twoColumnDetail( 'Add to .env', @@ -222,22 +345,26 @@ protected function createVenv(string $pythonPath): void "Never commit your .env file. Add the following line manually:\n" ." AETHER_PYTHON_PATH={$venvPython}" ); + + return $venvPython; } /** - * 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 = __DIR__.'/../../bin/python/requirements.txt'; + $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(''); } @@ -252,4 +379,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/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 95a0a8d..1177a2f 100644 --- a/tests/Feature/Commands/AetherInstallCommandTest.php +++ b/tests/Feature/Commands/AetherInstallCommandTest.php @@ -2,25 +2,72 @@ 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_'); + FakeInterpreters::$paths[] = $path; + $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 +// ------------------------------------------------------------------------- + +/** + * Tracks the fake interpreters created by this test file so cleanup removes + * only its own files, even under parallel test workers sharing the temp dir. + */ +final class FakeInterpreters +{ + /** @var list */ + public static array $paths = []; +} + +afterEach(function () { + foreach (FakeInterpreters::$paths as $tmp) { + @unlink($tmp); + } + + FakeInterpreters::$paths = []; +}); + +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('-m venv') + ->expectsOutputToContain('AETHER_PYTHON_PATH='); +}); + +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 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(); + + $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'); +});