Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[RFC] Add support for importing remote functions and tasks #297

Merged
merged 3 commits into from Apr 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Expand Up @@ -5,3 +5,4 @@
/my-app.*
/var/
/vendor/
/tests/Examples/fixtures/**/.castor.stub.php
3 changes: 2 additions & 1 deletion CHANGELOG.md
Expand Up @@ -7,7 +7,8 @@
* Add support for running Castor on Linux arm64 and distribute the binary
`castor.linux-arm64.phar` automatically with the release
* Add a bash installer to ease installation
* Add a option `ignoreValidationErrors` on `AsTask` attribute to ignore
* Add support for importing remote functions and tasks
* Add an option `ignoreValidationErrors` on `AsTask` attribute to ignore
parameters & options validation errors
* Add support for dynamic autocomplete task arguments/options
* Add support for merging an application `box.json` config file used by
Expand Down
14 changes: 10 additions & 4 deletions bin/generate-tests.php
Expand Up @@ -75,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;
Expand Down Expand Up @@ -119,7 +123,7 @@
;
foreach ($dirs as $dir) {
$class = u($dir->getRelativePath())->camel()->title()->append('Test')->toString();
add_test([], $class, '{{ base }}/tests/Examples/fixtures/broken/' . $dir->getRelativePath());
add_test([], $class, '{{ base }}/tests/Examples/fixtures/broken/' . $dir->getRelativePath(), true);
}

add_test(['args:passthru', 'a', 'b', '--no', '--foo', 'bar', '-x'], 'ArgPassthruExpanded');
Expand All @@ -140,7 +144,7 @@
add_test(['list'], 'LayoutWithFolder', __DIR__ . '/../tests/Examples/fixtures/layout/with-folder');
add_test(['list'], 'LayoutWithOldFolder', __DIR__ . '/../tests/Examples/fixtures/layout/with-old-folder');

function add_test(array $args, string $class, ?string $cwd = null)
function add_test(array $args, string $class, ?string $cwd = null, bool $needRemote = false)
{
$fp = fopen(__FILE__, 'r');
fseek($fp, __COMPILER_HALT_OFFSET__ + 1);
Expand All @@ -152,6 +156,7 @@ function add_test(array $args, string $class, ?string $cwd = null)
env: [
'COLUMNS' => 1000,
'ENDPOINT' => $_SERVER['ENDPOINT'],
'CASTOR_NO_REMOTE' => $needRemote ? 0 : 1,
],
timeout: null,
);
Expand All @@ -163,6 +168,7 @@ function add_test(array $args, string $class, ?string $cwd = null)
'{{ args }}' => implode(', ', array_map(fn ($arg) => var_export($arg, true), $args)),
'{{ exitCode }}' => $process->getExitCode(),
'{{ cwd }}' => $cwd ? ', ' . var_export($cwd, true) : '',
'{{ needRemote }}' => $needRemote ? ', needRemote: true' : '',
]);

file_put_contents(__DIR__ . '/../tests/Examples/Generated/' . $class . '.php', $code);
Expand All @@ -185,7 +191,7 @@ class {{ class_name }} extends TaskTestCase
// {{ task }}
public function test(): void
{
$process = $this->runTask([{{ args }}]{{ cwd }});
$process = $this->runTask([{{ args }}]{{ cwd }}{{ needRemote }});

$this->assertSame({{ exitCode }}, $process->getExitCode());
$this->assertStringEqualsFile(__FILE__ . '.output.txt', $process->getOutput());
Expand Down
5 changes: 4 additions & 1 deletion composer.json
Expand Up @@ -18,7 +18,10 @@
"psr-4": {
"Castor\\": "src/"
},
"files": ["src/functions.php"]
"files": [
"src/functions.php",
"src/functions-internal.php"
]
},
"autoload-dev": {
"psr-4": {
Expand Down
4 changes: 4 additions & 0 deletions doc/getting-started/basic-usage.md
Expand Up @@ -80,6 +80,10 @@ use function Castor\import;
import(__DIR__);
```

> [!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
Expand Down
142 changes: 142 additions & 0 deletions doc/going-further/extending-castor/remote-imports.md
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you add some words about the autoloader?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⬆️ No a blocker for now

@@ -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 will 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.
pyrech marked this conversation as resolved.
Show resolved Hide resolved

## 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
```
4 changes: 2 additions & 2 deletions doc/going-further/index.md
Expand Up @@ -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, autocomplete, 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.
25 changes: 25 additions & 0 deletions examples/remote-import.php
@@ -0,0 +1,25 @@
<?php

namespace remote_import;

use Castor\Attribute\AsTask;

use function Castor\import;

// Importing tasks from a Composer package
import('composer://pyrech/castor-example', version: '^1.0');
// Importing tasks from a Composer package not published on packagist (but still having a composer.json)
import('composer://pyrech/castor-example-package-not-published', version: '*', vcs: 'https://github.com/pyrech/castor-example-package-not-published.git');
// Importing tasks from a repository not using Composer
import('package://pyrech/foobar', source: [
'url' => '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
}
5 changes: 5 additions & 0 deletions phpstan-baseline.neon
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions phpstan.neon
Expand Up @@ -19,3 +19,10 @@ parameters:
foo?: string,
}
'''
ImportSource: '''
array{
url?: string,
type?: "git" | "svn",
reference?: string,
}
'''
24 changes: 22 additions & 2 deletions src/Console/Application.php
Expand Up @@ -20,6 +20,7 @@
use Castor\Fingerprint\FingerprintHelper;
use Castor\FunctionFinder;
use Castor\GlobalHelper;
use Castor\Import\Importer;
use Castor\PlatformHelper;
use Castor\WaitForHelper;
use Monolog\Logger;
Expand Down Expand Up @@ -59,10 +60,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, [
Expand Down Expand Up @@ -215,6 +217,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);
}

Expand Down
18 changes: 15 additions & 3 deletions src/Console/ApplicationFactory.php
Expand Up @@ -10,6 +10,10 @@
use Castor\ExpressionLanguage;
use Castor\Fingerprint\FingerprintHelper;
use Castor\FunctionFinder;
use Castor\Import\Importer;
use Castor\Import\Listener\RemoteImportListener;
use Castor\Import\Remote\Composer;
use Castor\Import\Remote\PackageImporter;
use Castor\Listener\GenerateStubsListener;
use Castor\Listener\UpdateCastorListener;
use Castor\Monolog\Processor\ProcessProcessor;
Expand Down Expand Up @@ -49,6 +53,9 @@ public static function create(): SymfonyApplication
$cache = new FilesystemAdapter(directory: $cacheDir);
$logger = new Logger('castor', [], [new ProcessProcessor()]);
$fs = new Filesystem();
$fingerprintHelper = new FingerprintHelper($cache);
$packageImporter = new PackageImporter($logger, new Composer($fs, $logger, $fingerprintHelper));
$importer = new Importer($packageImporter, $logger);
$eventDispatcher = new EventDispatcher(logger: $logger);
$eventDispatcher->addSubscriber(new UpdateCastorListener(
$cache,
Expand All @@ -58,8 +65,9 @@ public static function create(): SymfonyApplication
$eventDispatcher->addSubscriber(new GenerateStubsListener(
new StubsGenerator($rootDir, $logger),
));
$eventDispatcher->addSubscriber(new RemoteImportListener($packageImporter));

/** @var SymfonyApplication */
/** @var Application */
// @phpstan-ignore-next-line
$application = new $class(
$rootDir,
Expand All @@ -69,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
$packageImporter->setApplication($application);

$application->setDispatcher($eventDispatcher);
$application->add(new DebugCommand($rootDir, $cacheDir, $contextRegistry));

Expand Down