From 865cbd28491556f9f63b7c74cbd2b3ed5f2f5e57 Mon Sep 17 00:00:00 2001 From: corgab Date: Tue, 8 Sep 2026 17:59:51 +0000 Subject: [PATCH 1/2] fix: point DriverNotFoundException at the right cause The message always told the developer to check 'aether.default', even when the unknown name was passed explicitly to Quantum::driver('ionq') or Quantum::circuit('ionq'), which is the common case during development and has nothing to do with that setting. Manager resolves a null argument to the default before createDriver() runs, so an unknown name that equals the default now blames the aether.default setting, and any other unknown name points at Quantum::extend() and a possible typo, naming the built-in drivers. Closes #50 --- src/Exceptions/DriverNotFoundException.php | 16 +++++++++++-- src/QuantumManager.php | 7 +++++- tests/Feature/QuantumManagerTest.php | 24 +++++++++++++++++++ .../Exceptions/ExceptionHierarchyTest.php | 17 +++++++++++-- 4 files changed, 59 insertions(+), 5 deletions(-) diff --git a/src/Exceptions/DriverNotFoundException.php b/src/Exceptions/DriverNotFoundException.php index 1ea354a..b51a632 100644 --- a/src/Exceptions/DriverNotFoundException.php +++ b/src/Exceptions/DriverNotFoundException.php @@ -10,10 +10,22 @@ class DriverNotFoundException extends AetherException { /** - * Create an exception for an unknown driver name. + * Create an exception for a driver name a caller asked for explicitly. */ public static function forDriver(string $name): self { - return new self("Quantum driver [{$name}] is not registered. Check your 'aether.default' configuration."); + return new self( + "Quantum driver [{$name}] is not registered. The built-in drivers are 'local' and 'aws'; register a custom one with Quantum::extend('{$name}', ...) or check the name for a typo." + ); + } + + /** + * Create an exception for a default driver that resolves to nothing. + */ + public static function forDefaultDriver(string $name): self + { + return new self( + "Quantum driver [{$name}] is configured as the default but is not registered. Check the 'aether.default' setting (AETHER_DRIVER) in config/aether.php, or register the driver with Quantum::extend()." + ); } } diff --git a/src/QuantumManager.php b/src/QuantumManager.php index b288eaa..e2c8a6d 100644 --- a/src/QuantumManager.php +++ b/src/QuantumManager.php @@ -149,7 +149,12 @@ protected function createDriver($driver) return $this->$method(); } - throw DriverNotFoundException::forDriver($driver); + // Manager resolves a null argument to the default before calling us, so + // an unknown name that equals the default points at configuration; any + // other unknown name was asked for explicitly by the caller. + throw $driver === $this->getDefaultDriver() + ? DriverNotFoundException::forDefaultDriver($driver) + : DriverNotFoundException::forDriver($driver); } /** diff --git a/tests/Feature/QuantumManagerTest.php b/tests/Feature/QuantumManagerTest.php index 0f99548..d669c33 100644 --- a/tests/Feature/QuantumManagerTest.php +++ b/tests/Feature/QuantumManagerTest.php @@ -24,6 +24,30 @@ expect(app(QuantumManager::class)->driver('aws'))->toBeInstanceOf(AwsBraketDriver::class); }); +it('blames the driver name, not the default setting, when an explicit driver is unknown', function () { + config(['aether.default' => 'local']); + + try { + app(QuantumManager::class)->driver('ionq'); + $this->fail('Expected DriverNotFoundException.'); + } catch (DriverNotFoundException $e) { + expect($e->getMessage()) + ->toContain("Quantum::extend('ionq'") + ->not->toContain('aether.default'); + } +}); + +it('blames the aether.default setting when the configured default driver is unknown', function () { + config(['aether.default' => 'ionq']); + + try { + app(QuantumManager::class)->driver(); + $this->fail('Expected DriverNotFoundException.'); + } catch (DriverNotFoundException $e) { + expect($e->getMessage())->toContain('aether.default'); + } +}); + it('throws DriverNotFoundException for unknown driver', function () { app(QuantumManager::class)->driver('unknown'); })->throws(DriverNotFoundException::class); diff --git a/tests/Unit/Exceptions/ExceptionHierarchyTest.php b/tests/Unit/Exceptions/ExceptionHierarchyTest.php index 10064e7..964f844 100644 --- a/tests/Unit/Exceptions/ExceptionHierarchyTest.php +++ b/tests/Unit/Exceptions/ExceptionHierarchyTest.php @@ -77,11 +77,24 @@ expect(is_subclass_of(DriverNotFoundException::class, AetherException::class))->toBeTrue(); }); -it('for driver includes driver name', function (): void { +it('for driver includes driver name and points at registration, not at the default setting', function (): void { $exception = DriverNotFoundException::forDriver('braket'); expect($exception)->toBeInstanceOf(DriverNotFoundException::class); - expect($exception->getMessage())->toContain('braket'); + expect($exception->getMessage()) + ->toContain('braket') + ->toContain("Quantum::extend('braket'") + ->not->toContain('aether.default'); +}); + +it('for default driver points at the aether.default setting', function (): void { + $exception = DriverNotFoundException::forDefaultDriver('braket'); + + expect($exception)->toBeInstanceOf(DriverNotFoundException::class); + expect($exception->getMessage()) + ->toContain('braket') + ->toContain('aether.default') + ->toContain('AETHER_DRIVER'); }); // ------------------------------------------------------------------------- From 9bcb3387a55a7bbc62491bd6263ec036e9f09893 Mon Sep 17 00:00:00 2001 From: corgab Date: Tue, 8 Sep 2026 18:06:28 +0000 Subject: [PATCH 2/2] fix: keep driver resolution robust before choosing the not-found message Comparing the unknown name with getDefaultDriver() exposed two latent faults: a null aether.default returned null from a string-typed method, and a blank one made Str::studly('') resolve to createDriver itself. The default now falls back to 'local' for null or blank values, and an empty name can no longer match a method. Enum-backed driver arguments arrive as their raw value, so createDriver() casts once before matching creators or building the message. The explicit-name message lists the drivers that do resolve, built-ins and extend()ed ones alike, from the manager instead of a hard-coded pair. --- src/Exceptions/DriverNotFoundException.php | 10 ++++- src/QuantumManager.php | 37 +++++++++++++++---- tests/Feature/QuantumManagerTest.php | 24 ++++++++++++ .../Exceptions/ExceptionHierarchyTest.php | 6 +++ 4 files changed, 67 insertions(+), 10 deletions(-) diff --git a/src/Exceptions/DriverNotFoundException.php b/src/Exceptions/DriverNotFoundException.php index b51a632..83d2995 100644 --- a/src/Exceptions/DriverNotFoundException.php +++ b/src/Exceptions/DriverNotFoundException.php @@ -11,11 +11,17 @@ class DriverNotFoundException extends AetherException { /** * Create an exception for a driver name a caller asked for explicitly. + * + * @param list $available The names that do resolve, so a typo is easy to spot. */ - public static function forDriver(string $name): self + public static function forDriver(string $name, array $available = []): self { + $known = $available === [] + ? '' + : ' Registered drivers: '.implode(', ', array_map(static fn (string $driver): string => "'{$driver}'", $available)).'.'; + return new self( - "Quantum driver [{$name}] is not registered. The built-in drivers are 'local' and 'aws'; register a custom one with Quantum::extend('{$name}', ...) or check the name for a typo." + "Quantum driver [{$name}] is not registered.{$known} Register a custom one with Quantum::extend('{$name}', ...) or check the name for a typo." ); } diff --git a/src/QuantumManager.php b/src/QuantumManager.php index e2c8a6d..af74752 100644 --- a/src/QuantumManager.php +++ b/src/QuantumManager.php @@ -32,7 +32,11 @@ class QuantumManager extends Manager */ public function getDefaultDriver(): string { - return $this->config->get('aether.default', 'local'); + // A null or blank aether.default (an empty AETHER_DRIVER= line) must + // still resolve to a driver, not surface later as a TypeError. + $default = $this->config->get('aether.default'); + + return is_string($default) && $default !== '' ? $default : 'local'; } /** @@ -139,22 +143,39 @@ public function bridge(): PythonBridge */ protected function createDriver($driver) { - if (isset($this->customCreators[$driver])) { - return $this->callCustomCreator($driver); + // Manager has already turned an enum into its value, which may be an int. + $name = (string) $driver; + + if (isset($this->customCreators[$name])) { + return $this->callCustomCreator($name); } - $method = 'create'.Str::studly($driver).'Driver'; + $method = 'create'.Str::studly($name).'Driver'; - if (method_exists($this, $method)) { + if ($name !== '' && method_exists($this, $method)) { return $this->$method(); } // Manager resolves a null argument to the default before calling us, so // an unknown name that equals the default points at configuration; any // other unknown name was asked for explicitly by the caller. - throw $driver === $this->getDefaultDriver() - ? DriverNotFoundException::forDefaultDriver($driver) - : DriverNotFoundException::forDriver($driver); + throw $name === $this->getDefaultDriver() + ? DriverNotFoundException::forDefaultDriver($name) + : DriverNotFoundException::forDriver($name, $this->availableDrivers()); + } + + /** + * The driver names that resolve today: the built-ins plus every extend()ed one. + * + * @return list + */ + private function availableDrivers(): array + { + return array_values(array_unique([ + 'local', + 'aws', + ...array_map(strval(...), array_keys($this->customCreators)), + ])); } /** diff --git a/tests/Feature/QuantumManagerTest.php b/tests/Feature/QuantumManagerTest.php index d669c33..2b7a2a6 100644 --- a/tests/Feature/QuantumManagerTest.php +++ b/tests/Feature/QuantumManagerTest.php @@ -37,6 +37,30 @@ } }); +it('names the extended drivers in the message for an unknown explicit driver', function () { + $manager = app(QuantumManager::class); + $manager->extend('ionq', fn () => Mockery::mock(QuantumDevice::class)); + + try { + $manager->driver('ionk'); + $this->fail('Expected DriverNotFoundException.'); + } catch (DriverNotFoundException $e) { + expect($e->getMessage())->toContain("'ionq'"); + } +}); + +it('falls back to the local driver when aether.default is null or blank', function (mixed $default) { + config(['aether.default' => $default]); + + expect(app(QuantumManager::class)->driver())->toBeInstanceOf(LocalSimulatorDriver::class); +})->with(['null' => [null], 'blank' => ['']]); + +it('still throws DriverNotFoundException for an unknown driver when aether.default is null', function () { + config(['aether.default' => null]); + + expect(fn () => app(QuantumManager::class)->driver('ionq'))->toThrow(DriverNotFoundException::class); +}); + it('blames the aether.default setting when the configured default driver is unknown', function () { config(['aether.default' => 'ionq']); diff --git a/tests/Unit/Exceptions/ExceptionHierarchyTest.php b/tests/Unit/Exceptions/ExceptionHierarchyTest.php index 964f844..74e9176 100644 --- a/tests/Unit/Exceptions/ExceptionHierarchyTest.php +++ b/tests/Unit/Exceptions/ExceptionHierarchyTest.php @@ -87,6 +87,12 @@ ->not->toContain('aether.default'); }); +it('for driver lists the registered drivers when given', function (): void { + $exception = DriverNotFoundException::forDriver('ionk', ['local', 'aws', 'ionq']); + + expect($exception->getMessage())->toContain("Registered drivers: 'local', 'aws', 'ionq'."); +}); + it('for default driver points at the aether.default setting', function (): void { $exception = DriverNotFoundException::forDefaultDriver('braket');