From fe545ccd0e2c14d27d4ef13de3e23d33c18f8481 Mon Sep 17 00:00:00 2001 From: corgab Date: Tue, 8 Sep 2026 18:43:44 +0000 Subject: [PATCH 1/3] refactor(config): read package settings through a typed AetherConfig service SubmitQuantumCircuit and PollQuantumTask called the global config() helper nine times between them, repeating the same default-resolution expressions, and the literal 'local' default for aether.default lived in four files. Config\AetherConfig now owns every aether.* default as a constant and exposes typed readers (defaultDriver(), queue(), pollInterval(), maxPollAttempts(), persistTasks(), pythonPath(), processTimeout(), driver()). It is a container singleton, injected into the jobs' handle() alongside QuantumManager; job constructors and tries(), which Laravel calls without injection, resolve it from the container. QuantumManager, the About section and the install command read through it too. Reads go to the repository on each call, so config()->set() in a test or a runtime change is still honoured. Closes #57 --- CLAUDE.md | 2 + src/AetherServiceProvider.php | 20 ++- src/Commands/AetherInstallCommand.php | 5 +- src/Config/AetherConfig.php | 137 ++++++++++++++++++ src/Jobs/PollQuantumTask.php | 30 ++-- src/Jobs/SubmitQuantumCircuit.php | 17 ++- src/QuantumManager.php | 20 ++- tests/Feature/AsynchronousExecutionTest.php | 4 +- tests/Feature/Jobs/PollQuantumTaskTest.php | 15 +- .../Feature/Jobs/SubmitQuantumCircuitTest.php | 7 +- tests/Feature/PersistenceTest.php | 5 +- tests/Feature/ServiceProviderTest.php | 19 +++ tests/Unit/Config/AetherConfigTest.php | 126 ++++++++++++++++ 13 files changed, 363 insertions(+), 44 deletions(-) create mode 100644 src/Config/AetherConfig.php create mode 100644 tests/Unit/Config/AetherConfigTest.php diff --git a/CLAUDE.md b/CLAUDE.md index bdc8121..46e18fe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,6 +58,8 @@ Quantum (Facade) Published to `config/aether.php`. Key settings: `default` (driver name), `python_path` (Python executable), `drivers` (per-driver config with `synchronous_safe` flag). +Package-level settings are read through `Config\AetherConfig` (a container singleton), never via `config('aether.*')` directly: it owns every default (`DEFAULT_DRIVER = 'local'`, poll interval, attempts...) and returns typed values. Jobs get it by method injection in `handle()`; constructors and `tries()` resolve it with `app(AetherConfig::class)`. + ## Testing `Quantum::fake()` replaces the manager with `QuantumFake` — same pattern as `Http::fake()`. Provides `assertCircuitRan()` and `assertEntropyGenerated()`. diff --git a/src/AetherServiceProvider.php b/src/AetherServiceProvider.php index ff0fd5d..f4a637d 100644 --- a/src/AetherServiceProvider.php +++ b/src/AetherServiceProvider.php @@ -4,7 +4,9 @@ namespace Aether; +use Aether\Config\AetherConfig; use Aether\Contracts\QuantumDevice; +use Illuminate\Contracts\Config\Repository; use Illuminate\Foundation\Console\AboutCommand; use Illuminate\Support\ServiceProvider; @@ -20,6 +22,10 @@ public function register(): void 'aether' ); + $this->app->singleton(AetherConfig::class, function ($app): AetherConfig { + return new AetherConfig($app->make(Repository::class)); + }); + $this->app->singleton(QuantumManager::class, function ($app): QuantumManager { return new QuantumManager($app); }); @@ -48,10 +54,14 @@ public function boot(): void $this->commands([Commands\AetherInstallCommand::class]); } - AboutCommand::add('Aether', fn (): array => [ - 'Default Driver' => config('aether.default', 'local'), - 'Python Path' => config('aether.python_path', 'python3'), - 'Process Timeout' => config('aether.process_timeout', 300).'s', - ]); + AboutCommand::add('Aether', function (): array { + $config = $this->app->make(AetherConfig::class); + + return [ + 'Default Driver' => $config->defaultDriver(), + 'Python Path' => $config->pythonPath(), + 'Process Timeout' => $config->processTimeout().'s', + ]; + }); } } diff --git a/src/Commands/AetherInstallCommand.php b/src/Commands/AetherInstallCommand.php index d373a2a..34ae22f 100644 --- a/src/Commands/AetherInstallCommand.php +++ b/src/Commands/AetherInstallCommand.php @@ -4,6 +4,7 @@ namespace Aether\Commands; +use Aether\Config\AetherConfig; use Aether\QuantumManager; use Illuminate\Console\Command; use Symfony\Component\Process\Process; @@ -31,13 +32,13 @@ class AetherInstallCommand extends Command /** * Execute the console command. */ - public function handle(QuantumManager $manager): int + public function handle(QuantumManager $manager, AetherConfig $config): int { $this->components->info('Installing Aether...'); $this->publishConfig(); - $pythonPath = (string) config('aether.python_path', 'python3'); + $pythonPath = $config->pythonPath(); $pythonOk = $this->checkPython($pythonPath); diff --git a/src/Config/AetherConfig.php b/src/Config/AetherConfig.php new file mode 100644 index 0000000..ae1165a --- /dev/null +++ b/src/Config/AetherConfig.php @@ -0,0 +1,137 @@ +set()) is honoured by + * the next read; nothing is snapshotted. + * + * Per-driver options are not covered: they are typed by DriverConfig, built + * by the driver itself from the array driver() returns. + */ +final class AetherConfig +{ + public const DEFAULT_DRIVER = 'local'; + + public const DEFAULT_PYTHON_PATH = 'python3'; + + public const DEFAULT_PROCESS_TIMEOUT = 300; + + public const DEFAULT_POLL_INTERVAL = 5; + + public const DEFAULT_MAX_POLL_ATTEMPTS = 720; + + public function __construct( + private readonly Repository $config, + ) {} + + /** + * Name of the driver used when none is given (`aether.default`). + * + * A blank or non-string value falls back to the default, so a stray + * `AETHER_DRIVER=` never resolves to an empty driver name. + */ + public function defaultDriver(): string + { + $driver = $this->config->get('aether.default'); + + return is_string($driver) && trim($driver) !== '' ? $driver : self::DEFAULT_DRIVER; + } + + /** + * Python executable used to run the bin/python scripts (`aether.python_path`). + */ + public function pythonPath(): string + { + $path = $this->config->get('aether.python_path'); + + return is_string($path) && trim($path) !== '' ? $path : self::DEFAULT_PYTHON_PATH; + } + + /** + * Seconds a Python subprocess may run before it is killed (`aether.process_timeout`). + */ + public function processTimeout(): int + { + return $this->integer('aether.process_timeout', self::DEFAULT_PROCESS_TIMEOUT); + } + + /** + * Queue the asynchronous jobs run on, or null for the default queue (`aether.queue`). + */ + public function queue(): ?string + { + $queue = $this->config->get('aether.queue'); + + return is_string($queue) && trim($queue) !== '' ? $queue : null; + } + + /** + * Seconds between two status checks of an asynchronous task (`aether.poll_interval`). + */ + public function pollInterval(): int + { + return $this->integer('aether.poll_interval', self::DEFAULT_POLL_INTERVAL); + } + + /** + * Number of status checks before the polling job gives up (`aether.max_poll_attempts`). + */ + public function maxPollAttempts(): int + { + return $this->integer('aether.max_poll_attempts', self::DEFAULT_MAX_POLL_ATTEMPTS); + } + + /** + * Whether asynchronous tasks are mirrored into the quantum_tasks table (`aether.persist_tasks`). + * + * Accepts real booleans and the string spellings env() produces; anything + * unrecognised counts as disabled, the safe default for a feature that + * needs a migration to have run. + */ + public function persistTasks(): bool + { + $value = $this->config->get('aether.persist_tasks', false); + + return is_scalar($value) && filter_var($value, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) === true; + } + + /** + * Raw options for one driver (`aether.drivers.`), for the driver to type. + * + * @return array + */ + public function driver(string $name): array + { + $options = $this->config->get("aether.drivers.{$name}", []); + + return is_array($options) ? $options : []; + } + + /** + * Read an integer option, falling back to $default when blank or not numeric. + */ + private function integer(string $key, int $default): int + { + $value = $this->config->get($key); + + if (is_int($value)) { + return $value; + } + + $filtered = is_scalar($value) && ! is_bool($value) + ? filter_var($value, FILTER_VALIDATE_INT) + : false; + + return $filtered === false ? $default : $filtered; + } +} diff --git a/src/Jobs/PollQuantumTask.php b/src/Jobs/PollQuantumTask.php index 9a4c0b2..6f77ab1 100644 --- a/src/Jobs/PollQuantumTask.php +++ b/src/Jobs/PollQuantumTask.php @@ -4,6 +4,7 @@ namespace Aether\Jobs; +use Aether\Config\AetherConfig; use Aether\Contracts\AsynchronousDevice; use Aether\Contracts\QuantumDevice; use Aether\Events\CircuitCompleted; @@ -55,23 +56,28 @@ public function __construct( public readonly array $circuit, public readonly ?string $driver = null, ) { - $this->onQueue(config('aether.queue')); + // Constructors get no method injection, so the queue name is resolved + // from the container by hand; tries() below is in the same position. + $this->onQueue(app(AetherConfig::class)->queue()); } /** * Determine the number of times the job may be attempted. + * + * Called by the queue worker with no arguments, so the setting is read + * from the container rather than injected. */ public function tries(): int { - return (int) config('aether.max_poll_attempts', 720); + return app(AetherConfig::class)->maxPollAttempts(); } /** * Execute the job. */ - public function handle(QuantumManager $manager, Dispatcher $events): void + public function handle(QuantumManager $manager, Dispatcher $events, AetherConfig $config): void { - $driverName = $this->driver ?? config('aether.default', 'local'); + $driverName = $this->driver ?? $config->defaultDriver(); $device = $manager->driver($this->driver); if (! $device instanceof AsynchronousDevice || ! $device instanceof QuantumDevice) { @@ -85,19 +91,19 @@ public function handle(QuantumManager $manager, Dispatcher $events): void if ($this->attempts() >= $maxAttempts) { $e = QuantumExecutionException::pollingExhausted($this->taskArn, $this->attempts()); - $this->persist($snapshot->status, null, $e->getMessage()); + $this->persist($config, $snapshot->status, null, $e->getMessage()); throw $e; } - $this->persist($snapshot->status); - $this->release((int) config('aether.poll_interval', 5)); + $this->persist($config, $snapshot->status); + $this->release($config->pollInterval()); return; } if (! $snapshot->status->isSuccessful()) { $e = TaskFailedException::forTask($this->taskArn, $snapshot->status); - $this->persist($snapshot->status, null, $e->getMessage()); + $this->persist($config, $snapshot->status, null, $e->getMessage()); throw $e; } @@ -106,11 +112,11 @@ public function handle(QuantumManager $manager, Dispatcher $events): void 'checkTask', "task [{$this->taskArn}] completed but returned no measurement counts." ); - $this->persist($snapshot->status, null, $e->getMessage()); + $this->persist($config, $snapshot->status, null, $e->getMessage()); throw $e; } - $this->persist($snapshot->status, $snapshot->counts); + $this->persist($config, $snapshot->status, $snapshot->counts); $events->dispatch(new CircuitCompleted( $driverName, @@ -132,9 +138,9 @@ public function handle(QuantumManager $manager, Dispatcher $events): void * * @param array|null $counts */ - private function persist(TaskStatus $status, ?array $counts = null, ?string $error = null): void + private function persist(AetherConfig $config, TaskStatus $status, ?array $counts = null, ?string $error = null): void { - if (! config('aether.persist_tasks', false)) { + if (! $config->persistTasks()) { return; } diff --git a/src/Jobs/SubmitQuantumCircuit.php b/src/Jobs/SubmitQuantumCircuit.php index 539ded3..5937248 100644 --- a/src/Jobs/SubmitQuantumCircuit.php +++ b/src/Jobs/SubmitQuantumCircuit.php @@ -5,6 +5,7 @@ namespace Aether\Jobs; use Aether\Circuit\CircuitBuilder; +use Aether\Config\AetherConfig; use Aether\Contracts\AsynchronousDevice; use Aether\Contracts\QuantumDevice; use Aether\Exceptions\QuantumExecutionException; @@ -44,15 +45,17 @@ public function __construct( public readonly array $circuit, public readonly ?string $driver = null, ) { - $this->onQueue(config('aether.queue')); + // Constructors get no method injection, so the queue name is the one + // setting resolved from the container by hand. + $this->onQueue(app(AetherConfig::class)->queue()); } /** * Execute the job. */ - public function handle(QuantumManager $manager): void + public function handle(QuantumManager $manager, AetherConfig $config): void { - $driverName = $this->driver ?? config('aether.default', 'local'); + $driverName = $this->driver ?? $config->defaultDriver(); $device = $manager->driver($this->driver); if (! $device instanceof AsynchronousDevice || ! $device instanceof QuantumDevice) { @@ -63,10 +66,10 @@ public function handle(QuantumManager $manager): void $taskArn = $device->submitCircuit($builder); - $this->persistSubmission($taskArn, $driverName); + $this->persistSubmission($config, $taskArn, $driverName); PollQuantumTask::dispatch($taskArn, $this->circuit, $this->driver) - ->delay((int) config('aether.poll_interval', 5)); + ->delay($config->pollInterval()); } /** @@ -77,9 +80,9 @@ public function handle(QuantumManager $manager): void * a database failure is reported and swallowed rather than allowed to * retry the job and submit a second billable task. */ - private function persistSubmission(string $taskArn, string $driverName): void + private function persistSubmission(AetherConfig $config, string $taskArn, string $driverName): void { - if (! config('aether.persist_tasks', false)) { + if (! $config->persistTasks()) { return; } diff --git a/src/QuantumManager.php b/src/QuantumManager.php index b288eaa..e23729a 100644 --- a/src/QuantumManager.php +++ b/src/QuantumManager.php @@ -7,6 +7,7 @@ use Aether\Bridge\PythonBridge; use Aether\Circuit\BatchBuilder; use Aether\Circuit\CircuitBuilder; +use Aether\Config\AetherConfig; use Aether\Drivers\AwsBraketDriver; use Aether\Drivers\LocalSimulatorDriver; use Aether\Entropy\EntropyGenerator; @@ -32,7 +33,7 @@ class QuantumManager extends Manager */ public function getDefaultDriver(): string { - return $this->config->get('aether.default', 'local'); + return $this->settings()->defaultDriver(); } /** @@ -159,7 +160,7 @@ protected function createLocalDriver(): LocalSimulatorDriver { return new LocalSimulatorDriver( $this->createBridge(), - $this->config->get('aether.drivers.local', []), + $this->settings()->driver('local'), ); } @@ -170,7 +171,7 @@ protected function createAwsDriver(): AwsBraketDriver { return new AwsBraketDriver( $this->createBridge(), - $this->config->get('aether.drivers.aws', []), + $this->settings()->driver('aws'), ); } @@ -180,8 +181,17 @@ protected function createAwsDriver(): AwsBraketDriver private function createBridge(): PythonBridge { return new PythonBridge( - $this->config->get('aether.python_path', 'python3'), - (int) $this->config->get('aether.process_timeout', 300), + $this->settings()->pythonPath(), + $this->settings()->processTimeout(), ); } + + /** + * The typed package settings, resolved from the container on each call so + * a config value changed after the manager was built is still honoured. + */ + private function settings(): AetherConfig + { + return $this->container->make(AetherConfig::class); + } } diff --git a/tests/Feature/AsynchronousExecutionTest.php b/tests/Feature/AsynchronousExecutionTest.php index 572da08..63d406b 100644 --- a/tests/Feature/AsynchronousExecutionTest.php +++ b/tests/Feature/AsynchronousExecutionTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Aether\Config\AetherConfig; use Aether\Contracts\PythonExecutor; use Aether\Drivers\LocalSimulatorDriver; use Aether\Events\CircuitCompleted; @@ -51,7 +52,7 @@ $circuit = Quantum::circuit('local')->qubits(1)->h(0)->measure()->shots(100); // Stage one: the submission job hands the task off to the backend. - (new SubmitQuantumCircuit($circuit->toArray(), 'local'))->handle(app(QuantumManager::class)); + (new SubmitQuantumCircuit($circuit->toArray(), 'local'))->handle(app(QuantumManager::class), app(AetherConfig::class)); $arn = null; @@ -65,6 +66,7 @@ (new PollQuantumTask($arn, $circuit->toArray(), 'local'))->handle( app(QuantumManager::class), app(Dispatcher::class), + app(AetherConfig::class), ); Event::assertDispatched(CircuitCompleted::class, function (CircuitCompleted $event) use ($arn): bool { diff --git a/tests/Feature/Jobs/PollQuantumTaskTest.php b/tests/Feature/Jobs/PollQuantumTaskTest.php index e9222c2..53eccca 100644 --- a/tests/Feature/Jobs/PollQuantumTaskTest.php +++ b/tests/Feature/Jobs/PollQuantumTaskTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Aether\Config\AetherConfig; use Aether\Events\CircuitCompleted; use Aether\Exceptions\QuantumExecutionException; use Aether\Exceptions\TaskFailedException; @@ -39,7 +40,7 @@ $manager->extend('fake-async', fn () => $device); $job = (new PollQuantumTask($device->taskArnToReturn, ['qubits' => 2, 'gates' => [], 'shots' => 100], 'fake-async'))->withFakeQueueInteractions(); - $job->handle($manager, app(Dispatcher::class)); + $job->handle($manager, app(Dispatcher::class), app(AetherConfig::class)); $job->assertReleased(delay: 3); }); @@ -62,7 +63,7 @@ $job->setJob($mockJob); try { - $job->handle($manager, app(Dispatcher::class)); + $job->handle($manager, app(Dispatcher::class), app(AetherConfig::class)); $this->fail('Expected QuantumExecutionException to be thrown.'); } catch (QuantumExecutionException $exception) { expect($exception->getMessage())->toContain($device->taskArnToReturn); @@ -78,7 +79,7 @@ $job = new PollQuantumTask($device->taskArnToReturn, ['qubits' => 2, 'gates' => [], 'shots' => 100], 'fake-async'); - $job->handle($manager, app(Dispatcher::class)); + $job->handle($manager, app(Dispatcher::class), app(AetherConfig::class)); })->with([TaskStatus::Failed, TaskStatus::Cancelled])->throws(TaskFailedException::class); it('dispatches CircuitCompleted with the counts and task arn once completed', function () { @@ -92,7 +93,7 @@ $circuit = ['qubits' => 2, 'gates' => [], 'shots' => 10]; $job = new PollQuantumTask($device->taskArnToReturn, $circuit, 'fake-async'); - $job->handle($manager, app(Dispatcher::class)); + $job->handle($manager, app(Dispatcher::class), app(AetherConfig::class)); Event::assertDispatched( CircuitCompleted::class, @@ -114,7 +115,7 @@ $manager->extend('fake-async', fn () => $device); $job = new PollQuantumTask($device->taskArnToReturn, ['qubits' => 1, 'gates' => [], 'shots' => 1]); - $job->handle($manager, app(Dispatcher::class)); + $job->handle($manager, app(Dispatcher::class), app(AetherConfig::class)); Event::assertDispatched( CircuitCompleted::class, @@ -131,7 +132,7 @@ $job = new PollQuantumTask($device->taskArnToReturn, ['qubits' => 2, 'gates' => [], 'shots' => 100], 'fake-async'); - $job->handle($manager, app(Dispatcher::class)); + $job->handle($manager, app(Dispatcher::class), app(AetherConfig::class)); })->throws(QuantumExecutionException::class); it('throws asynchronousUnsupported when the resolved driver does not support async execution', function () { @@ -140,5 +141,5 @@ $manager->extend('fake-sync', fn () => $device); $job = new PollQuantumTask('arn:fake', ['qubits' => 2, 'gates' => [], 'shots' => 100], 'fake-sync'); - $job->handle($manager, app(Dispatcher::class)); + $job->handle($manager, app(Dispatcher::class), app(AetherConfig::class)); })->throws(QuantumExecutionException::class); diff --git a/tests/Feature/Jobs/SubmitQuantumCircuitTest.php b/tests/Feature/Jobs/SubmitQuantumCircuitTest.php index c662632..5b812a0 100644 --- a/tests/Feature/Jobs/SubmitQuantumCircuitTest.php +++ b/tests/Feature/Jobs/SubmitQuantumCircuitTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Aether\Circuit\CircuitBuilder; +use Aether\Config\AetherConfig; use Aether\Exceptions\QuantumExecutionException; use Aether\Jobs\PollQuantumTask; use Aether\Jobs\SubmitQuantumCircuit; @@ -21,7 +22,7 @@ $manager->extend('fake-async', fn () => $device); $job = new SubmitQuantumCircuit(['qubits' => 2, 'gates' => [], 'shots' => 100], 'fake-async'); - $job->handle($manager); + $job->handle($manager, app(AetherConfig::class)); expect($device->submittedCircuits)->toHaveCount(1) ->and($device->submittedCircuits[0])->toBeInstanceOf(CircuitBuilder::class); @@ -40,7 +41,7 @@ $manager->extend('fake-sync', fn () => $device); $job = new SubmitQuantumCircuit(['qubits' => 2, 'gates' => [], 'shots' => 100], 'fake-sync'); - $job->handle($manager); + $job->handle($manager, app(AetherConfig::class)); })->throws(QuantumExecutionException::class); it('mentions the unsupported driver name in the exception message', function () { @@ -51,7 +52,7 @@ $job = new SubmitQuantumCircuit(['qubits' => 2, 'gates' => [], 'shots' => 100], 'fake-sync'); try { - $job->handle($manager); + $job->handle($manager, app(AetherConfig::class)); $this->fail('Expected QuantumExecutionException to be thrown.'); } catch (QuantumExecutionException $exception) { expect($exception->getMessage())->toContain('fake-sync'); diff --git a/tests/Feature/PersistenceTest.php b/tests/Feature/PersistenceTest.php index 34e4bc4..4284032 100644 --- a/tests/Feature/PersistenceTest.php +++ b/tests/Feature/PersistenceTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Aether\Config\AetherConfig; use Aether\Events\CircuitCompleted; use Aether\Exceptions\QuantumExecutionException; use Aether\Exceptions\TaskFailedException; @@ -39,7 +40,7 @@ // Submit through the real job, then hand back the poll job it queued so // each test can drive the polling state machine directly. $this->submit = function (): PollQuantumTask { - (new SubmitQuantumCircuit($this->circuit, 'fake-async'))->handle($this->manager); + (new SubmitQuantumCircuit($this->circuit, 'fake-async'))->handle($this->manager, app(AetherConfig::class)); $pollJob = null; Queue::assertPushed(PollQuantumTask::class, function (PollQuantumTask $job) use (&$pollJob) { @@ -51,7 +52,7 @@ return $pollJob; }; - $this->poll = fn (PollQuantumTask $job) => $job->handle($this->manager, app(Dispatcher::class)); + $this->poll = fn (PollQuantumTask $job) => $job->handle($this->manager, app(Dispatcher::class), app(AetherConfig::class)); }); // ------------------------------------------------------------------------- diff --git a/tests/Feature/ServiceProviderTest.php b/tests/Feature/ServiceProviderTest.php index a462f4e..23afae5 100644 --- a/tests/Feature/ServiceProviderTest.php +++ b/tests/Feature/ServiceProviderTest.php @@ -4,6 +4,7 @@ use Aether\AetherServiceProvider; use Aether\Circuit\CircuitBuilder; +use Aether\Config\AetherConfig; use Aether\Contracts\QuantumDevice; use Aether\Entropy\EntropyGenerator; use Aether\Facades\Quantum; @@ -14,6 +15,24 @@ // Service container registration // ------------------------------------------------------------------------- +it('registers AetherConfig as a singleton in the container', function () { + $first = $this->app->make(AetherConfig::class); + $second = $this->app->make(AetherConfig::class); + + expect($first)->toBeInstanceOf(AetherConfig::class) + ->and($first)->toBe($second); +}); + +it('resolves the default driver through AetherConfig', function () { + config()->set('aether.default', ''); + + expect(app(QuantumManager::class)->getDefaultDriver())->toBe('local'); + + config()->set('aether.default', 'aws'); + + expect(app(QuantumManager::class)->getDefaultDriver())->toBe('aws'); +}); + it('registers QuantumManager as a singleton in the container', function () { $first = $this->app->make(QuantumManager::class); $second = $this->app->make(QuantumManager::class); diff --git a/tests/Unit/Config/AetherConfigTest.php b/tests/Unit/Config/AetherConfigTest.php new file mode 100644 index 0000000..691ceb2 --- /dev/null +++ b/tests/Unit/Config/AetherConfigTest.php @@ -0,0 +1,126 @@ + $aether + */ +function aetherConfig(array $aether): AetherConfig +{ + return new AetherConfig(new Repository(['aether' => $aether])); +} + +// ------------------------------------------------------------------------- +// Defaults +// ------------------------------------------------------------------------- + +it('returns the documented defaults when nothing is configured', function () { + $config = aetherConfig([]); + + expect($config->defaultDriver())->toBe('local') + ->and($config->pythonPath())->toBe('python3') + ->and($config->processTimeout())->toBe(300) + ->and($config->queue())->toBeNull() + ->and($config->pollInterval())->toBe(5) + ->and($config->maxPollAttempts())->toBe(720) + ->and($config->persistTasks())->toBeFalse() + ->and($config->driver('local'))->toBe([]); +}); + +it('exposes each default as a constant so the literal lives in one place', function () { + expect(AetherConfig::DEFAULT_DRIVER)->toBe('local') + ->and(AetherConfig::DEFAULT_PYTHON_PATH)->toBe('python3') + ->and(AetherConfig::DEFAULT_PROCESS_TIMEOUT)->toBe(300) + ->and(AetherConfig::DEFAULT_POLL_INTERVAL)->toBe(5) + ->and(AetherConfig::DEFAULT_MAX_POLL_ATTEMPTS)->toBe(720); +}); + +// ------------------------------------------------------------------------- +// Configured values +// ------------------------------------------------------------------------- + +it('returns the configured values with their documented types', function () { + $config = aetherConfig([ + 'default' => 'aws', + 'python_path' => '/opt/venv/bin/python', + 'process_timeout' => 45, + 'queue' => 'quantum', + 'poll_interval' => 3, + 'max_poll_attempts' => 12, + 'persist_tasks' => true, + 'drivers' => ['aws' => ['region' => 'eu-west-1']], + ]); + + expect($config->defaultDriver())->toBe('aws') + ->and($config->pythonPath())->toBe('/opt/venv/bin/python') + ->and($config->processTimeout())->toBe(45) + ->and($config->queue())->toBe('quantum') + ->and($config->pollInterval())->toBe(3) + ->and($config->maxPollAttempts())->toBe(12) + ->and($config->persistTasks())->toBeTrue() + ->and($config->driver('aws'))->toBe(['region' => 'eu-west-1']); +}); + +it('casts the numeric strings env() hands over', function () { + $config = aetherConfig(['process_timeout' => '45', 'poll_interval' => '3', 'max_poll_attempts' => '12']); + + expect($config->processTimeout())->toBe(45) + ->and($config->pollInterval())->toBe(3) + ->and($config->maxPollAttempts())->toBe(12); +}); + +it('falls back to the default for a blank or non-numeric integer option', function (mixed $raw) { + $config = aetherConfig(['poll_interval' => $raw, 'max_poll_attempts' => $raw, 'process_timeout' => $raw]); + + expect($config->pollInterval())->toBe(5) + ->and($config->maxPollAttempts())->toBe(720) + ->and($config->processTimeout())->toBe(300); +})->with(['null' => [null], 'empty string' => [''], 'word' => ['soon'], 'boolean' => [true], 'array' => [[5]]]); + +it('treats a blank default driver, python path or queue as unset', function (mixed $raw) { + $config = aetherConfig(['default' => $raw, 'python_path' => $raw, 'queue' => $raw]); + + expect($config->defaultDriver())->toBe('local') + ->and($config->pythonPath())->toBe('python3') + ->and($config->queue())->toBeNull(); +})->with(['null' => [null], 'empty string' => [''], 'whitespace' => [' '], 'array' => [['aws']]]); + +it('reads persist_tasks from booleans and their env() spellings', function (mixed $raw, bool $expected) { + expect(aetherConfig(['persist_tasks' => $raw])->persistTasks())->toBe($expected); +})->with([ + 'true' => [true, true], + '"true"' => ['true', true], + '"1"' => ['1', true], + 'false' => [false, false], + '"false"' => ['false', false], + '"0"' => ['0', false], + 'null' => [null, false], + 'garbage' => ['maybe', false], +]); + +it('returns an empty array for a driver entry that is missing or not an array', function () { + $config = aetherConfig(['drivers' => ['aws' => 'oops']]); + + expect($config->driver('aws'))->toBe([]) + ->and($config->driver('ionq'))->toBe([]); +}); + +// ------------------------------------------------------------------------- +// Live reads +// ------------------------------------------------------------------------- + +it('reads the repository on every call instead of snapshotting', function () { + $repository = new Repository(['aether' => ['poll_interval' => 5]]); + $config = new AetherConfig($repository); + + expect($config->pollInterval())->toBe(5); + + $repository->set('aether.poll_interval', 9); + + expect($config->pollInterval())->toBe(9); +}); From ae3e0e2b4de401b299e2c18317231370c4070ba3 Mon Sep 17 00:00:00 2001 From: corgab Date: Tue, 8 Sep 2026 18:50:23 +0000 Subject: [PATCH 2/3] fix(config): trim string settings, use the injected settings in handle(), and keep the manager container-independent - defaultDriver(), pythonPath() and queue() return the trimmed value, so a padded env value no longer leaks whitespace into driver names or the Process command. - PollQuantumTask::handle() budgets attempts from the injected AetherConfig instead of re-resolving it through tries(). - QuantumManager::settings() falls back to building the reader over its own config repository when the container has no binding, so a bare container with only 'config' bound still resolves drivers. - local_task_ttl joins AetherConfig (localTaskTtl()); the extend() examples in the bridge() docblock and README read the driver array through it. --- CLAUDE.md | 2 +- README.md | 2 +- src/Config/AetherConfig.php | 39 ++++++++++++++----- src/Drivers/LocalSimulatorDriver.php | 3 +- src/Jobs/PollQuantumTask.php | 2 +- src/QuantumManager.php | 23 ++++++++--- tests/Feature/Jobs/PollQuantumTaskTest.php | 23 +++++++++++ tests/Feature/ServiceProviderTest.php | 13 +++++++ tests/Unit/Config/AetherConfigTest.php | 17 ++++++-- .../Unit/Drivers/LocalSimulatorDriverTest.php | 5 ++- 10 files changed, 106 insertions(+), 23 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 46e18fe..7bc1ae5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,7 +58,7 @@ Quantum (Facade) Published to `config/aether.php`. Key settings: `default` (driver name), `python_path` (Python executable), `drivers` (per-driver config with `synchronous_safe` flag). -Package-level settings are read through `Config\AetherConfig` (a container singleton), never via `config('aether.*')` directly: it owns every default (`DEFAULT_DRIVER = 'local'`, poll interval, attempts...) and returns typed values. Jobs get it by method injection in `handle()`; constructors and `tries()` resolve it with `app(AetherConfig::class)`. +Package-level settings are read through `Config\AetherConfig` (a container singleton), never via `config('aether.*')` directly: it owns every top-level default (`DEFAULT_DRIVER = 'local'`, poll interval, attempts, local task TTL...) and returns typed values. Jobs get it by method injection in `handle()`; constructors, `tries()` and drivers resolve it with `app(AetherConfig::class)`. Per-driver options (`aether.drivers.*`) are the driver's own business. ## Testing diff --git a/README.md b/README.md index a59f1ac..9a57168 100644 --- a/README.md +++ b/README.md @@ -264,7 +264,7 @@ use Aether\Facades\Quantum; Quantum::extend('ionq', fn () => new IonqDriver( Quantum::bridge(), - config('aether.drivers.ionq'), + app(\Aether\Config\AetherConfig::class)->driver('ionq'), )); ``` diff --git a/src/Config/AetherConfig.php b/src/Config/AetherConfig.php index ae1165a..b27d677 100644 --- a/src/Config/AetherConfig.php +++ b/src/Config/AetherConfig.php @@ -30,6 +30,8 @@ final class AetherConfig public const DEFAULT_MAX_POLL_ATTEMPTS = 720; + public const DEFAULT_LOCAL_TASK_TTL = 3600; + public function __construct( private readonly Repository $config, ) {} @@ -42,9 +44,7 @@ public function __construct( */ public function defaultDriver(): string { - $driver = $this->config->get('aether.default'); - - return is_string($driver) && trim($driver) !== '' ? $driver : self::DEFAULT_DRIVER; + return $this->string('aether.default') ?? self::DEFAULT_DRIVER; } /** @@ -52,9 +52,7 @@ public function defaultDriver(): string */ public function pythonPath(): string { - $path = $this->config->get('aether.python_path'); - - return is_string($path) && trim($path) !== '' ? $path : self::DEFAULT_PYTHON_PATH; + return $this->string('aether.python_path') ?? self::DEFAULT_PYTHON_PATH; } /** @@ -70,9 +68,7 @@ public function processTimeout(): int */ public function queue(): ?string { - $queue = $this->config->get('aether.queue'); - - return is_string($queue) && trim($queue) !== '' ? $queue : null; + return $this->string('aether.queue'); } /** @@ -105,6 +101,15 @@ public function persistTasks(): bool return is_scalar($value) && filter_var($value, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) === true; } + /** + * Seconds the local simulator keeps a dispatched result available to the + * polling job (`aether.local_task_ttl`). + */ + public function localTaskTtl(): int + { + return $this->integer('aether.local_task_ttl', self::DEFAULT_LOCAL_TASK_TTL); + } + /** * Raw options for one driver (`aether.drivers.`), for the driver to type. * @@ -117,6 +122,22 @@ public function driver(string $name): array return is_array($options) ? $options : []; } + /** + * Read a string option, trimmed, or null when blank or not a string. + */ + private function string(string $key): ?string + { + $value = $this->config->get($key); + + if (! is_string($value)) { + return null; + } + + $trimmed = trim($value); + + return $trimmed === '' ? null : $trimmed; + } + /** * Read an integer option, falling back to $default when blank or not numeric. */ diff --git a/src/Drivers/LocalSimulatorDriver.php b/src/Drivers/LocalSimulatorDriver.php index 010cf85..ae53a68 100644 --- a/src/Drivers/LocalSimulatorDriver.php +++ b/src/Drivers/LocalSimulatorDriver.php @@ -5,6 +5,7 @@ namespace Aether\Drivers; use Aether\Circuit\CircuitBuilder; +use Aether\Config\AetherConfig; use Aether\Contracts\AsynchronousDevice; use Aether\Exceptions\QuantumExecutionException; use Aether\Tasks\TaskSnapshot; @@ -79,6 +80,6 @@ private function cacheKey(string $taskArn): string private function taskTtl(): int { - return (int) config('aether.local_task_ttl', 3600); + return app(AetherConfig::class)->localTaskTtl(); } } diff --git a/src/Jobs/PollQuantumTask.php b/src/Jobs/PollQuantumTask.php index 6f77ab1..8e59f5b 100644 --- a/src/Jobs/PollQuantumTask.php +++ b/src/Jobs/PollQuantumTask.php @@ -87,7 +87,7 @@ public function handle(QuantumManager $manager, Dispatcher $events, AetherConfig $snapshot = $device->checkTask($this->taskArn); if (! $snapshot->status->isTerminal()) { - $maxAttempts = $this->tries(); + $maxAttempts = $config->maxPollAttempts(); if ($this->attempts() >= $maxAttempts) { $e = QuantumExecutionException::pollingExhausted($this->taskArn, $this->attempts()); diff --git a/src/QuantumManager.php b/src/QuantumManager.php index e23729a..6c206cd 100644 --- a/src/QuantumManager.php +++ b/src/QuantumManager.php @@ -127,7 +127,7 @@ public function fake(array|CircuitResult|Closure|ResultSequence|null $stub = nul * * Quantum::extend('ionq', fn () => new IonqDriver( * Quantum::bridge(), - * config('aether.drivers.ionq'), + * app(AetherConfig::class)->driver('ionq'), * )); */ public function bridge(): PythonBridge @@ -180,18 +180,29 @@ protected function createAwsDriver(): AwsBraketDriver */ private function createBridge(): PythonBridge { + $settings = $this->settings(); + return new PythonBridge( - $this->settings()->pythonPath(), - $this->settings()->processTimeout(), + $settings->pythonPath(), + $settings->processTimeout(), ); } /** - * The typed package settings, resolved from the container on each call so - * a config value changed after the manager was built is still honoured. + * The typed package settings. + * + * Taken from the container when the service provider has bound it (so a + * swapped instance is honoured), otherwise built over the same config + * repository Manager already holds, so a bare container with only + * `config` bound still works. Resolved per call: the reader is stateless + * and a config value changed after the manager was built must still win. */ private function settings(): AetherConfig { - return $this->container->make(AetherConfig::class); + if ($this->container->bound(AetherConfig::class)) { + return $this->container->make(AetherConfig::class); + } + + return new AetherConfig($this->config); } } diff --git a/tests/Feature/Jobs/PollQuantumTaskTest.php b/tests/Feature/Jobs/PollQuantumTaskTest.php index 53eccca..6ef6345 100644 --- a/tests/Feature/Jobs/PollQuantumTaskTest.php +++ b/tests/Feature/Jobs/PollQuantumTaskTest.php @@ -12,6 +12,7 @@ use Aether\Tasks\TaskStatus; use Aether\Tests\Feature\Jobs\FakeAsynchronousDevice; use Aether\Tests\Feature\Jobs\FakeSynchronousOnlyDevice; +use Illuminate\Config\Repository; use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Contracts\Queue\Job; use Illuminate\Support\Facades\Event; @@ -30,6 +31,28 @@ expect($job->tries())->toBe(42); }); +it('budgets the attempts inside handle() from the injected settings, not the container', function () { + config(['aether.max_poll_attempts' => 720]); + + $device = new FakeAsynchronousDevice; + $device->snapshotToReturn = new TaskSnapshot(TaskStatus::Running); + + $manager = app(QuantumManager::class); + $manager->extend('fake-async', fn () => $device); + + $mockJob = Mockery::mock(Job::class); + $mockJob->shouldReceive('attempts')->andReturn(2); + $mockJob->shouldNotReceive('release'); + + $job = new PollQuantumTask($device->taskArnToReturn, ['qubits' => 2, 'gates' => [], 'shots' => 100], 'fake-async'); + $job->setJob($mockJob); + + $settings = new AetherConfig(new Repository(['aether' => ['max_poll_attempts' => 2]])); + + expect(fn () => $job->handle($manager, app(Dispatcher::class), $settings)) + ->toThrow(QuantumExecutionException::class); +}); + it('releases itself back to the queue with the configured delay when the task is not terminal', function () { config(['aether.poll_interval' => 3, 'aether.max_poll_attempts' => 720]); diff --git a/tests/Feature/ServiceProviderTest.php b/tests/Feature/ServiceProviderTest.php index 23afae5..c82792a 100644 --- a/tests/Feature/ServiceProviderTest.php +++ b/tests/Feature/ServiceProviderTest.php @@ -6,9 +6,12 @@ use Aether\Circuit\CircuitBuilder; use Aether\Config\AetherConfig; use Aether\Contracts\QuantumDevice; +use Aether\Drivers\LocalSimulatorDriver; use Aether\Entropy\EntropyGenerator; use Aether\Facades\Quantum; use Aether\QuantumManager; +use Illuminate\Config\Repository; +use Illuminate\Container\Container; use Illuminate\Support\Facades\Artisan; // ------------------------------------------------------------------------- @@ -23,6 +26,16 @@ ->and($first)->toBe($second); }); +it('builds drivers on a bare container that only binds config', function () { + $container = new Container; + $container->instance('config', new Repository(['aether' => ['python_path' => 'python3', 'drivers' => ['local' => []]]])); + + $manager = new QuantumManager($container); + + expect($manager->getDefaultDriver())->toBe('local') + ->and($manager->driver('local'))->toBeInstanceOf(LocalSimulatorDriver::class); +}); + it('resolves the default driver through AetherConfig', function () { config()->set('aether.default', ''); diff --git a/tests/Unit/Config/AetherConfigTest.php b/tests/Unit/Config/AetherConfigTest.php index 691ceb2..d7d88ab 100644 --- a/tests/Unit/Config/AetherConfigTest.php +++ b/tests/Unit/Config/AetherConfigTest.php @@ -29,6 +29,7 @@ function aetherConfig(array $aether): AetherConfig ->and($config->pollInterval())->toBe(5) ->and($config->maxPollAttempts())->toBe(720) ->and($config->persistTasks())->toBeFalse() + ->and($config->localTaskTtl())->toBe(3600) ->and($config->driver('local'))->toBe([]); }); @@ -37,7 +38,8 @@ function aetherConfig(array $aether): AetherConfig ->and(AetherConfig::DEFAULT_PYTHON_PATH)->toBe('python3') ->and(AetherConfig::DEFAULT_PROCESS_TIMEOUT)->toBe(300) ->and(AetherConfig::DEFAULT_POLL_INTERVAL)->toBe(5) - ->and(AetherConfig::DEFAULT_MAX_POLL_ATTEMPTS)->toBe(720); + ->and(AetherConfig::DEFAULT_MAX_POLL_ATTEMPTS)->toBe(720) + ->and(AetherConfig::DEFAULT_LOCAL_TASK_TTL)->toBe(3600); }); // ------------------------------------------------------------------------- @@ -67,11 +69,20 @@ function aetherConfig(array $aether): AetherConfig }); it('casts the numeric strings env() hands over', function () { - $config = aetherConfig(['process_timeout' => '45', 'poll_interval' => '3', 'max_poll_attempts' => '12']); + $config = aetherConfig(['process_timeout' => '45', 'poll_interval' => '3', 'max_poll_attempts' => '12', 'local_task_ttl' => '60']); expect($config->processTimeout())->toBe(45) ->and($config->pollInterval())->toBe(3) - ->and($config->maxPollAttempts())->toBe(12); + ->and($config->maxPollAttempts())->toBe(12) + ->and($config->localTaskTtl())->toBe(60); +}); + +it('trims the driver name, python path and queue it returns', function () { + $config = aetherConfig(['default' => ' aws ', 'python_path' => ' /usr/bin/python3', 'queue' => 'quantum ']); + + expect($config->defaultDriver())->toBe('aws') + ->and($config->pythonPath())->toBe('/usr/bin/python3') + ->and($config->queue())->toBe('quantum'); }); it('falls back to the default for a blank or non-numeric integer option', function (mixed $raw) { diff --git a/tests/Unit/Drivers/LocalSimulatorDriverTest.php b/tests/Unit/Drivers/LocalSimulatorDriverTest.php index 8dc0b81..e3175a1 100644 --- a/tests/Unit/Drivers/LocalSimulatorDriverTest.php +++ b/tests/Unit/Drivers/LocalSimulatorDriverTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Aether\Circuit\CircuitBuilder; +use Aether\Config\AetherConfig; use Aether\Contracts\AsynchronousDevice; use Aether\Contracts\EstimatesCost; use Aether\Contracts\PythonExecutor; @@ -46,7 +47,9 @@ // a minimal config repository for the config() helper to resolve. Cache::swap(new CacheRepository(new ArrayStore)); Container::setInstance(tap(new Container, function (Container $container) { - $container->instance('config', new ConfigRepository(['aether' => ['local_task_ttl' => 3600]])); + $config = new ConfigRepository(['aether' => ['local_task_ttl' => 3600]]); + $container->instance('config', $config); + $container->instance(AetherConfig::class, new AetherConfig($config)); })); }); From 727c7d6ec958be9ef696338cd5051bc529649047 Mon Sep 17 00:00:00 2001 From: corgab Date: Thu, 10 Sep 2026 13:15:57 +0200 Subject: [PATCH 3/3] Apply review feedback: mark Python configuration methods as deprecated --- src/Config/AetherConfig.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Config/AetherConfig.php b/src/Config/AetherConfig.php index b27d677..44cf696 100644 --- a/src/Config/AetherConfig.php +++ b/src/Config/AetherConfig.php @@ -49,6 +49,8 @@ public function defaultDriver(): string /** * Python executable used to run the bin/python scripts (`aether.python_path`). + * + * @deprecated Python support will be removed in the 100% PHP rewrite. */ public function pythonPath(): string { @@ -57,6 +59,8 @@ public function pythonPath(): string /** * Seconds a Python subprocess may run before it is killed (`aether.process_timeout`). + * + * @deprecated Python support will be removed in the 100% PHP rewrite. */ public function processTimeout(): int {