diff --git a/docs/cn/php/phpy.md b/docs/cn/php/phpy.md index a5b3732..50073e0 100644 --- a/docs/cn/php/phpy.md +++ b/docs/cn/php/phpy.md @@ -102,6 +102,8 @@ composer require swoole/phpy `update`命令会根据`phpy.json`配置信息进行更新,更多查看`--help` +- 如果已经构建好Python环境,建议使用`./vendor/bin/phpy update --skip-build-tools --skip-env --skip-ext`跳过环境构建,避免重复执行 + ### 4. 环境检查 #### 命令: @@ -135,6 +137,10 @@ composer require swoole/phpy `scan`命令会根据`phpy.json`的`config.scan-dirs`扫描所有php文件并检查依赖的`Python-module`, 引入并安装,更多查看`--help` +- `scan`命令维护了一个`Python module`的`top_level`与`module_name`的映射表,在映射表中不存在映射关系的时候需要手动确认 +- `scan`命令负责将扫描结果保存至`phpy.json`,由`ModuleInstall->upgrade()`负责构建`requirements.txt`及安装 +- 如安装过程失败,请根据错误信息进行环境补足,一般情况是缺少依赖,待依赖安装完成后重复执行`scan`即可安装 + ### 6. 缓存清除 #### 命令: @@ -143,3 +149,21 @@ composer require swoole/phpy ``` `clear-cache`命令会根据`phpy.json`的`config.cache-dir`清除相关缓存,更多查看`--help` + +## 共建维护 + +### 公共映射库 + +包 = 模块,包名 = 模块名,但在模块之中以import引入的名称是模块的`top_level`,`requirements.txt` +安装的依据是`module_name`,但在`Python`世界中,`top_level`并不一定与`module_name`相同; + +因此`PHPy`通过`supabase`公共库储存维护了一张`top_level`与`module_name`的映射表,这张映射表需要 +开发者们一起积极维护; + - `PHPy`提供了一个`metadata:push`的命令,开发者可以手动提交映射关系至公共库; + - `PHPy`提供了一个`metadata:query`的命令,开发者可以查看映射关系公共库; + - `PHPy`的`scan`的命令也会在未索引到映射关系时提示开发者手动输入,输入数据在随后会自动同步到公共库; + +**!这里我们倡导所有使用者及开发者,请爱护好该映射库,请勿破坏!** + +**!公共库目前以免费版储存数据提供至开源社区,请勿过量占用资源!** + diff --git a/tests/mock/import.php b/tests/mock/import.php new file mode 100644 index 0000000..a8fd280 --- /dev/null +++ b/tests/mock/import.php @@ -0,0 +1,10 @@ +addCommands([ + new MetadataQueryCommand(), + new MetadataPushCommand(), new ScanCommand(), new InitConfigCommand(), new InstallCommand(), diff --git a/tools/src/Phpy/Commands/InstallCommand.php b/tools/src/Phpy/Commands/InstallCommand.php index 3bfcfb5..134475b 100644 --- a/tools/src/Phpy/Commands/InstallCommand.php +++ b/tools/src/Phpy/Commands/InstallCommand.php @@ -81,7 +81,9 @@ protected function handler(): int } $config = new Config($lockFile ?: $jsonFile); // build tools - (new BuildToolsInstaller($config, $this->consoleIO))->install(); + if (!$this->consoleIO?->getInput()->getOption('skip-build-tools')) { + (new BuildToolsInstaller($config, $this->consoleIO))->install(); + } // install python env if (!$this->consoleIO?->getInput()->getOption('skip-env')) { (new PythonInstaller($config, $this->consoleIO))->install(); diff --git a/tools/src/Phpy/Commands/MetadataPushCommand.php b/tools/src/Phpy/Commands/MetadataPushCommand.php new file mode 100644 index 0000000..4bde1d0 --- /dev/null +++ b/tools/src/Phpy/Commands/MetadataPushCommand.php @@ -0,0 +1,91 @@ +setName('metadata:push') + ->setDescription('Submit metadata to make scan smarter.') + ->setHelp(<<metadata:push command collects the mapping between Python modules and + top levels to a public database; more accurate data submissions can help + make the scan command smarter. +EOT + ); + } + + /** @inheritdoc */ + protected function handler(): int + { + $pushed = []; + while (1) { + $metadata = $this->consoleIO?->ask( + "Please enter the metadata information in the format: [top_level]:[module_name]" + ); + $metadataArray = explode(':', $metadata); + if (count($metadataArray) !== 2) { + $this->consoleIO?->error("Invalid metadata information. $metadata"); + continue; + } + list($topLevel, $moduleName) = $metadataArray; + $pushed[$topLevel] = $moduleName; + $continue = $this->consoleIO?->ask( + "Is there anything else? [Y,n]", + true, + questionClass: ConfirmationQuestion::class + ); + if (!$continue) { + break; + } + } + if (!$pushed) { + return $this->consoleIO?->success('No metadata information entered.'); + } + $count = count($pushed); + // 输出 $pushed 的内容供用户确认 + $this->consoleIO?->output("Metadata to be pushing ($count):"); + foreach ($pushed as $topLevel => $moduleName) { + $this->consoleIO?->subOutput( + "top_level: $topLevel module_name: $moduleName" + ); + } + // 确认是否继续 + $confirm = $this->consoleIO?->ask( + 'Do you want to proceed with pushing the metadata? [Y,n]', + true, + questionClass: ConfirmationQuestion::class + ); + if (!$confirm) { + return $this->consoleIO?->comment('Cancelled.'); + } + $bar = new ProgressBar($this->consoleIO?->getOutput(), count($pushed)); + $this->consoleIO?->output('Start pushing metadata.'); + foreach ($pushed as $topLevel => $moduleName) { + PythonMetadata::pushMetadata($topLevel, $moduleName); + $bar->advance(); + } + $bar->finish(); + $bar->clear(); + return $this->consoleIO?->success('completed.'); + } +} diff --git a/tools/src/Phpy/Commands/MetadataQueryCommand.php b/tools/src/Phpy/Commands/MetadataQueryCommand.php new file mode 100644 index 0000000..356e2d6 --- /dev/null +++ b/tools/src/Phpy/Commands/MetadataQueryCommand.php @@ -0,0 +1,95 @@ +setName('metadata:query') + ->setDescription('Query metadata') + ->addOption('top_level', 't',InputOption::VALUE_OPTIONAL, 'top_level') + ->addOption('module_name', 'm',InputOption::VALUE_OPTIONAL, 'module_name'); + } + + /** @inheritdoc */ + protected function handler(): int + { + $topLevel = $this->consoleIO->getInput()->getOption('top_level'); + $moduleName = $this->consoleIO->getInput()->getOption('module_name'); + + // 分页配置 + $pageSize = 5; + $currentPage = 1; + $get = true; + $list = []; + $schema = ['ID', 'Module Name', 'Top Level', 'Version', 'Created At']; + do { + $list = $get ? PythonMetadata::queryMetadata($topLevel, $moduleName, $pageSize, ($currentPage - 1) * $pageSize) : $list; + // 检查是否有数据 + if ($list) { + $table = new Table($this->consoleIO->getOutput()); + $table->setHeaders($schema); + foreach ($list as $item) { + $table->addRow([ + $item['id'], + $item['module_name'], + $item['top_level'], + $item['version'], + $item['created_at'] + ]); + } + $ask = ($currentPage > 1) ? + 'Press n for next page, p for previous page, or Ctrl + C to quit.' : + 'Press n for next page, or Ctrl + C to quit.'; + } else { + $table = new Table($this->consoleIO->getOutput()); + $table->setHeaders($schema); + $table->addRow([ + '--', + '--', + '--', + '--', + '--' + ]); + $ask = 'Press p for previous page, or Ctrl + C to quit.'; + } + $table->render(); + // 显示导航信息 + $this->consoleIO->output("Page $currentPage"); + $input = $this->consoleIO?->ask($ask); + switch ($input) { + case 'n': + if (!$list) { + $get = false; + break; + } + $get = true; + $currentPage ++; + break; + case 'p': + $currentPage --; + $get = true; + if ($currentPage < 1) { + $get = false; + $currentPage = 1; + break; + } + break; + default: + $get = false; + break; + } + } while (true); + } +} diff --git a/tools/src/Phpy/Commands/ScanCommand.php b/tools/src/Phpy/Commands/ScanCommand.php index 6ae8b76..46ed8c5 100644 --- a/tools/src/Phpy/Commands/ScanCommand.php +++ b/tools/src/Phpy/Commands/ScanCommand.php @@ -65,7 +65,7 @@ protected function handler(): int // 解析 import依赖 $moduleInstaller->scan(); // 安装 - $moduleInstaller->install(); + $moduleInstaller->upgrade(); } catch (CommandStopException $exception) { return $this->consoleIO?->success($exception->getMessage() ?: 'Scan stop.'); } catch (CommandSuccessedException $exception) { diff --git a/tools/src/Phpy/Commands/UpdateCommand.php b/tools/src/Phpy/Commands/UpdateCommand.php index 8ae63da..2fd5c8f 100644 --- a/tools/src/Phpy/Commands/UpdateCommand.php +++ b/tools/src/Phpy/Commands/UpdateCommand.php @@ -28,6 +28,10 @@ protected function configure(): void ->setName('update') ->setDescription('Updates your dependencies to the latest version according to phpy.json, and updates the phpy.lock file') ->addArgument('module', InputArgument::OPTIONAL, 'Python module name to update') + ->addOption('skip-build-tools', null, null, 'Skip the build tools installation.') + ->addOption('skip-env', null, null, 'Skip the environment upgrade.') + ->addOption('skip-ext', null, null, 'Skip the phpy extension upgrade.') + ->addOption('skip-module', null, null, 'Skip the module upgrade.') ->setHelp( <<update command reads the phpy.json file from the @@ -62,9 +66,12 @@ protected function handler(): int } // 尝试读取lock $lockFile = Application::getLockFile(System::getcwd()); - $config = new Config($lockFile ?: $jsonFile); + $config = new Config($jsonFile); + $config->merge(new Config($lockFile)); // build tools - (new BuildToolsInstaller($config, $this->consoleIO))->install(); + if (!$this->consoleIO?->getInput()->getOption('skip-build-tools')) { + (new BuildToolsInstaller($config, $this->consoleIO))->install(); + } // update python env if (!$this->consoleIO?->getInput()->getOption('skip-env')) { (new PythonInstaller($config, $this->consoleIO))->upgrade(); diff --git a/tools/src/Phpy/Config.php b/tools/src/Phpy/Config.php index a39bfde..34cf5bc 100644 --- a/tools/src/Phpy/Config.php +++ b/tools/src/Phpy/Config.php @@ -72,12 +72,27 @@ public function load(string $file): void $this->config = array_replace_recursive($this->config, $config ?? []); } + /** + * @param string $file + * @return void + */ public function save(string $file): void { $content = $this->__toString(); System::putFileContent($file, $content); } + /** + * @param Config ...$configs + * @return void + */ + public function merge(Config ...$configs): void + { + foreach ($configs as $config) { + $this->config = array_merge_recursive($this->config, $config->all(false)); + } + } + /** * 获取配置 * @@ -99,7 +114,7 @@ public function get(?string $key, mixed $default = null): mixed $config = $config[$index]; } - return $found ? $config : $default; + return $found ? (($config instanceof \stdClass) ? [] : $config) : $default; } return $config; @@ -126,11 +141,14 @@ public function set(string $key, mixed $value): void } /** + * @param bool $transform * @return array */ - public function all(): array + public function all(bool $transform = true): array { - $this->config['modules'] = $this->config['modules'] ?: new \stdClass(); + if ($transform) { + $this->config['modules'] = $this->config['modules'] ?: new \stdClass(); + } return $this->config; } } diff --git a/tools/src/Phpy/ConsoleIO.php b/tools/src/Phpy/ConsoleIO.php index fd7c2ae..a5593be 100644 --- a/tools/src/Phpy/ConsoleIO.php +++ b/tools/src/Phpy/ConsoleIO.php @@ -126,7 +126,7 @@ public function getExtra(?string $key = null, mixed $default = null): mixed * @param string $message * @param mixed|null $default * @param string $tag - * @param class-string $questionClass + * @param class-string $questionClass when $questionClass is ConfirmationQuestion::class, it will be 'confirm' * @return mixed */ public function ask(string $message, mixed $default = null, string $tag = '[?]', string $questionClass = Question::class): mixed @@ -138,13 +138,6 @@ public function ask(string $message, mixed $default = null, string $tag = '[?]', return $questionHelper->ask($this->getInput(), $this->getOutput(), $question); } - public function confirm(string $msg, $default = false) - { - $helper = new QuestionHelper(); - $question = new ConfirmationQuestion($msg, $default); - return $helper->ask($this->getInput(), $this->getOutput(), $question); - } - /** * sub输出 * diff --git a/tools/src/Phpy/Helpers/Process.php b/tools/src/Phpy/Helpers/Process.php index 44cb7ad..b6874f1 100644 --- a/tools/src/Phpy/Helpers/Process.php +++ b/tools/src/Phpy/Helpers/Process.php @@ -5,6 +5,7 @@ namespace PhpyTool\Phpy\Helpers; use PhpyTool\Phpy\ConsoleIO; +use PhpyTool\Phpy\Exceptions\PhpyException; class Process { @@ -128,4 +129,126 @@ public function executePythonConfig(string $command, ?array &$output = null, boo $python = System::pythonConfig(); return $this->execute("$python $command", $output, subOutput: $subOutput); } + + /** + * 请求 + * + * @param string $method + * @param string $url + * @param array $data + * @param array $headers + * @return array{httpCode:int, responseBody:string} + */ + public function request(string $method, string $url, array $data = [], array $headers = []): array + { + $method = strtoupper($method); + $headers['Accept'] ??= 'application/json'; + // 统一处理 GET 参数 + if ($method === 'GET') { + $url .= $data + ? (str_contains($url, '?') ? '&' : '?') . http_build_query($data) + : ''; + } else { + $jsonData = json_encode($data, JSON_UNESCAPED_UNICODE); + $headers['Content-Type'] = 'application/json'; + $headers['Content-Length'] = strlen($jsonData); + } + // 没有安装curl,则使用curl命令 + if (!extension_loaded('curl')) { + return $this->requestUseCurlShell($method, $url, $data, $headers); + } + $ch = curl_init(); + try { + $options = [ + CURLOPT_URL => $url, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CUSTOMREQUEST => $method, + CURLOPT_HTTPHEADER => self::formatHeaders($headers), + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_CONNECTTIMEOUT => 30, + CURLOPT_TIMEOUT => 60, + ]; + if (isset($jsonData)) { + $options[CURLOPT_POSTFIELDS] = $jsonData; + } + curl_setopt_array($ch, $options); + $responseBody = curl_exec($ch); + if ($errorNo = curl_errno($ch)) { + throw new PhpyException("cURL error ($errorNo): " . curl_error($ch), $errorNo); + } + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + return [ + 'httpCode' => (int)$httpCode, + 'responseBody' => (string)$responseBody + ]; + } finally { + curl_close($ch); + } + } + + /** + * 使用curl命令请求 + * + * @param string $method + * @param string $url + * @param array $data + * @param array $headers + * @return array{httpCode:int, responseBody:string} + */ + public function requestUseCurlShell(string $method, string $url, array $data = [], array $headers = []): array + { + $method = strtoupper($method); + $cmd = [ + 'curl', + '-sS', + '-X', $method, + '-o', '-', + '-w', "HTTP_CODE=%{http_code}", + '--connect-timeout', '30', + '--max-time', '60' + ]; + // 添加请求头 + foreach (self::formatHeaders($headers) as $header) { + array_push($cmd, '-H', escapeshellarg($header)); + } + + // 处理请求体 + if ($method !== 'GET' and $data) { + $jsonData = json_encode($data, JSON_UNESCAPED_UNICODE); + array_push($cmd, '--data', escapeshellarg($jsonData)); + } + + $cmd[] = escapeshellarg($url); + $output = []; + $resultCode = $this->execute(implode(' ', $cmd), $output); + // 处理命令行执行错误 + if ($resultCode !== 0) { + throw new PhpyException("cURL command failed (exit $resultCode): " . implode("\n", $output), $resultCode); + } + $rawResponse = implode('', array_reverse($output)); + if (preg_match('/^(?.*)HTTP_CODE=(?\d+)$/s', $rawResponse, $matches)) { + return [ + 'httpCode' => (int)$matches['http_code'], + 'responseBody' => (string)$matches['response_body'] + ]; + } else { + throw new PhpyException("Invalid response format: $rawResponse"); + } + } + + /** + * headers map to headers + * + * @param array $headers + * @return array + */ + private static function formatHeaders(array $headers): array + { + return array_map( + fn($k, $v) => "$k: $v", + array_keys($headers), + array_values($headers) + ); + } + } diff --git a/tools/src/Phpy/Helpers/PythonMetadata.php b/tools/src/Phpy/Helpers/PythonMetadata.php index 8c673b6..d5e29df 100644 --- a/tools/src/Phpy/Helpers/PythonMetadata.php +++ b/tools/src/Phpy/Helpers/PythonMetadata.php @@ -2,116 +2,132 @@ namespace PhpyTool\Phpy\Helpers; +use PhpyTool\Phpy\Exceptions\PhpyException; + class PythonMetadata { - public static function isStdLibrary(string $module) + /** @var string */ + protected static string $apiKey = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InRmYmpnbnBtdGx6eXRiZXp3dGR4Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDI5ODQ5MjQsImV4cCI6MjA1ODU2MDkyNH0.trmgSPSI55IRnU4ko1E6gJQNi-kyXbobsl61bkRZVBE'; + + /** @var string */ + protected static string $supabaseUrl = 'https://tfbjgnpmtlzytbezwtdx.supabase.co'; + + /** + * 判断是否是标准库 + * + * @param string $module + * @return bool + */ + public static function isStdLibrary(string $module): bool { static $stdLibrary = null; if (!$stdLibrary) { - $stdLibrary = array_flip(self::getStdLibrary()); + $stdLibrary = array_flip(self::getPythonStdModules()); } return isset($stdLibrary[$module]); } - private static function getStdLibrary(): array + /** + * 通过系统函数获取python标准库 + * + * @return array + */ + public static function getPythonStdModules(): array + { + $pythonCode = <<<'PYTHON' +import sys, json +print(json.dumps(sorted(sys.stdlib_module_names))) +PYTHON; + // 执行命令并捕获输出 + (new Process())->executePython("-c '$pythonCode'", $output); + return json_decode($output[0], true) ?? []; + } + + /** + * 上报metadata + * + * @param string $topLevel + * @param string $moduleName + * @param string $version + * @return array + */ + public static function pushMetadata(string $topLevel, string $moduleName, string $version = 'default'): array { - return [ - "os", - "sys", - "re", - "math", - "random", - "dataclasses", - "json", - "datetime", - "time", - "calendar", - "collections", - "heapq", - "bisect", - "array", - "weakref", - "types", - "copy", - "pprint", - "reprlib", - "enum", - "numbers", - "textwrap", - "this", - "cmath", - "decimal", - "fractions", - "statistics", - "itertools", - "functools", - "operator", - "pathlib", - "fileinput", - "stat", - "filecmp", - "tempfile", - "glob", - "fnmatch", - "linecache", - "shutil", - "macpath", - "pickle", - "copyreg", - "shelve", - "marshal", - "dbm", - "sqlite3", - "zlib", - "gzip", - "bz2", - "lzma", - "zipfile", - "tarfile", - "csv", - "configparser", - "netrc", - "xdrlib", - "plistlib", - "hashlib", - "hmac", - "secrets", - "builtins", - "platform", - "asyncio", - "socket", - "turtle", - "typing", - "tkinter", - ]; + $res = (new Process())->request( + 'POST', + static::$supabaseUrl . "/rest/v1/rpc/push_metadata", + [ + 'p_module_name' => $moduleName, + 'p_top_level' => $topLevel, + 'p_version' => $version + ], + [ + 'Content-Type' => 'application/json', + 'apikey' => static::$apiKey, + 'Authorization' => 'Bearer ' . static::$apiKey + ] + ); + if ($res['httpCode'] !== 200) { + throw new PhpyException("push metadata failed: {$res['responseBody']}"); + } + return json_decode($res['responseBody'], true) ?? []; } - static function getPipPackage(string $module): ?string + /** + * 查询metadata + * + * @param string|null $topLevel + * @param string|null $moduleName + * @param int $limit + * @param int $offset + * @return array + */ + public static function queryMetadata(?string $topLevel, ?string $moduleName, int $limit = 0, int $offset = 0): array { - static $map = array( - "torch" => "torch", - "transformers" => "transformers", - "accelerate" => "accelerate", - "TermTk" => "pytermtk", - "paddlenlp" => "paddlenlp", - "cefpython3" => "cefpython3", - "dearpygui" => "dearpygui", - "pygame" => "pygame", - "gi" => "PyGObject", - "gradio_client" => "gradio_client", - "gradio" => "gradio", - "openai" => "openai", - "webview" => "pywebview", - "numpy" => "numpy", - "magicgui" => "magicgui", - "modelscope" => "modelscope", - "tqdm" => "tqdm", - "llama_index" => "llama-index", - "wx" => "wxPython", - "pyqt5" => "PyQt5", + $res = (new Process())->request( + 'POST', + static::$supabaseUrl . "/rest/v1/rpc/query_metadata", + [ + 'p_module_name' => $moduleName, + 'p_top_level' => $topLevel, + 'p_limit' => $limit, + 'p_offset' => $offset, + ], + [ + 'Content-Type' => 'application/json', + 'apikey' => static::$apiKey, + 'Authorization' => 'Bearer ' . static::$apiKey + ] ); + if ($res['httpCode'] !== 200) { + throw new PhpyException("query metadata failed: {$res['responseBody']}"); + } + return json_decode($res['responseBody'], true) ?? []; + } - [$root] = explode('.', $module); - return $map[$root] ?? null; + /** + * 根据topLevel查询对应的模块信息 + * + * @param string $nameString + * @param string $version + * @return string|null + */ + static function getPipPackage(string $nameString, string $version = 'default'): ?string + { + static $metadataMap = []; + [$topLevel] = explode('.', $nameString); + if (!isset($metadataMap[$topLevel])) { + $moduleName = null; + if ($query = static::queryMetadata($topLevel, null)) { + foreach ($query as $item) { + if ($version === $item['version']) { + $moduleName = $item['module_name']; + break; + } + } + } + $metadataMap[$topLevel] = $moduleName; + } + return $metadataMap[$topLevel] ?? null; } } \ No newline at end of file diff --git a/tools/src/Phpy/Helpers/System.php b/tools/src/Phpy/Helpers/System.php index 3f374f1..01bd24d 100644 --- a/tools/src/Phpy/Helpers/System.php +++ b/tools/src/Phpy/Helpers/System.php @@ -168,7 +168,7 @@ public static function getBuildToolsList(): array { return match (static::getPackageManager()) { 'apk' => [ - 'gcc', 'g++', 'make', 'autoconf', + 'gcc', 'g++', 'make', 'autoconf', 'cmake', 'musl-dev', 'expat-dev', 'libffi-dev', @@ -196,10 +196,7 @@ public static function getBuildToolsList(): array 'linux-headers-generic' ], 'yum' => [ - 'gcc', - 'gcc-c++', - 'make', - 'autoconf', + 'gcc', 'gcc-c++', 'make', 'autoconf', 'cmake', 'expat-devel', 'libffi-devel', 'openssl-devel', @@ -211,10 +208,7 @@ public static function getBuildToolsList(): array 'kernel-headers' ], 'zypper' => [ - 'gcc', - 'gcc-c++', - 'make', - 'autoconf', + 'gcc', 'gcc-c++', 'make', 'autoconf', 'cmake', 'libexpat-devel', 'libffi-devel', 'libopenssl-devel', @@ -226,9 +220,7 @@ public static function getBuildToolsList(): array 'kernel-default-devel' ], 'pacman' => [ - 'gcc', - 'make', - 'autoconf', + 'gcc', 'make', 'autoconf', 'cmake', 'expat', 'libffi', 'openssl', diff --git a/tools/src/Phpy/Helpers/Version.php b/tools/src/Phpy/Helpers/Version.php index 0835d40..c2d4470 100644 --- a/tools/src/Phpy/Helpers/Version.php +++ b/tools/src/Phpy/Helpers/Version.php @@ -5,18 +5,132 @@ namespace PhpyTool\Phpy\Helpers; use Composer\Semver\VersionParser; +use PhpyTool\Phpy\Exceptions\PhpyException; class Version { /** - * @param string $version + * 获取模块的PEP可用版本列表 + * + * @param string $module + * @return array + */ + public static function getPepVersions(string $module): array + { + static $modulePepVersions = []; + if (!isset($modulePepVersions[$module])) { + $retry = 0; + do { + try { + $res = (new Process())->request( + 'GET', + "https://pypi.org/pypi/$module/json", + [], + [ + 'Content-Type' => 'application/json' + ] + ); + } catch (\Throwable) { + echo "request error, retry $retry"; + usleep(($retry + 1 ) * 200 * 1000); + } finally { + $retry ++; + } + } while ($retry < 3); + + $httpCode = $res['httpCode'] ?? 500; + + $res = json_decode($responseBody = $res['responseBody'], true); + if ($httpCode === 404) { + $modulePepVersions[$module] = []; + } else if ($httpCode === 200) { + $modulePepVersions[$module] = array_keys($res['releases'] ?? []); + } else { + throw new PhpyException("Request failed: $responseBody"); + } + } + return $modulePepVersions[$module] ?? []; + } + + /** + * 将符合 PEP 440 稳定版格式(含有限预发行标识)的版本号转换为 SemVer 格式。 + * + * 规则说明: + * 1. 如果版本中包含 epoch(例如 "1!1.2.3")或 post-release(例如 "1.2.3.post1"),返回 null。 + * 2. 仅支持纯数字部分和预发行部分(预发行部分仅允许 a/alpha、b/beta、rc),格式必须完全符合要求。 + * 3. 数字部分:若不足三段,则补 0;若超过三段,则将第三段及以后部分合并为 patch, + * 如 "2.3.4.5" 转换为 "2.3.45"。 + * 4. 预发行部分转换为 SemVer 格式,形式为 "-