From 5b27af8ee535081d153ec1707ff0f664b0061113 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFck=20Piera?= Date: Mon, 26 Feb 2024 16:12:43 +0100 Subject: [PATCH] Import remote tasks with Composer --- CHANGELOG.md | 1 + bin/generate-tests.php | 15 +- doc/getting-started/basic-usage.md | 4 + doc/going-further/extending-castor/events.md | 4 + .../extending-castor/remote-imports.md | 142 +++++++++++++ doc/going-further/index.md | 4 +- examples/remote-import.php | 25 +++ phpstan-baseline.neon | 5 + src/Console/Application.php | 28 ++- src/Console/ApplicationFactory.php | 15 +- .../BeforeApplicationInitializationEvent.php | 13 ++ src/Remote/Composer.php | 141 +++++++++++++ src/Remote/Exception/ComposerError.php | 7 + src/Remote/Exception/ImportError.php | 7 + src/Remote/Exception/InvalidImportFormat.php | 7 + src/Remote/Importer.php | 186 ++++++++++++++++++ src/Remote/Listener/RemoteImportListener.php | 28 +++ src/functions.php | 36 +++- ...ntAndForceTest.php.output_not_runnable.txt | 3 + ...rprintAndForceTest.php.output_runnable.txt | 3 + ...ingerprintTest.php.output_not_runnable.txt | 3 + ...thAFingerprintTest.php.output_runnable.txt | 3 + ...ingerprintTest.php.output_not_runnable.txt | 3 + ...eteFingerprintTest.php.output_runnable.txt | 3 + .../FilesystemFindTest.php.output.txt | 2 +- .../Generated/ListTest.php.output.txt | 1 + .../Remote/RemoteImportRemoteTasksTest.php | 30 +++ ...rtRemoteTasksTest.php.output_no_update.txt | 7 + ...mportRemoteTasksTest.php.output_update.txt | 10 + tests/Helper/OutputCleaner.php | 1 + tests/TaskTestCase.php | 6 +- 31 files changed, 732 insertions(+), 11 deletions(-) create mode 100644 doc/going-further/extending-castor/remote-imports.md create mode 100644 examples/remote-import.php create mode 100644 src/Event/BeforeApplicationInitializationEvent.php create mode 100644 src/Remote/Composer.php create mode 100644 src/Remote/Exception/ComposerError.php create mode 100644 src/Remote/Exception/ImportError.php create mode 100644 src/Remote/Exception/InvalidImportFormat.php create mode 100644 src/Remote/Importer.php create mode 100644 src/Remote/Listener/RemoteImportListener.php create mode 100644 tests/Examples/Remote/RemoteImportRemoteTasksTest.php create mode 100644 tests/Examples/Remote/RemoteImportRemoteTasksTest.php.output_no_update.txt create mode 100644 tests/Examples/Remote/RemoteImportRemoteTasksTest.php.output_update.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index b8055ea2..a7a59f7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Not released yet +* Add support for importing remote functions and tasks * Add a option `ignoreValidationErrors` on `AsTask` attribute to ignore parameters & options validation errors * Add a way to merge an application `box.json` config file used by `castor:repack`command diff --git a/bin/generate-tests.php b/bin/generate-tests.php index 581b58cb..f0f47704 100755 --- a/bin/generate-tests.php +++ b/bin/generate-tests.php @@ -28,7 +28,13 @@ $application ->run(new ArrayInput(['command' => 'list', '--format' => 'json']), $o = new BufferedOutput()) ; -$applicationDescription = json_decode($o->fetch(), true); +$json = $o->fetch(); + +try { + $applicationDescription = json_decode($json, true, flags: \JSON_THROW_ON_ERROR); +} catch (JsonException $e) { + throw new RuntimeException('Could not get the list of commands: ' . $json, previous: $e); +} $taskFilterList = [ '_complete', @@ -69,10 +75,14 @@ 'log:info', 'log:with-context', 'parallel:sleep', + 'remote-import:remote-tasks', 'run:ls', 'run:run-parallel', + // Imported tasks + 'pyrech:hello-example', + 'pyrech:foobar', ]; -$optionFilterList = array_flip(['help', 'quiet', 'verbose', 'version', 'ansi', 'no-ansi', 'no-interaction', 'context']); +$optionFilterList = array_flip(['help', 'quiet', 'verbose', 'version', 'ansi', 'no-ansi', 'no-interaction', 'context', 'no-remote', 'update-remotes']); foreach ($applicationDescription['commands'] as $task) { if (in_array($task['name'], $taskFilterList, true)) { continue; @@ -144,6 +154,7 @@ function add_test(array $args, string $class, ?string $cwd = null) env: [ 'COLUMNS' => 120, 'ENDPOINT' => $_SERVER['ENDPOINT'], + 'CASTOR_NO_REMOTE' => 1, ], timeout: null, ); diff --git a/doc/getting-started/basic-usage.md b/doc/getting-started/basic-usage.md index c9bdd065..ab2ec88b 100644 --- a/doc/getting-started/basic-usage.md +++ b/doc/getting-started/basic-usage.md @@ -73,6 +73,10 @@ import(__DIR__ . '/my-app/castor'); > You cannot dynamically import tasks. The `import()` function must be called > at the top level of the file. +> [!NOTE] +> You can also import functions from a remote resource. See the +> [related documentation](../going-further/extending-castor/remote-imports.md). + ## Overriding task name, namespace or description The `Castor\Attribute\AsTask` attribute takes three optional diff --git a/doc/going-further/extending-castor/events.md b/doc/going-further/extending-castor/events.md index 65679837..7a216e73 100644 --- a/doc/going-further/extending-castor/events.md +++ b/doc/going-further/extending-castor/events.md @@ -30,6 +30,10 @@ function my_event_listener(AfterApplicationInitializationEvent|AfterExecuteTaskE Here is the built-in events triggered by Castor: +* `Castor\Event\BeforeApplicationInitializationEvent`: This event is triggered + before the application has been initialized and before the task and context + has been looked at. It provides access to the `Application` instance; + * `Castor\Event\AfterApplicationInitializationEvent`: This event is triggered after the application has been initialized. It provides access to the `Application` instance and an array of `TaskDescriptor` objects; diff --git a/doc/going-further/extending-castor/remote-imports.md b/doc/going-further/extending-castor/remote-imports.md new file mode 100644 index 00000000..125cd1e3 --- /dev/null +++ b/doc/going-further/extending-castor/remote-imports.md @@ -0,0 +1,142 @@ +# Import remote functions + +Castor can import functions from your filesystem but also from a remote resource. + +## Importing functions + +When importing functions from a remote resource, Castor will use Composer to +download the packages and store them in `.castor/vendor/`. + +To import functions, you need to use the same `import()` function used to import +your tasks, but this time with a different syntax for the `path` argument. + +The import syntax depends on the source of the packages. + +### From a Composer package (scheme `composer://`) + +This is the most common use case when the functions to import are defined in a +Composer package. You can directly import them by using the package name +prefixed with the `composer://` scheme: + +```php +use function Castor\import; + +import('composer://vendor-name/project-name'); +``` + +This will import all the tasks defined in the package. + +#### Specify the version + +You can define the version of the package to import by using the `version` +argument: + +```php +use function Castor\import; + +import('composer://vendor-name/project-name', version: '^1.0'); +``` + +You can use any version constraint supported by Composer (like `*`, `dev-main`, +`^1.0`, etc.). See the [Composer documentation](https://getcomposer.org/doc/articles/versions.md#writing-version-constraints) +for more information. + +> [!TIP] +> The `version` argument is optional and will default to `*`. + +#### Import from a package not pushed to packagist.org + +In some cases, you may have a Composer package that is not pushed to +packagist.org (like a private package hosted on packagist.com or another package +registry). In such cases, you can import it by using the `vcs` argument to +specify the repository URL where the package is hosted: + +```php + +use function Castor\import; + +import('composer://vendor-name/project-name', vcs: 'https://github.com/organization/repository.git'); +``` + +### From a repository (scheme `package://`) + +If the functions you want to import are not available as a Composer package, you +can still import them by using a special configuration that Composer will +understand. This will now use the `package://` scheme. + +```php +use function Castor\import; + +import('composer://vendor-name/project-name', source: [ + 'url' => 'https://github.com/organization/repository.git', + 'type' => 'git', // 'Any source type supported by Composer (git, svn, etc)' + 'reference' => 'main', // A commit id, a branch or a tag name +]); +``` + +> [!NOTE] +> The "vendor-name/project-name" name can be whatever you want and we only be +> used internally by Castor and Composer to make the repository behave like a +> normal Composer package. + +> [!TIP] +> Rather than using the `package://` scheme, it may be simpler to create a +> standard `composer.json` to your repository and import your newly created +> package by using the `composer://` scheme and the `vcs` argument. + +## Import only a specific file + +No matter where does the package come from (Composer package, git repository, +etc.), you can restrict the file (or directory) to be imported. This is +configured by using the `file` argument specifying the path inside the package +or repository. + +```php +use function Castor\import; + +import('composer://vendor-name/project-name', file: 'castor/my-tasks.php'); +``` + +> [!NOTE] +> The `file` argument is optional and will empty by default, causing Castor to +> import and parse all the PHP files in the package. While handy, it's probably +> not what you want if your package contains PHP code that are not related to +> Castor. + +## Preventing remote imports + +In case you have trouble with the imported functions (or if you don't trust +them), you can prevent Castor from importing and running any of them. Add the +`--no-remote` option when calling any Castor tasks: + +```bash +$ castor --no-remote my-task +``` + +This will trigger a warning to remind you that the remote imports are disabled. +Also, any task or configuration using an imported function will trigger an error +with Castor complaining about undefined functions. + +If you want to disable remote imports every time, you can define the +`CASTOR_NO_REMOTE` environment variable to 1: + +```bash +$ export CASTOR_NO_REMOTE=1 +$ castor my-task # will not import any remote functions +``` + +## Update imported packages + +When you import a package in a given version, Castor will not update +automatically update the packages once a new version of your dependency is +available. + +To update your dependencies, you will either need to: +- change the required version yourself (thus every one using your Castor project +will profit of the update once they run your project); +- force the update on your side only by either using the `--update-remotes` +option or by removing the `.castor/vendor/` folder. + +```bash +$ castor --update-remotes +``` diff --git a/doc/going-further/index.md b/doc/going-further/index.md index e99d8a04..1502e1ae 100644 --- a/doc/going-further/index.md +++ b/doc/going-further/index.md @@ -7,5 +7,5 @@ help you write your tasks. * [Interacting with Castor](interacting-with-castor/advanced-context.md): how to interact with Castor's output, context, logs, etc. * [Extending Castor](extending-castor/events.md): how to wire some logic -inside Castor, how to redistribute your project as your own phar or static -binary. +inside Castor, import remote tasks, redistribute your project as your own phar +or static binary, etc. diff --git a/examples/remote-import.php b/examples/remote-import.php new file mode 100644 index 00000000..3d30947e --- /dev/null +++ b/examples/remote-import.php @@ -0,0 +1,25 @@ + 'https://github.com/pyrech/castor-example-misc.git', + 'type' => 'git', + 'reference' => 'main', // commit id, branch or tag name +]); + +#[AsTask(description: 'Use functions imported from remote packages')] +function remote_tasks(): void +{ + \pyrech\helloExample(); // from composer://pyrech/castor-example + \pyrech\foobar(); // from package://pyrech/foobar +} diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 9c18cc20..3126631b 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -5,6 +5,11 @@ parameters: count: 1 path: examples/args.php + - + message: "#^Function pyrech\\\\.* not found\\.$#" + count: 2 + path: examples/ + - message: "#^Default value of the parameter \\#1 \\$data \\(array\\{\\}\\) of method Castor\\\\Context\\:\\:__construct\\(\\) is incompatible with type array\\{name\\: string, production\\: bool, foo\\?\\: string\\}\\.$#" count: 1 diff --git a/src/Console/Application.php b/src/Console/Application.php index 10a6a810..fb3fe4b0 100644 --- a/src/Console/Application.php +++ b/src/Console/Application.php @@ -15,12 +15,14 @@ use Castor\Descriptor\TaskDescriptor; use Castor\Descriptor\TaskDescriptorCollection; use Castor\Event\AfterApplicationInitializationEvent; +use Castor\Event\BeforeApplicationInitializationEvent; use Castor\EventDispatcher; use Castor\ExpressionLanguage; use Castor\Fingerprint\FingerprintHelper; use Castor\FunctionFinder; use Castor\GlobalHelper; use Castor\PlatformHelper; +use Castor\Remote\Importer; use Castor\WaitForHelper; use Monolog\Logger; use Psr\Cache\CacheItemPoolInterface; @@ -59,10 +61,11 @@ public function __construct( public readonly ExpressionLanguage $expressionLanguage, public readonly Logger $logger, public readonly Filesystem $fs, + public readonly WaitForHelper $waitForHelper, + public readonly FingerprintHelper $fingerprintHelper, + public readonly Importer $importer, public HttpClientInterface $httpClient, public CacheItemPoolInterface&CacheInterface $cache, - public WaitForHelper $waitForHelper, - public FingerprintHelper $fingerprintHelper, ) { $handler = ErrorHandler::register(); $handler->setDefaultLogger($logger, [ @@ -131,6 +134,9 @@ public function doRun(InputInterface $input, OutputInterface $output): int $this->symfonyStyle = new SymfonyStyle($input, $output); $this->logger->pushHandler(new ConsoleHandler($output)); + $event = new BeforeApplicationInitializationEvent($this); + $this->eventDispatcher->dispatch($event); + $descriptors = $this->initializeApplication($input); // Must be done after the initializeApplication() call, to ensure all @@ -215,6 +221,24 @@ private function initializeApplication(InputInterface $input): TaskDescriptorCol )); } + $this->getDefinition()->addOption( + new InputOption( + 'no-remote', + null, + InputOption::VALUE_NONE, + 'Skip the import of all remote remote packages', + ) + ); + + $this->getDefinition()->addOption( + new InputOption( + 'update-remotes', + null, + InputOption::VALUE_NONE, + 'Force the update of remote packages', + ) + ); + return new TaskDescriptorCollection($tasks, $symfonyTasks); } diff --git a/src/Console/ApplicationFactory.php b/src/Console/ApplicationFactory.php index 6a4e5e29..22169dff 100644 --- a/src/Console/ApplicationFactory.php +++ b/src/Console/ApplicationFactory.php @@ -10,11 +10,15 @@ use Castor\ExpressionLanguage; use Castor\Fingerprint\FingerprintHelper; use Castor\FunctionFinder; +use Castor\HasherHelper; use Castor\Listener\GenerateStubsListener; use Castor\Listener\UpdateCastorListener; use Castor\Monolog\Processor\ProcessProcessor; use Castor\PathHelper; use Castor\PlatformHelper; +use Castor\Remote\Composer; +use Castor\Remote\Importer; +use Castor\Remote\Listener\RemoteImportListener; use Castor\Stub\StubsGenerator; use Castor\WaitForHelper; use Monolog\Logger; @@ -49,6 +53,8 @@ public static function create(): SymfonyApplication $cache = new FilesystemAdapter(directory: $cacheDir); $logger = new Logger('castor', [], [new ProcessProcessor()]); $fs = new Filesystem(); + $fingerprintHelper = new FingerprintHelper($cache); + $importer = new Importer($logger, new Composer($fs, $logger, $fingerprintHelper)); $eventDispatcher = new EventDispatcher(logger: $logger); $eventDispatcher->addSubscriber(new UpdateCastorListener( $cache, @@ -59,6 +65,7 @@ public static function create(): SymfonyApplication new StubsGenerator($logger), $rootDir, )); + $eventDispatcher->addSubscriber(new RemoteImportListener($importer)); /** @var SymfonyApplication */ // @phpstan-ignore-next-line @@ -70,12 +77,16 @@ public static function create(): SymfonyApplication new ExpressionLanguage($contextRegistry), $logger, $fs, + new WaitForHelper($httpClient, $logger), + $fingerprintHelper, + $importer, $httpClient, $cache, - new WaitForHelper($httpClient, $logger), - new FingerprintHelper($cache), ); + // Avoid dependency cycle + $importer->setApplication($application); + $application->setDispatcher($eventDispatcher); $application->add(new DebugCommand($rootDir, $cacheDir, $contextRegistry)); diff --git a/src/Event/BeforeApplicationInitializationEvent.php b/src/Event/BeforeApplicationInitializationEvent.php new file mode 100644 index 00000000..3bedb62b --- /dev/null +++ b/src/Event/BeforeApplicationInitializationEvent.php @@ -0,0 +1,13 @@ + 'This file is managed by Castor. Do not edit it manually.', + 'config' => [ + 'sort-packages' => true, + ], + 'replace' => [ + 'castor/castor' => Application::VERSION, + ], + ]; + + public function __construct( + private readonly Filesystem $filesystem, + private readonly LoggerInterface $logger, + private readonly FingerprintHelper $fingerprintHelper, + /** @var array */ + private array $configuration = self::DEFAULT_COMPOSER_CONFIGURATION, + ) { + } + + /** + * @return array + */ + public function getConfiguration(): array + { + return $this->configuration; + } + + /** + * @param array $configuration + */ + public function setConfiguration(array $configuration): void + { + $this->configuration = $configuration; + } + + public function update(bool $force = false, bool $displayProgress = true): void + { + $composer = (new ExecutableFinder())->find('composer'); + + if (!$composer) { + throw new ComposerError('The "composer" executable was not found. In order to use remote import, please make sure that Composer is installed and available in your PATH.'); + } + + $dir = PathHelper::getRoot() . self::VENDOR_DIR; + + $this->filesystem->mkdir($dir); + + file_put_contents($dir . '.gitignore', "*\n"); + + $this->writeJsonFile($dir . 'composer.json', $this->configuration); + + $ran = false; + $fingerprint = json_encode($this->configuration, \JSON_THROW_ON_ERROR); + + if ($force || !$this->fingerprintHelper->verifyFingerprintFromHash($fingerprint)) { + $progressIndicator = null; + if ($displayProgress) { + $progressIndicator = new ProgressIndicator(GlobalHelper::getOutput(), null, 100, ['⠏', '⠛', '⠹', '⢸', '⣰', '⣤', '⣆', '⡇']); + $progressIndicator->start('Downloading remote packages'); + } + + $this->run(['update'], callback: function () use ($progressIndicator) { + if ($progressIndicator) { + $progressIndicator->advance(); + } + }); + + if ($progressIndicator) { + $progressIndicator->finish('Remote packages imported'); + } + $this->fingerprintHelper->postProcessFingerprintForHash($fingerprint); + + $ran = true; + } + + if (!$ran) { + $this->logger->debug('Packages were already required, no need to run Composer.'); + } + } + + public function remove(): void + { + $this->filesystem->remove(PathHelper::getRoot() . self::VENDOR_DIR); + } + + /** + * @param string[] $args + */ + private function run(array $args, callable $callback): void + { + $this->logger->debug('Running Composer command.', [ + 'args' => implode(' ', $args), + ]); + + $dir = PathHelper::getRoot() . self::VENDOR_DIR; + + $process = new Process(['composer', ...$args, '--working-dir', $dir]); + $process->setEnv([ + 'COMPOSER_VENDOR_DIR' => $dir, + ]); + $process->run($callback); + + if (!$process->isSuccessful()) { + throw new ComposerError('The Composer process failed: ' . $process->getErrorOutput()); + } + + $this->logger->debug('Composer command was successful.', [ + 'args' => implode(' ', $args), + 'output' => $process->getOutput(), + ]); + } + + /** + * @param array $json + */ + private function writeJsonFile(string $path, array $json): void + { + file_put_contents($path, json_encode($json, \JSON_PRETTY_PRINT | \JSON_THROW_ON_ERROR)); + } +} diff --git a/src/Remote/Exception/ComposerError.php b/src/Remote/Exception/ComposerError.php new file mode 100644 index 00000000..98506db9 --- /dev/null +++ b/src/Remote/Exception/ComposerError.php @@ -0,0 +1,7 @@ + */ + private array $imports = [], + ) { + } + + public function setApplication(Application $application) + { + $this->application = $application; + } + + /** + * @param ?array{ + * url?: string, + * type?: "git" | "svn", + * reference?: string, + * } $source + */ + public function importFunctionsFrom(string $scheme, string $package, ?string $file = null, ?string $version = null, ?string $vcs = null, ?array $source = null): void + { + if (!$this->allowsRemote()) { + throw new ImportError(sprintf('Remote imports are disabled, skipping import of "%s".', $package)); + } + + if (isset($this->imports[$package]) && $this->imports[$package]->version !== $version) { + throw new ImportError(sprintf('The package "%s" is already required in version "%s", could not require it in version "%s"', $package, $this->imports[$package]->version, $version)); + } + + if (!preg_match('#^(?[^/]+)/(?[^/]+)$#', $package)) { + throw new InvalidImportFormat(sprintf('The import path must be formatted like this: "%s:///".', $scheme)); + } + + if ('composer' === $scheme) { + if (null !== $source) { + throw new InvalidImportFormat('The "source" argument is not supported for Composer/Packagist packages.'); + } + + $this->importPackageWithComposer($package, version: $version ?? '*', repositoryUrl: $vcs, file: $file); + + return; + } + + if ('package' === $scheme) { + if (null !== $version || null !== $vcs) { + throw new InvalidImportFormat('The "source" and "vcs" arguments are not supported for non-Composer packages.'); + } + if (null === $source) { + throw new InvalidImportFormat('The "source" argument is required for non-Composer packages.'); + } + + $this->importPackageWithComposer($package, version: 'v1', source: $source, file: $file); + + return; + } + + throw new InvalidImportFormat(sprintf('The import scheme "%s" is not supported.', $scheme)); + } + + public function fetchPackages(InputInterface $input): void + { + if (!$this->imports) { + $this->composer->remove(); + + return; + } + + // Need to look for the raw options as the input is not yet parsed + $forceUpdate = true !== $input->getParameterOption('--update-remotes', true); + $displayProgress = 'list' !== $input->getFirstArgument(); + + $autoloadPath = PathHelper::getRoot() . Composer::VENDOR_DIR . 'autoload.php'; + + if (!file_exists($autoloadPath)) { + $forceUpdate = true; + } + + $this->composer->update($forceUpdate, $displayProgress); + + require_once $autoloadPath; + + foreach ($this->imports as $package => $import) { + foreach ($import->getFiles() as $file) { + import(PathHelper::getRoot() . Composer::VENDOR_DIR . $package . '/' . ($file ?? '')); + } + } + } + + /** + * @param ?array{ + * url?: string, + * type?: "git" | "svn", + * reference?: string, + * } $source + */ + private function importPackageWithComposer(string $package, string $version, ?string $repositoryUrl = null, ?array $source = null, ?string $file = null): void + { + $this->logger->notice('Importing remote package with Composer.', [ + 'package' => $package, + 'version' => $version, + ]); + + $json = $this->composer->getConfiguration(); + + $json['require'][$package] = $version; + + if ($repositoryUrl) { + $json['repositories'][] = [ + 'type' => 'vcs', + 'url' => $repositoryUrl, + ]; + } + + if ($source) { + if (!isset($source['url'], $source['type'], $source['reference'])) { + throw new ImportError('The "source" argument must contain "url", "type" and "reference" keys.'); + } + + $json['repositories'][] = [ + 'type' => 'package', + 'package' => [ + 'name' => $package, + 'version' => $version, + 'source' => $source, + ], + ]; + } + + $this->composer->setConfiguration($json); + + $this->imports[$package] ??= new Import($version); + $this->imports[$package]->addFile($file); + } + + private function allowsRemote(): bool + { + if ($_SERVER['CASTOR_NO_REMOTE'] ?? false) { + return false; + } + + $input = $this->application->getInput(); + + // Need to look for the raw options as the input is not yet parsed + return true === $input->getParameterOption('--no-remote', true); + } +} + +class Import +{ + /** @var string[] */ + private array $files; + + public function __construct( + public readonly string $version, + ) { + } + + public function addFile(?string $file = null) + { + $this->files[] = $file; + } + + public function getFiles(): array + { + return array_unique($this->files); + } +} diff --git a/src/Remote/Listener/RemoteImportListener.php b/src/Remote/Listener/RemoteImportListener.php new file mode 100644 index 00000000..4778ed13 --- /dev/null +++ b/src/Remote/Listener/RemoteImportListener.php @@ -0,0 +1,28 @@ + 'afterInitialize', + ]; + } + + public function afterInitialize(AfterApplicationInitializationEvent $event): void + { + $this->importer->fetchPackages($event->application->getInput()); + } +} diff --git a/src/functions.php b/src/functions.php index 96b631ea..2f6b4b36 100644 --- a/src/functions.php +++ b/src/functions.php @@ -8,6 +8,8 @@ use Castor\Exception\MinimumVersionRequirementNotMetException; use Castor\Exception\WaitFor\ExitedBeforeTimeoutException; use Castor\Exception\WaitFor\TimeoutReachedException; +use Castor\Remote\Exception\ImportError; +use Castor\Remote\Exception\InvalidImportFormat; use Joli\JoliNotif\Notification; use Joli\JoliNotif\NotifierFactory; use JoliCode\PhpOsHelper\OsHelper; @@ -762,8 +764,40 @@ function http_client(): HttpClientInterface return GlobalHelper::getHttpClient(); } -function import(string $path): void +/** + * @param ?array{ + * url?: string, + * type?: "git" | "svn", + * reference?: string, + * } $source + */ +function import(string $path, ?string $file = null, ?string $version = null, ?string $vcs = null, ?array $source = null): void { + $scheme = parse_url($path, \PHP_URL_SCHEME); + + if ($scheme) { + try { + GlobalHelper::getApplication()->importer->importFunctionsFrom( + $scheme, + mb_substr($path, mb_strlen($scheme) + 3), + $file, + $version, + $vcs, + $source, + ); + + return; + } catch (InvalidImportFormat $e) { + throw fix_exception(new \InvalidArgumentException($e->getMessage(), 0, $e)); + } catch (ImportError $e) { + log($e->getMessage(), 'warning'); + + return; + } + } elseif (null !== $file || null !== $version || null !== $vcs || null !== $source) { + throw fix_exception(new \InvalidArgumentException('The "file", "version", "vcs" and "source" arguments can only be used with a remote import.')); + } + if (!file_exists($path)) { throw fix_exception(new \InvalidArgumentException(sprintf('The file "%s" does not exist.', $path))); } diff --git a/tests/Examples/Fingerprint/FingerprintTaskWithAFingerprintAndForceTest.php.output_not_runnable.txt b/tests/Examples/Fingerprint/FingerprintTaskWithAFingerprintAndForceTest.php.output_not_runnable.txt index e232b1fb..c8642a88 100644 --- a/tests/Examples/Fingerprint/FingerprintTaskWithAFingerprintAndForceTest.php.output_not_runnable.txt +++ b/tests/Examples/Fingerprint/FingerprintTaskWithAFingerprintAndForceTest.php.output_not_runnable.txt @@ -1,2 +1,5 @@ +hh:ii:ss WARNING [castor] Remote imports are disabled, skipping import of "pyrech/castor-example". +hh:ii:ss WARNING [castor] Remote imports are disabled, skipping import of "pyrech/castor-example-package-not-published". +hh:ii:ss WARNING [castor] Remote imports are disabled, skipping import of "pyrech/foobar". Hello Task with Fingerprint! Cool! I finished! diff --git a/tests/Examples/Fingerprint/FingerprintTaskWithAFingerprintAndForceTest.php.output_runnable.txt b/tests/Examples/Fingerprint/FingerprintTaskWithAFingerprintAndForceTest.php.output_runnable.txt index 4eb89b59..2c9190c8 100644 --- a/tests/Examples/Fingerprint/FingerprintTaskWithAFingerprintAndForceTest.php.output_runnable.txt +++ b/tests/Examples/Fingerprint/FingerprintTaskWithAFingerprintAndForceTest.php.output_runnable.txt @@ -1,3 +1,6 @@ +hh:ii:ss WARNING [castor] Remote imports are disabled, skipping import of "pyrech/castor-example". +hh:ii:ss WARNING [castor] Remote imports are disabled, skipping import of "pyrech/castor-example-package-not-published". +hh:ii:ss WARNING [castor] Remote imports are disabled, skipping import of "pyrech/foobar". Hello Task with Fingerprint! Cool, no fingerprint! Executing... Fingerprint has been executed! diff --git a/tests/Examples/Fingerprint/FingerprintTaskWithAFingerprintTest.php.output_not_runnable.txt b/tests/Examples/Fingerprint/FingerprintTaskWithAFingerprintTest.php.output_not_runnable.txt index e232b1fb..c8642a88 100644 --- a/tests/Examples/Fingerprint/FingerprintTaskWithAFingerprintTest.php.output_not_runnable.txt +++ b/tests/Examples/Fingerprint/FingerprintTaskWithAFingerprintTest.php.output_not_runnable.txt @@ -1,2 +1,5 @@ +hh:ii:ss WARNING [castor] Remote imports are disabled, skipping import of "pyrech/castor-example". +hh:ii:ss WARNING [castor] Remote imports are disabled, skipping import of "pyrech/castor-example-package-not-published". +hh:ii:ss WARNING [castor] Remote imports are disabled, skipping import of "pyrech/foobar". Hello Task with Fingerprint! Cool! I finished! diff --git a/tests/Examples/Fingerprint/FingerprintTaskWithAFingerprintTest.php.output_runnable.txt b/tests/Examples/Fingerprint/FingerprintTaskWithAFingerprintTest.php.output_runnable.txt index 00f0ed61..47b5600a 100644 --- a/tests/Examples/Fingerprint/FingerprintTaskWithAFingerprintTest.php.output_runnable.txt +++ b/tests/Examples/Fingerprint/FingerprintTaskWithAFingerprintTest.php.output_runnable.txt @@ -1,3 +1,6 @@ +hh:ii:ss WARNING [castor] Remote imports are disabled, skipping import of "pyrech/castor-example". +hh:ii:ss WARNING [castor] Remote imports are disabled, skipping import of "pyrech/castor-example-package-not-published". +hh:ii:ss WARNING [castor] Remote imports are disabled, skipping import of "pyrech/foobar". Hello Task with Fingerprint! Cool, no fingerprint! Executing... Cool! I finished! diff --git a/tests/Examples/Fingerprint/FingerprintTaskWithCompleteFingerprintTest.php.output_not_runnable.txt b/tests/Examples/Fingerprint/FingerprintTaskWithCompleteFingerprintTest.php.output_not_runnable.txt index e232b1fb..c8642a88 100644 --- a/tests/Examples/Fingerprint/FingerprintTaskWithCompleteFingerprintTest.php.output_not_runnable.txt +++ b/tests/Examples/Fingerprint/FingerprintTaskWithCompleteFingerprintTest.php.output_not_runnable.txt @@ -1,2 +1,5 @@ +hh:ii:ss WARNING [castor] Remote imports are disabled, skipping import of "pyrech/castor-example". +hh:ii:ss WARNING [castor] Remote imports are disabled, skipping import of "pyrech/castor-example-package-not-published". +hh:ii:ss WARNING [castor] Remote imports are disabled, skipping import of "pyrech/foobar". Hello Task with Fingerprint! Cool! I finished! diff --git a/tests/Examples/Fingerprint/FingerprintTaskWithCompleteFingerprintTest.php.output_runnable.txt b/tests/Examples/Fingerprint/FingerprintTaskWithCompleteFingerprintTest.php.output_runnable.txt index 00f0ed61..47b5600a 100644 --- a/tests/Examples/Fingerprint/FingerprintTaskWithCompleteFingerprintTest.php.output_runnable.txt +++ b/tests/Examples/Fingerprint/FingerprintTaskWithCompleteFingerprintTest.php.output_runnable.txt @@ -1,3 +1,6 @@ +hh:ii:ss WARNING [castor] Remote imports are disabled, skipping import of "pyrech/castor-example". +hh:ii:ss WARNING [castor] Remote imports are disabled, skipping import of "pyrech/castor-example-package-not-published". +hh:ii:ss WARNING [castor] Remote imports are disabled, skipping import of "pyrech/foobar". Hello Task with Fingerprint! Cool, no fingerprint! Executing... Cool! I finished! diff --git a/tests/Examples/Generated/FilesystemFindTest.php.output.txt b/tests/Examples/Generated/FilesystemFindTest.php.output.txt index d20e33cf..b76f9390 100644 --- a/tests/Examples/Generated/FilesystemFindTest.php.output.txt +++ b/tests/Examples/Generated/FilesystemFindTest.php.output.txt @@ -1 +1 @@ -Number of PHP files: 29 +Number of PHP files: 30 diff --git a/tests/Examples/Generated/ListTest.php.output.txt b/tests/Examples/Generated/ListTest.php.output.txt index 7e5315ea..5da10ced 100644 --- a/tests/Examples/Generated/ListTest.php.output.txt +++ b/tests/Examples/Generated/ListTest.php.output.txt @@ -51,6 +51,7 @@ output:output Plays with Sym parallel:exception Sleep and throw an exception parallel:sleep Sleeps for 5, 7, and 10 seconds in parallel quiet:quiet Executes something but does not output anything +remote-import:remote-tasks Use functions imported from remote packages run:ls Run a sub-process and display information about it run:test-file Run a sub-process and return its exit code, with get_exit_code() function run:variables Run a sub-process with environment variables and display information about it diff --git a/tests/Examples/Remote/RemoteImportRemoteTasksTest.php b/tests/Examples/Remote/RemoteImportRemoteTasksTest.php new file mode 100644 index 00000000..696cd54b --- /dev/null +++ b/tests/Examples/Remote/RemoteImportRemoteTasksTest.php @@ -0,0 +1,30 @@ +remove(__DIR__ . '/../../../.castor/vendor'); + + // No vendor => should download + $process = $this->runTask(['remote-import:remote-tasks'], needRemote: true); + $this->assertSame(0, $process->getExitCode()); + $this->assertStringEqualsFile(__FILE__ . '.output_update.txt', $process->getOutput()); + + // Vendor downloaded => should not download + $process = $this->runTask(['remote-import:remote-tasks'], needRemote: true); + $this->assertSame(0, $process->getExitCode()); + $this->assertStringEqualsFile(__FILE__ . '.output_no_update.txt', $process->getOutput()); + + // Force remotes update => should update + $process = $this->runTask(['remote-import:remote-tasks', '--update-remotes'], needRemote: true); + $this->assertSame(0, $process->getExitCode()); + $this->assertStringEqualsFile(__FILE__ . '.output_update.txt', $process->getOutput()); + } +} diff --git a/tests/Examples/Remote/RemoteImportRemoteTasksTest.php.output_no_update.txt b/tests/Examples/Remote/RemoteImportRemoteTasksTest.php.output_no_update.txt new file mode 100644 index 00000000..41083c96 --- /dev/null +++ b/tests/Examples/Remote/RemoteImportRemoteTasksTest.php.output_no_update.txt @@ -0,0 +1,7 @@ + +Hello from example! +=================== + +This is foobar! +=============== + diff --git a/tests/Examples/Remote/RemoteImportRemoteTasksTest.php.output_update.txt b/tests/Examples/Remote/RemoteImportRemoteTasksTest.php.output_update.txt new file mode 100644 index 00000000..3441d8c2 --- /dev/null +++ b/tests/Examples/Remote/RemoteImportRemoteTasksTest.php.output_update.txt @@ -0,0 +1,10 @@ + Downloading remote packages + Remote packages imported + + +Hello from example! +=================== + +This is foobar! +=============== + diff --git a/tests/Helper/OutputCleaner.php b/tests/Helper/OutputCleaner.php index 8761816a..942508f1 100644 --- a/tests/Helper/OutputCleaner.php +++ b/tests/Helper/OutputCleaner.php @@ -15,6 +15,7 @@ public static function cleanOutput(string $string): string $string = preg_replace('{In ContextRegistry.php line \d+:}m', 'In ContextRegistry.php line XXXX:', $string); $string = preg_replace('{you are using v\d+.\d+.\d+.}m', 'you are using vX.Y.Z.', $string); $string = preg_replace('{you are using v\d+.\d+.\d+.}m', 'you are using vX.Y.Z.', $string); + $string = preg_replace("{\d{2}:\d{2}:\d{2} WARNING \[castor\] Remote imports are disabled, skipping import of \".*\".\n}m", '', $string); return str_replace(\dirname(__DIR__, 2), '...', $string); } diff --git a/tests/TaskTestCase.php b/tests/TaskTestCase.php index a98c740d..746d9f3b 100644 --- a/tests/TaskTestCase.php +++ b/tests/TaskTestCase.php @@ -14,7 +14,7 @@ public static function setUpBeforeClass(): void WebServerHelper::start(); } - public function runTask(array $args, ?string $cwd = null): Process + public function runTask(array $args, ?string $cwd = null, bool $needRemote = false): Process { $coverage = $this->getTestResultObject()?->getCodeCoverage(); @@ -24,6 +24,10 @@ public function runTask(array $args, ?string $cwd = null): Process 'ENDPOINT' => $_SERVER['ENDPOINT'], ]; + if (!$needRemote) { + $extraEnv['CASTOR_NO_REMOTE'] = 1; + } + if ($coverage) { $castorBin = __DIR__ . '/bin/castor'; $testName = debug_backtrace()[1]['class'] . '::' . debug_backtrace()[1]['function'];