diff --git a/docs/cn/php/phpy.md b/docs/cn/php/phpy.md index 50073e0..d70aa88 100644 --- a/docs/cn/php/phpy.md +++ b/docs/cn/php/phpy.md @@ -150,6 +150,13 @@ composer require swoole/phpy `clear-cache`命令会根据`phpy.json`的`config.cache-dir`清除相关缓存,更多查看`--help` +### 7. 切换pip镜像 +```shell +./vendor/bin/phpy config:pip-mirror +``` + +`config:pip-mirror`预设部分pip镜像源提供选择变更,并且支持自定义pip镜像源,更多查看`--help` + ## 共建维护 ### 公共映射库 diff --git a/tests/phpunit/tools/phpy/ConfigTest.php b/tests/phpunit/tools/phpy/ConfigTest.php new file mode 100644 index 0000000..1f3437f --- /dev/null +++ b/tests/phpunit/tools/phpy/ConfigTest.php @@ -0,0 +1,83 @@ +set('a.b.c', 'value'); + $this->assertEquals('value', $config->get('a.b.c')); + + // 测试覆盖现有值 + $config->set('python.source-url', 'new_url'); + $this->assertEquals('new_url', $config->get('python.source-url')); + } + + public function testGetWithDefault() + { + $config = new Config(); + $this->assertNull($config->get('non.exist.key')); + $this->assertEquals('default', $config->get('non.exist.key', 'default')); + } + + public function testMergeConfigs() + { + $config1 = new Config(); + $config1->set('config.scan-dirs', ['/path1']); + + $config2 = new Config(); + $config2->set('config.scan-dirs', ['/path2']); + + $config1->merge($config2); + $this->assertEquals( + ['/path1', '/path2'], + $config1->get('config.scan-dirs') + ); + } + + public function testAllMethod() + { + $config = new Config(); + // transform=true时modules转stdClass + $result = $config->all(); + $this->assertInstanceOf(\stdClass::class, $result['modules']); + + // transform=false时保持原类型 + $result = $config->all(false); + $this->assertIsArray($result['modules']); + } + + public function testToStringSerialization() + { + $config = new Config(); + $jsonString = (string)$config; + + // 验证基础结构 + $this->assertStringContainsString('"python": {', $jsonString); + $this->assertStringContainsString('"modules": {}', $jsonString); + + // 验证格式选项 + $this->assertStringNotContainsString('\\/', $jsonString); // 验证JSON_UNESCAPED_SLASHES + $this->assertMatchesRegularExpression('/"install-dir":\s+"\/usr"/', $jsonString); // 验证缩进 + } + + public function testModulesHandling() + { + $config = new Config(); + // 测试空数组转换 + $config->set('modules', []); + $this->assertInstanceOf(\stdClass::class, $config->all()['modules']); + + // 测试非空数组保持原样 + $config->set('modules', ['test']); + $this->assertIsArray($config->all()['modules']); + } +} diff --git a/tests/phpunit/tools/phpy/VersionTest.php b/tests/phpunit/tools/phpy/VersionTest.php new file mode 100644 index 0000000..5f48765 --- /dev/null +++ b/tests/phpunit/tools/phpy/VersionTest.php @@ -0,0 +1,45 @@ +assertNull(Version::pepToSemver('1!2.3.4')); + $this->assertNull(Version::pepToSemver('1.2.3.post4')); + + // Test version normalization + $this->assertEquals('1.2.0', Version::pepToSemver('v1.2')); + $this->assertEquals('1.2.34', Version::pepToSemver('1.2.3.4')); + + // Test pre-release labels + $this->assertEquals('1.2.0-alpha.1', Version::pepToSemver('1.2a1')); + $this->assertEquals('1.2.0-beta.2', Version::pepToSemver('1.2b2')); + $this->assertEquals('1.2.0-rc.3', Version::pepToSemver('1.2rc3')); + $this->assertNull(Version::pepToSemver('1.2dev4')); + } + + public function testValidatePepVersion() + { + $this->assertTrue(Version::validatePepVersion('1.2.3')); + $this->assertFalse(Version::validatePepVersion('invalid-version')); + $this->assertTrue(Version::validatePepVersion('1.2.3-beta.4', false)); + } + + public function testSplitVersion() + { + $this->assertEquals([1, 2, 3], Version::splitVersion('v1.2.3')); + $this->assertEquals([1, 2, 0], Version::splitVersion('1.2')); + $this->assertEquals([1, 0, 0], Version::splitVersion('1')); + $this->assertEquals([2, 3, 4], Version::splitVersion('2.3.4.5.6')); + } +} diff --git a/tools/src/Phpy/Application.php b/tools/src/Phpy/Application.php index 854191a..96f6588 100644 --- a/tools/src/Phpy/Application.php +++ b/tools/src/Phpy/Application.php @@ -15,7 +15,6 @@ use PhpyTool\Phpy\Commands\MetadataPushCommand; use PhpyTool\Phpy\Commands\PythonInstall; use PhpyTool\Phpy\Commands\ScanCommand; -use PhpyTool\Phpy\Commands\ScanImport; use PhpyTool\Phpy\Commands\ShowCommand; use PhpyTool\Phpy\Commands\UpdateCommand; use PhpyTool\Phpy\Helpers\System; @@ -23,7 +22,7 @@ class Application extends \Symfony\Component\Console\Application { /** @var string */ - public const VERSION = '0.0.1'; + public const VERSION = '0.1.0'; /** @var string */ private string $logo = <<addCommands([ + // 共建资源相关 new MetadataQueryCommand(), new MetadataPushCommand(), - new ScanCommand(), + // 配置相关 new InitConfigCommand(), + new PipMirrorConfig(), + // phpy工具相关 new InstallCommand(), new UpdateCommand(), + new ScanCommand(), new ShowCommand(), + new ClearCacheCommand(), + // 独立安装命令 new PipModuleInstall(), new PhpyInstall(), new PythonInstall(), - new PipMirrorConfig(), - new ClearCacheCommand(), ]); } diff --git a/tools/src/Phpy/Commands/ClearCacheCommand.php b/tools/src/Phpy/Commands/ClearCacheCommand.php index 5dfa046..d494a9b 100644 --- a/tools/src/Phpy/Commands/ClearCacheCommand.php +++ b/tools/src/Phpy/Commands/ClearCacheCommand.php @@ -44,7 +44,7 @@ protected function handler(): int if (!$this->consoleIO?->ask( "No phpy.json in current directory, do you want to use the one at $cDir [Y,n]?", true, - ConfirmationQuestion::class + questionClass: ConfirmationQuestion::class )) { throw new CommandStopException("PHPy could not find a phpy.json file in $sDir"); } diff --git a/tools/src/Phpy/Commands/InstallCommand.php b/tools/src/Phpy/Commands/InstallCommand.php index 134475b..6b7bd72 100644 --- a/tools/src/Phpy/Commands/InstallCommand.php +++ b/tools/src/Phpy/Commands/InstallCommand.php @@ -65,7 +65,7 @@ protected function handler(): int if (!$this->consoleIO?->ask( "No phpy.json in current directory, do you want to use the one at $cDir [Y,n]?", true, - ConfirmationQuestion::class + questionClass: ConfirmationQuestion::class )) { throw new CommandStopException("PHPy could not find a phpy.json file in $sDir"); } diff --git a/tools/src/Phpy/Commands/PipMirrorConfig.php b/tools/src/Phpy/Commands/PipMirrorConfig.php index 14538d4..8707183 100644 --- a/tools/src/Phpy/Commands/PipMirrorConfig.php +++ b/tools/src/Phpy/Commands/PipMirrorConfig.php @@ -4,57 +4,96 @@ namespace PhpyTool\Phpy\Commands; -use Symfony\Component\Console\Helper\QuestionHelper; +use PhpyTool\Phpy\Helpers\System; use Symfony\Component\Console\Question\ChoiceQuestion; class PipMirrorConfig extends AbstractCommand { + + /** + * 预设的 pip 镜像源 + * + * @var array|string[] + */ + protected static array $mirrors = [ + 'Python官方' => 'https://pypi.org/simple/', + '清华' => 'https://pypi.tuna.tsinghua.edu.cn/simple', + '阿里云' => 'http://mirrors.aliyun.com/pypi/simple/', + '华为云' => 'https://mirrors.huaweicloud.com/repository/pypi/simple/', + '腾讯云' => 'https://mirrors.cloud.tencent.com/pypi/simple/', + '中科大' => 'https://pypi.mirrors.ustc.edu.cn/simple/', + '网易' => 'http://mirrors.163.com/pypi/simple/', + ]; + + /** + * 设置 pip 镜像源 + * + * @param string $name + * @param string $url + * @return bool + */ + public static function addMirror(string $name, string $url): bool + { + if (!static::$mirrors[$name] ?? null) { + static::$mirrors[$name] = $url; + return true; + } + return false; + } + /** @inheritdoc */ protected function configure(): void { parent::configure(); $this ->setName('config:pip-mirror') - ->setDescription('Set pip mirror'); - } + ->setDescription('Set pip mirror') + ->setHelp(<<process->executePip($pipCommand, $output) != 0) { - throw new \RuntimeException('Failed to execute pip command'); - } - return implode("\n", $output); +自定义pip镜像源需在当前项目根目录创建pip-mirror.json,运行时自动加载; +pip-mirror.json格式样例: + +{ + "Python官方": "https://pypi.org/simple/", + "中科大": "https://pypi.mirrors.ustc.edu.cn/simple/" +} + +自定义pip镜像源运行时自动加载。 +EOT + ); } /** @inheritdoc */ protected function handler(): int { - $this->consoleIO?->output('Current pip mirror url: ' . $this->execPipCommand('config get global.index-url')); $this->consoleIO?->output('Select the pip mirror source ...'); - $options = ['Python 官方', '清华', '阿里云', '华为云', '腾讯云', '中科大', '网易']; - - // 创建选择问题 - $question = new ChoiceQuestion( - '请选择 pip 镜像站:', - $options, - 0 - ); - - $question->setErrorMessage('Invalid option.'); - - $helper = new QuestionHelper(); - $selectedOption = $helper->ask($this->consoleIO?->getInput(), $this->consoleIO?->getOutput(), $question); - - match ($selectedOption) { - 'Python 官方' => $this->execPipCommand('config set global.index-url https://pypi.org/simple/'), - '清华' => $this->execPipCommand('config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple'), - '阿里云' => $this->execPipCommand('config set global.index-url http://mirrors.aliyun.com/pypi/simple/'), - '华为云' => $this->execPipCommand('config set global.index-url https://mirrors.huaweicloud.com/repository/pypi/simple/'), - '腾讯云' => $this->execPipCommand('config set global.index-url https://mirrors.cloud.tencent.com/pypi/simple/'), - '中科大' => $this->execPipCommand('config set global.index-url https://pypi.mirrors.ustc.edu.cn/simple/'), - '网易' => $this->execPipCommand('config set global.index-url http://mirrors.163.com/pypi/simple/'), - }; - - return $this->consoleIO?->success('已将 pip 源设置为: ' . $selectedOption); + // 加载自定义配置文件 + $config = System::getcwd(). '/pip-mirror.json'; + if (file_exists($config)) { + try { + $config = json_decode(trim(System::getFileContent($config)), true, flags: JSON_THROW_ON_ERROR); + if ($config) { + foreach ($config as $name => $url) { + if (filter_var($url, FILTER_VALIDATE_URL)) { + static::addMirror($name, $url); + } + } + } + } catch (\JsonException) {} + } + // 选择镜像源 + $options = array_keys(static::$mirrors); + $selectedOption = $this->consoleIO?->choice('请选择 pip 镜像站:', $options, 0, function (ChoiceQuestion $choice) { + $choice->setErrorMessage('Invalid option.'); + }); + $indexUrl = static::$mirrors[$selectedOption] ?? null; + if (!$indexUrl) { + return $this->consoleIO?->error('Invalid option.'); + } + if ($this->process->executePip("config set global.index-url $indexUrl", subOutput: true) !== 0) { + return $this->consoleIO?->error('Failed to set pip mirror.'); + } + return $this->consoleIO?->success("已将 pip 源设置为: $selectedOption -> $indexUrl"); } } diff --git a/tools/src/Phpy/Commands/ScanCommand.php b/tools/src/Phpy/Commands/ScanCommand.php index 46ed8c5..fca4032 100644 --- a/tools/src/Phpy/Commands/ScanCommand.php +++ b/tools/src/Phpy/Commands/ScanCommand.php @@ -46,7 +46,7 @@ protected function handler(): int if (!$this->consoleIO?->ask( "No phpy.json in current directory, do you want to use the one at $cDir [Y,n]?", true, - ConfirmationQuestion::class + questionClass: ConfirmationQuestion::class )) { throw new CommandStopException("PHPy could not find a phpy.json file in $sDir"); } diff --git a/tools/src/Phpy/Commands/UpdateCommand.php b/tools/src/Phpy/Commands/UpdateCommand.php index 2fd5c8f..a4846dd 100644 --- a/tools/src/Phpy/Commands/UpdateCommand.php +++ b/tools/src/Phpy/Commands/UpdateCommand.php @@ -54,7 +54,7 @@ protected function handler(): int if (!$this->consoleIO?->ask( "No phpy.json in current directory, do you want to use the one at $cDir [Y,n]?", true, - ConfirmationQuestion::class + questionClass: ConfirmationQuestion::class )) { throw new CommandStopException("PHPy could not find a phpy.json file in $sDir"); } diff --git a/tools/src/Phpy/Config.php b/tools/src/Phpy/Config.php index 34cf5bc..7ccdff7 100644 --- a/tools/src/Phpy/Config.php +++ b/tools/src/Phpy/Config.php @@ -46,8 +46,7 @@ class Config */ public function __toString(): string { - $this->config['modules'] = $this->config['modules'] ?: new \stdClass(); - return json_encode($this->config, JSON_UNESCAPED_UNICODE|JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); + return json_encode($this->all(), JSON_UNESCAPED_UNICODE|JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); } /** @@ -146,9 +145,10 @@ public function set(string $key, mixed $value): void */ public function all(bool $transform = true): array { + $config = $this->config; if ($transform) { - $this->config['modules'] = $this->config['modules'] ?: new \stdClass(); + $config['modules'] = $config['modules'] ?: new \stdClass(); } - return $this->config; + return $config; } } diff --git a/tools/src/Phpy/ConsoleIO.php b/tools/src/Phpy/ConsoleIO.php index a5593be..8635a62 100644 --- a/tools/src/Phpy/ConsoleIO.php +++ b/tools/src/Phpy/ConsoleIO.php @@ -4,6 +4,7 @@ namespace PhpyTool\Phpy; +use Closure; use PhpyTool\Phpy\Helpers\Process; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Formatter\OutputFormatterStyle; @@ -12,9 +13,8 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\ConsoleOutput; use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Console\Question\ConfirmationQuestion; +use Symfony\Component\Console\Question\ChoiceQuestion; use Symfony\Component\Console\Question\Question; -use Symfony\Component\Console\Style\SymfonyStyle; class ConsoleIO { @@ -131,6 +131,9 @@ public function getExtra(?string $key = null, mixed $default = null): mixed */ public function ask(string $message, mixed $default = null, string $tag = '[?]', string $questionClass = Question::class): mixed { + if (!is_a($questionClass, Question::class, true)) { + throw new \InvalidArgumentException("$questionClass is not a valid Question class"); + } // 询问安装目录 $question = new $questionClass("$tag $message \n", $default); /** @var QuestionHelper $questionHelper */ @@ -138,6 +141,22 @@ public function ask(string $message, mixed $default = null, string $tag = '[?]', return $questionHelper->ask($this->getInput(), $this->getOutput(), $question); } + /** + * @param string $message + * @param array $choices + * @param mixed|null $default + * @param Closure|null $closure = function (ChoiceQuestion $question) {} + * @param string $tag + * @return mixed + */ + public function choice(string $message, array $choices, mixed $default = null, ?Closure $closure = null, string $tag = '[?]'): mixed + { + $question = new ChoiceQuestion("$tag $message \n", $choices, $default); + /** @var QuestionHelper $questionHelper */ + $questionHelper = $this->getHelperSet()->get('question'); + return $questionHelper->ask($this->getInput(), $this->getOutput(), $question); + } + /** * sub输出 * diff --git a/tools/src/Phpy/Helpers/PackageCollector.php b/tools/src/Phpy/Helpers/PackageCollector.php index 64d2e9f..1271eea 100644 --- a/tools/src/Phpy/Helpers/PackageCollector.php +++ b/tools/src/Phpy/Helpers/PackageCollector.php @@ -10,8 +10,15 @@ class PackageCollector extends NodeVisitorAbstract { + /** + * @var array + */ private array $packages = []; + /** + * @param Node $node + * @return void + */ public function enterNode(Node $node): void { $foundPackageFn = function (Node $node, int $index = 0) { @@ -44,11 +51,18 @@ public function enterNode(Node $node): void } } + /** + * @return array + */ public function getPackages(): array { return array_unique($this->packages); // 去重 } + /** + * @param $filePath + * @return array + */ static function parseFile($filePath): array { $code = file_get_contents($filePath); diff --git a/tools/src/Phpy/Helpers/Process.php b/tools/src/Phpy/Helpers/Process.php index b6874f1..9732972 100644 --- a/tools/src/Phpy/Helpers/Process.php +++ b/tools/src/Phpy/Helpers/Process.php @@ -57,6 +57,11 @@ private function debugMode(string $info): void /** * 执行命令 + * - 默认将stderr重定向合并至stdout + * - command支持使用2>,自定义stderr重定向 + * - 2>/dev/null: 忽略stderr + * - 2>&1: 合并stderr至stdout + * - 2>/tmp/run.log: 将stderr重定向至文件 * * @param string $command 执行命令 * @param array|null $output stdout & stderr (结果列表始终为倒序) @@ -65,7 +70,7 @@ private function debugMode(string $info): void */ public function execute(string $command, ?array &$output = null, bool $subOutput = false): int { - $command = str_ends_with($command, ' 2>&1') ? $command : "$command 2>&1"; + $command = str_contains($command, ' 2>') ? $command : "$command 2>&1"; $this->debugMode("execute( $command )"); $resultCode = -1; $output = $output === null ? [] : $output; @@ -132,6 +137,7 @@ public function executePythonConfig(string $command, ?array &$output = null, boo /** * 请求 + * - 当未安装curl拓展时尝试使用curl命令执行http请求 * * @param string $method * @param string $url diff --git a/tools/src/Phpy/Helpers/System.php b/tools/src/Phpy/Helpers/System.php index 01bd24d..1e7c498 100644 --- a/tools/src/Phpy/Helpers/System.php +++ b/tools/src/Phpy/Helpers/System.php @@ -22,7 +22,7 @@ class System /** * @return false|string */ - public static function getcwd() + public static function getcwd(): bool|string { return $GLOBALS['PHPY_CWD'] ?? getcwd(); } @@ -31,7 +31,7 @@ public static function getcwd() * @param string $cwd * @return void */ - public static function setcwd(string $cwd) + public static function setcwd(string $cwd): void { $GLOBALS['PHPY_CWD'] = $cwd; } @@ -52,7 +52,6 @@ public static function python(?string $path = null): string throw new PhpyException('Python not found. '); } } -// System::putFileContent($command, $path); return $path; } @@ -72,7 +71,6 @@ public static function pip(?string $path = null): string throw new PhpyException('Python-pip not found. '); } } -// System::putFileContent($command, $path); return $path; } @@ -92,7 +90,6 @@ public static function pythonConfig(?string $path = null): string throw new PhpyException('Python-config not found. '); } } -// System::putFileContent($command, $path); return $path; } diff --git a/tools/src/Phpy/Helpers/Version.php b/tools/src/Phpy/Helpers/Version.php index c2d4470..44a297c 100644 --- a/tools/src/Phpy/Helpers/Version.php +++ b/tools/src/Phpy/Helpers/Version.php @@ -21,7 +21,7 @@ public static function getPepVersions(string $module): array static $modulePepVersions = []; if (!isset($modulePepVersions[$module])) { $retry = 0; - do { + while (1) { try { $res = (new Process())->request( 'GET', @@ -31,17 +31,19 @@ public static function getPepVersions(string $module): array 'Content-Type' => 'application/json' ] ); - } catch (\Throwable) { - echo "request error, retry $retry"; + break; + } catch (\Throwable $throwable) { + if ($retry > 2) { + throw new PhpyException("Request failed: {$throwable->getMessage()}"); + } usleep(($retry + 1 ) * 200 * 1000); } finally { $retry ++; } - } while ($retry < 3); - + } $httpCode = $res['httpCode'] ?? 500; - $res = json_decode($responseBody = $res['responseBody'], true); + $res = json_decode(($responseBody = $res['responseBody'] ?? ''), true); if ($httpCode === 404) { $modulePepVersions[$module] = []; } else if ($httpCode === 200) { diff --git a/tools/src/Phpy/Installers/ModuleInstaller.php b/tools/src/Phpy/Installers/ModuleInstaller.php index b1e6feb..60d3f0a 100644 --- a/tools/src/Phpy/Installers/ModuleInstaller.php +++ b/tools/src/Phpy/Installers/ModuleInstaller.php @@ -67,7 +67,7 @@ public function scan(): void $this->consoleIO?->output("Scanning $file"); $scannedPackages = PackageCollector::parseFile($file); foreach ($scannedPackages as $package) { - $this->consoleIO?->subOutput("-- scanned: $package"); + $this->consoleIO?->subOutput("-- scanned top_level: $package"); } $packages = array_merge($packages, $scannedPackages); } @@ -108,9 +108,9 @@ public function scan(): void } if ($modules) { $count = count($modules); - $this->consoleIO?->output("Scanned: $count"); + $this->consoleIO?->output("Scanned modules: $count"); foreach ($modules as $module => $version) { - $this->consoleIO?->subOutput("-- $module - $version"); + $this->consoleIO?->subOutput("-- module_name: $module - $version"); } } if (!$this->consoleIO?->ask(