diff --git a/.gitignore b/.gitignore
index fc4d358..e222716 100644
--- a/.gitignore
+++ b/.gitignore
@@ -53,4 +53,7 @@ acinclude.m4
config.guess
config.sub
/composer.lock
-
+/*.command
+/py-vendor
+/requirements.txt
+/phpy.lock
diff --git a/bin/phpy b/bin/phpy
index c7ab205..4a0decd 100755
--- a/bin/phpy
+++ b/bin/phpy
@@ -1,21 +1,12 @@
#!/usr/bin/env php
addCommands([
- new \PhpyTool\Commands\PipModuleInstall(),
- new \PhpyTool\Commands\PhpyInstall(),
- new \PhpyTool\Commands\PythonInstall(),
- new \PhpyTool\Commands\PipMirrorConfig(),
- new \PhpyTool\Commands\ScanImport(),
-]);
try {
- $application->run();
+ (new Application())->run();
} catch (Throwable $e) {
exit($e->getMessage() . PHP_EOL);
}
diff --git a/composer.json b/composer.json
index d5a6964..fdfdf56 100644
--- a/composer.json
+++ b/composer.json
@@ -15,7 +15,8 @@
},
"require-dev": {
"phpunit/phpunit": "^10.4",
- "friendsofphp/php-cs-fixer": "^3.40"
+ "friendsofphp/php-cs-fixer": "^3.40",
+ "symfony/var-dumper": "^6.0 | ^7.0"
},
"autoload": {
"psr-4": {
diff --git a/docs/cn/README.md b/docs/cn/README.md
index ffde243..23ccca9 100644
--- a/docs/cn/README.md
+++ b/docs/cn/README.md
@@ -18,6 +18,7 @@ PHP 扩展
* [IDE 提示](php/composer.md)
* [Socket API](php/socket.md)
* [继承 Python 类](php/inherit.md)
+* [PHPy 工具](php/phpy.md)
Python 模块
---
diff --git a/docs/cn/php/phpy.md b/docs/cn/php/phpy.md
new file mode 100644
index 0000000..a5b3732
--- /dev/null
+++ b/docs/cn/php/phpy.md
@@ -0,0 +1,145 @@
+# PHPy 管理工具
+
+PHPy提供类似`composer`的包管理工具,可以安装、更新、卸载 `Python`环境、`swoole/phpy`拓展及`Python-module`,
+对其他`composer`引入的`Python`相关信息和模块进行依赖管理。
+
+## 使用
+
+通过`composer`安装`swoole/phpy`
+```shell
+composer require swoole/phpy
+```
+
+## 文档
+
+### 1. 初始化`phpy.json`配置
+
+#### 命令:
+```shell
+./vendor/bin/phpy init-config
+```
+
+**与`composer`类似,`PHPy`也有自己的配置管理文件`phpy.json`;使用`init-config`命令可以在当前目录下创建`phpy.json`文件。**
+
+#### 文件内容:
+```php
+{
+ // 全局配置
+ "config": {
+ // 缓存目录
+ "cache-dir": "~/.cache/phpy",
+ // 扫描路径
+ "scan-dirs": [
+ ],
+ // pip源
+ "pip-index-url": ""
+ },
+ // python 配置
+ "python": {
+ // 源码路径
+ "source-url": "https://github.com/python/cpython.git",
+ // 安装路径
+ "install-dir": "/usr",
+ // 安装版本,不支持latest构建
+ "install-version": "v3.13.2",
+ // 编译参数(建议不要改动)
+ "install-configure": [
+ "--enable-shared",
+ "--with-system-expat",
+ "--with-system-ffi",
+ "--enable-ipv6",
+ "--enable-loadable-sqlite-extensions",
+ "--with-computed-gotos",
+ "--with-ensurepip=install"
+ ]
+ },
+ // phpy 配置
+ "phpy": {
+ // 源码路径
+ "source-url": "https://github.com/swoole/phpy.git",
+ // 安装路径,支持latest使用master分支进行构建
+ "install-version": "latest",
+ // 编译参数
+ "install-configure": [],
+ // ini文件路径,空字符串为不自动引入php.ini
+ "ini-path": "/usr/local/etc/php/conf.d/xx-php-ext-phpy.ini"
+ },
+ // 模块配置
+ "modules": {
+ // 例子
+ "pandas": "^2.0"
+ }
+}
+```
+
+### 2. 依赖安装
+
+#### 命令:
+```shell
+./vendor/bin/phpy install
+```
+- `install`命令会根据当前项目及`vendor`中引入的所有`composer`包的`phpy.json`配置信息进行安装,安装内容如下:
+ - 编译构建依赖,详见[BuildToolsInstaller.php](../../../tools/src/Phpy/Installer/BuildToolsInstaller.php)
+ - 编译安装`Python`环境,详见[PythonInstaller.php](../../../tools/src/Phpy/Installer/PythonInstaller.php)
+ - 编译安装`phpy`拓展,详见[PhpyInstaller.php](../../../tools/src/Phpy/Installer/PhpyInstaller.php)
+ - 安装`Python`模块,详见[ModuleInstaller.php](../../../tools/src/Phpy/Installer/ModuleInstaller.php)
+- `install`命令会在项目路径下创建`phpy.lock`文件,用于记录安装信息,下次安装时,如果`phpy.lock`文件存在,则不会重复安装。
+- `install`命令默认使用`Python-venv`环境,会在项目路径下创建`py-vendor`目录,用于存储`Python`环境及模块。
+- `install`命令会在项目路径下创建如下文件,**以下文件建议加入项目`.gitignore`文件**:
+ - `pip.command`:用于提供`executePip`方法标准化执行`pip`命令
+ - `python.command`:用于提供`executePython`方法标准化执行`python`命令
+ - `phpy.command`:用于提供`executePhpy`方法标准化执行`phpy`命令
+ - `phpy.lock`:用于记录安装信息,下次安装时,如果`phpy.lock`文件存在,则不会重复安装
+ - `requirements.txt`:用于记录安装的`Python`模块信息,使用标准化`executePip`安装
+- 更多查看`--help`
+
+### 3. 依赖更新
+
+#### 命令:
+```shell
+./vendor/bin/phpy update
+```
+
+`update`命令会根据`phpy.json`配置信息进行更新,更多查看`--help`
+
+### 4. 环境检查
+
+#### 命令:
+```shell
+./vendor/bin/phpy show
+```
+
+`show`命令会展示当前`Python`环境信息及引入的`Python`模块信息,更多查看`--help`
+
+```shell
+/var/www/test-project # ./vendor/bin/phpy show
+[>] Python-env:
+ [>] Python 3.13.2
+ [>] pip 25.0.1 from /var/www/phpy/py-vendor/lib/python3.13/site-packages/pip (python 3.13)
+[>] Python-includes:
+ [>] -I/var/www/phpy/py-vendor/include/python3.13 -I/var/www/phpy/py-vendor/include/python3.13
+[>] Python-modules:
+ [>] Package Version
+ [>] ------- -------
+ [>] pip 25.0.1
+ [>] pyorc 0.10.0
+```
+
+### 5. 扫描引入
+
+#### 命令:
+```shell
+./vendor/bin/phpy scan
+```
+
+`scan`命令会根据`phpy.json`的`config.scan-dirs`扫描所有php文件并检查依赖的`Python-module`,
+引入并安装,更多查看`--help`
+
+### 6. 缓存清除
+
+#### 命令:
+```shell
+./vendor/bin/phpy clear-cache
+```
+
+`clear-cache`命令会根据`phpy.json`的`config.cache-dir`清除相关缓存,更多查看`--help`
diff --git a/phpy.json b/phpy.json
new file mode 100644
index 0000000..13341f1
--- /dev/null
+++ b/phpy.json
@@ -0,0 +1,29 @@
+{
+ "config": {
+ "cache-dir": "~/.cache/phpy",
+ "scan-dirs": [
+ ],
+ "pip-index-url": ""
+ },
+ "python": {
+ "source-url": "https://github.com/python/cpython.git",
+ "install-dir": "/usr",
+ "install-version": "v3.13.2",
+ "install-configure": [
+ "--enable-shared",
+ "--with-system-expat",
+ "--with-system-ffi",
+ "--enable-ipv6",
+ "--enable-loadable-sqlite-extensions",
+ "--with-computed-gotos",
+ "--with-ensurepip=install"
+ ]
+ },
+ "phpy": {
+ "source-url": "https://github.com/swoole/phpy.git",
+ "install-version": "latest",
+ "install-configure": [],
+ "ini-path": "/usr/local/etc/php/conf.d/xx-php-ext-phpy.ini"
+ },
+ "modules": {}
+}
\ No newline at end of file
diff --git a/tools/src/Commands/AbstractCommand.php b/tools/src/Commands/AbstractCommand.php
deleted file mode 100644
index 502556b..0000000
--- a/tools/src/Commands/AbstractCommand.php
+++ /dev/null
@@ -1,223 +0,0 @@
-addOption('debug', null, InputOption::VALUE_NONE, 'Enable debug mode.');
- }
-
- /**
- * @return OutputInterface|null
- */
- public function getOutput(): ?OutputInterface
- {
- return $this->output;
- }
-
- /**
- * @return InputInterface|null
- */
- public function getInput(): ?InputInterface
- {
- return $this->input;
- }
-
- /**
- * @return string|null
- */
- public function getRuntimeId(): ?string
- {
- return $this->runtimeId;
- }
-
- /**
- * @param string $info
- * @return void
- */
- private function debugMode(string $info): void
- {
- if (self::$debug) {
- $this->subOutput($info = "DEBUG INFO -> $info");
- $file = str_replace('\\', '_', get_called_class()) . '-' . $this->getRuntimeId();
- if (!is_dir($dir = getcwd() . "/.log")) {
- mkdir($dir, 0777, true);
- }
- file_put_contents("$dir/$file.log", "$info\n", FILE_APPEND | LOCK_EX);
- }
- }
-
- /**
- * 执行命令
- *
- * @param string $command
- * @param mixed|null $output
- * @param int|null $resultCode
- * @param bool $ignore 忽略中断
- * @return string|int|bool|null
- */
- protected function exec(string $command, mixed &$output = null, mixed &$resultCode = 0, bool $ignore = false): string|int|bool|null
- {
- $this->debugMode("exec( $command )");
- $lastLine = exec($command, $output, $resultCode);
- if ($resultCode !== 0 and !$ignore) {
- $this->error($lastLine);
- return $resultCode;
- }
- $this->debugMode("->> rc: $resultCode | last info: $lastLine");
- return $lastLine;
- }
-
- /**
- * @param string $command
- * @param int|null $resultCode
- * @param bool $ignore
- * @return string|int|bool|null
- */
- protected function system(string $command, ?int &$resultCode = 0, bool $ignore = false): string|int|bool|null
- {
- $this->debugMode("system( $command )");
- $info = system($command, $resultCode);
- if ($resultCode !== 0) {
- if (!$ignore) {
- $this->error($info);
- return $resultCode;
- }
- }
- $this->debugMode("->> rc: $resultCode | last info: $info");
- return $info;
- }
-
- /**
- * @param string $command
- * @param string|null $lastLine
- * @return int resultCode
- */
- protected function execWithProgress(string $command, ?string &$lastLine = null): int
- {
- $this->debugMode("execWithProgress( $command )");
- $process = popen($command, 'r');
- while (!feof($process)) {
- $line = fgets($process);
- if ($line === false) {
- break;
- } else {
- $lastLine = trim($line);
- if ($lastLine) {
- $this->subOutput($lastLine);
- }
- }
- usleep(1000);
- }
- $this->debugMode("> rc: null | last info: $lastLine");
- return pclose($process);
- }
-
- /**
- * sub输出
- *
- * @param string $message
- * @param string $tag
- * @return void
- */
- protected function subOutput(string $message, string $tag = '[>]'): void
- {
- $this->getOutput()?->getFormatter()->setStyle('sub-output', new OutputFormatterStyle('gray', null, ['underscore']));
- $this->getOutput()?->writeln("$tag $message");
- }
-
- /**
- * 普通输出
- *
- * @param string $message
- * @param string $tag
- * @return void
- */
- protected function output(string $message, string $tag = '[>]'): void
- {
- $this->getOutput()?->writeln("$tag $message");
- }
-
- /**
- * 输出info
- *
- * @param string $message
- * @return void
- */
- protected function comment(string $message): void
- {
- $this->getOutput()?->writeln("[i] $message");
- }
-
- /**
- * 输出error
- *
- * @param string $message
- * @return int
- */
- protected function error(string $message): int
- {
- $this->getOutput()?->writeln("[×] $message");
- return self::FAILURE;
- }
-
- /**
- * 输出success
- *
- * @param string $message
- * @return int
- */
- protected function success(string $message): int
- {
- $this->getOutput()?->writeln("[√] $message");
- return self::SUCCESS;
- }
-
- /** @inheritdoc */
- final protected function execute(InputInterface $input, OutputInterface $output): int
- {
- $this->runtimeId = uniqid();
- $this->input = $input;
- $this->output = $output;
- if (self::$debug = $input->getOption('debug')) {
- $this->comment("Run in debug mode. ID: {$this->getRuntimeId()}");
- }
- return $this->handler();
- }
-
- /**
- * @return int
- */
- abstract protected function handler(): int;
-
-}
diff --git a/tools/src/Commands/PhpyInstall.php b/tools/src/Commands/PhpyInstall.php
deleted file mode 100644
index e6ee385..0000000
--- a/tools/src/Commands/PhpyInstall.php
+++ /dev/null
@@ -1,144 +0,0 @@
-setName('install:phpy')
- ->setDescription('Installs PHP-ext PHPy.')
- ->addArgument('version', InputArgument::OPTIONAL, 'The version of PHPy to install', 'latest');
- }
-
- /** @inheritdoc */
- protected function handler(): int
- {
- $helper = new QuestionHelper();
- $version = $this->getInput()?->getArgument('version');
- // 询问安装目录
- $question = new Question("[?] Please specify the installation directory (default: .runtime): \n", getcwd() . '/.runtime');
- $installDir = $helper->ask($this->getInput(), $this->getOutput(), $question) . '/swoole_phpy_' . str_replace('.', '', $version);
-
- if (!file_exists($installDir)) {
- // 下载源码
- $this->output('Downloading the latest source code ...');
- if (
- $this->execWithProgress($version === 'latest' ?
- "git clone --depth 1 https://github.com/swoole/phpy.git $installDir" :
- "git clone --depth 1 --branch $version https://github.com/swoole/phpy.git $installDir") !== 0
- ) {
- return $this->error('Error downloading source code.');
- }
- } else {
- $this->comment('PHPy source code already downloaded.');
- }
-
- // 安装编译依赖组件
- $this->output('Installing dependencies...');
- if ($installCommands = $this->getSystemInstallCommands()) {
- if ($this->execWithProgress($installCommands) !== 0) {
- return $this->error('Error installing dependencies.');
- }
- } else {
- return $this->error('Please install PHPy manually.');
- }
-
- $question = new Question("[?] Please specify the Python-config directory (default: /usr/bin/python-config): \n", '/usr/bin/python-config');
- $pythonConfigDir = $helper->ask($this->getInput(), $this->getOutput(), $question);
- // 编译并安装拓展
- $this->output('Building and installing PHPy extension...');
- if (
- $this->execWithProgress(
- "cd $installDir && phpize && ./configure --with-python-config=$pythonConfigDir && make clean && make && make install"
- ) !== 0
- ) {
- return $this->error('Error building and installing PHPy extension.');
- }
-
- // 询问是否移除源码
- $question = new ConfirmationQuestion("[?] Do you want to add ext-PHPy in php.ini? [Y/n]: \n", true);
- if ($helper->ask($this->getInput(), $this->getOutput(), $question)) {
- $question = new Question("[?] Please specify the php.ini path (default: /usr/local/etc/php/conf.d/xx-php-ext-phpy.ini): \n", '/usr/local/etc/php/conf.d/xx-php-ext-phpy.ini');
- $phpIniPath = $helper->ask($this->getInput(), $this->getOutput(), $question);
- $this->system('echo "extension=phpy.so" > ' . $phpIniPath, $rc, true);
- if ($rc !== 0) {
- $this->error('Error removing source code. Your can remove it manually.');
- }
- }
-
- // 询问是否移除源码
- $question = new ConfirmationQuestion("[?] Do you want to remove the source code? (path: $installDir) [Y/n]: \n", true);
- if ($helper->ask($this->getInput(), $this->getOutput(), $question)) {
- $this->system('rm -rf ' . $installDir, $rc, true);
- if ($rc !== 0) {
- $this->error('Error removing source code. Your can remove it manually.');
- }
- }
-
- // 询问是否卸载编译依赖组件
- $question = new ConfirmationQuestion("[?] Do you want to remove the build dependencies? [Y/n]: \n", true);
- if ($helper->ask($this->getInput(), $this->getOutput(), $question)) {
- $removeCommands = $this->getSystemUninstallCommands();
- if ($removeCommands) {
- if ($this->execWithProgress($removeCommands) !== 0) {
- $this->error('Error removing build dependencies. Your can remove it manually.');
- }
- }
- }
-
- // 询问是composer安装swoole/phpy
- $question = new ConfirmationQuestion("[?] Do you want to require swoole/phpy? [y/N]: \n", false);
- if ($helper->ask($this->getInput(), $this->getOutput(), $question)) {
- if ($this->execWithProgress('composer require swoole/phpy --ignore-platform-req=ext-phpy') !== 0) {
- $this->error('Error requiring PHPy via Composer. Your can require it manually.');
- }
- }
-
- return $this->success('PHPy installation completed successfully. ');
- }
-
- private function getSystemInstallCommands(): ?string
- {
- return match ($this->getSystemType()) {
- 'alpine' => 'apk add --no-cache gcc g++ make autoconf',
- 'redhat' => 'sudo yum install -y gcc gcc-c++ make autoconf',
- 'darwin' => 'brew install autoconf',
- 'windows' => null,
- default => 'sudo apt-get install -y build-essential autoconf',
- };
- }
-
- private function getSystemUninstallCommands(): ?string
- {
- return match ($this->getSystemType()) {
- 'alpine' => 'apk del gcc g++ make autoconf',
- 'redhat' => 'sudo yum remove -y gcc gcc-c++ make autoconf',
- 'darwin' => 'brew uninstall autoconf',
- 'windows' => null,
- default => 'sudo apt-get remove -y build-essential autoconf',
- };
- }
-
- private function getSystemType(): string
- {
- return match (true) {
- file_exists('/etc/alpine-release') => 'alpine',
- file_exists('/etc/centos-release') ||
- file_exists('/etc/redhat-release') => 'redhat',
- stripos(php_uname('s'), 'Darwin') !== false => 'darwin',
- stripos(php_uname('s'), 'Windows') !== false => 'windows',
- default => 'linux',
- };
- }
-}
diff --git a/tools/src/Commands/PipMirrorConfig.php b/tools/src/Commands/PipMirrorConfig.php
deleted file mode 100644
index 55f83df..0000000
--- a/tools/src/Commands/PipMirrorConfig.php
+++ /dev/null
@@ -1,52 +0,0 @@
-setName('config:pip-mirror')
- ->setDescription('Set pip mirror');
- }
-
- /** @inheritdoc */
- protected function handler(): int
- {
- $this->output('Current pip mirror url: ' . $this->exec('pip config get global.index-url'));
- $this->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->input, $this->output, $question);
-
- match ($selectedOption) {
- 'Python 官方' => $this->exec('pip config set global.index-url https://pypi.org/simple/'),
- '清华' => $this->exec('pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple'),
- '阿里云' => $this->exec('pip config set global.index-url http://mirrors.aliyun.com/pypi/simple/'),
- '华为云' => $this->exec('pip config set global.index-url https://mirrors.huaweicloud.com/repository/pypi/simple/'),
- '腾讯云' => $this->exec('pip config set global.index-url https://mirrors.cloud.tencent.com/pypi/simple/'),
- '中科大' => $this->exec('pip config set global.index-url https://pypi.mirrors.ustc.edu.cn/simple/'),
- '网易' => $this->exec('pip config set global.index-url http://mirrors.163.com/pypi/simple/'),
- };
-
- return $this->success('已将 pip 源设置为: ' . $selectedOption);
- }
-}
diff --git a/tools/src/Commands/PipModuleInstall.php b/tools/src/Commands/PipModuleInstall.php
deleted file mode 100644
index 8fa12b3..0000000
--- a/tools/src/Commands/PipModuleInstall.php
+++ /dev/null
@@ -1,66 +0,0 @@
-setName('install:pip-module')
- ->setDescription('Installs Python PyORC module.')
- ->addArgument('module', InputArgument::REQUIRED, 'The module name to install')
- ->addArgument('version', InputArgument::OPTIONAL, 'The version of Python module to install', 'latest');
- }
-
- /** @inheritdoc */
- protected function handler(): int
- {
- $helper = new QuestionHelper();
- $module = $this->getInput()?->getArgument('module');
- $version = $this->getInput()?->getArgument('version');
-
- $question = new Question("[?] Please specify the path of Python (default: /usr/bin/python3): \n", '/usr/bin/python3');
- $pythonPath = $helper->ask($this->getInput(), $this->getOutput(), $question);
- if (
- !file_exists($pythonPath) or
- version_compare(
- $this->pythonVersion = substr($this->exec("$pythonPath --version", ignore: true), 7, 4),
- '3.10',
- '<'
- )
- ) {
- return $this->error('Please install Python 3.10+ manually.');
- }
-
- $question = new Question("[?] Please specify the path of pip (default: /usr/bin/pip3): \n", '/usr/bin/pip3');
- $pipPath = $helper->ask($this->getInput(), $this->getOutput(), $question);
- if (!file_exists($pipPath)) {
- return $this->error('Please install Python-pip manually.');
- }
-
- $this->output("Installing Python module $module-$version ...");
- $moduleInstalled = $this->exec("$pipPath show $module", ignore: true);
- if (!$moduleInstalled or ($version !== 'latest' and !str_contains($moduleInstalled, "Version: $version"))) {
- if ($this->execWithProgress(
- "$pipPath install $module" . ($version === 'latest' ? '' : "==$version") . ' --break-system-packages'
- )) {
- return $this->error("Error installing Python module $module-$version.");
- }
- } else {
- $this->comment("Python module $module-$version is already installed.");
- }
-
- return $this->success("Python module $module-$version installation complete.");
- }
-}
diff --git a/tools/src/Commands/PythonInstall.php b/tools/src/Commands/PythonInstall.php
deleted file mode 100644
index fde61cc..0000000
--- a/tools/src/Commands/PythonInstall.php
+++ /dev/null
@@ -1,95 +0,0 @@
-setName('install:python')
- ->setDescription('Installs Python 3.10+.')
- ->addOption('venv', null, InputOption::VALUE_NONE, 'Install virtual environment');
- }
-
- /** @inheritdoc */
- protected function handler(): int
- {
- $this->output('Checking and installing Python-3.10+ Python-pip Python-dev ...');
- if (!$installCommands = $this->getPythonInstallCommands()) {
- return $this->error('Unsupported operating system.');
- }
- $this->execWithProgress($installCommands, $lastLine);
- if (!str_starts_with($lastLine, 'OK:')) {
- return $this->error('Failed to install Python-3.10+ Python-pip Python-dev.');
- }
- // 再次检查Python版本
- if (version_compare(
- $this->pythonVersion = substr($this->exec('python3 --version', ignore: true), 7, 4),
- '3.10',
- '<'
- )
- ) {
- return $this->error('Python version must be 3.10 or higher.');
- }
-
- // 安装虚拟环境
- if ($this->getInput()?->getOption('venv')) {
- $helper = new QuestionHelper();
- $question = new Question("[?] Please specify the path of Python-venv (default: getcwd()/.venv): \n", null);
- $venvPath = $helper->ask($this->getInput(), $this->getOutput(), $question);
- // 检查虚拟环境是否已经存在
- $venvPath = $venvPath ?: getcwd(). '/.venv';
- $this->output("Setting up virtual environment in $venvPath ...");
- if (!file_exists($venvPath)) {
- // 安装虚拟
- $this->execWithProgress("python3 -m venv $venvPath");
- $this->execWithProgress("source $venvPath/bin/activate");
- // 软链python-config
- $pythonConfigPath = "$venvPath/bin/python-config";
- $this->execWithProgress("ln -s /usr/bin/python-config $pythonConfigPath");
- // 软链python-include
- $this->execWithProgress("rm -rf $venvPath/include/python$this->pythonVersion");
- $this->execWithProgress("ln -s /usr/include/python$this->pythonVersion $venvPath/include");
- $this->output("Virtual environment created. Python-config path: $pythonConfigPath");
- } else {
- $this->comment('Virtual environment already exists.');
- }
- }
-
- return $this->success('Python installation complete.');
- }
-
- private function getPythonInstallCommands(): ?string
- {
- return match ($this->getSystemType()) {
- 'alpine' => 'apk add --no-cache python3 py3-pip python3-dev',
- 'redhat' => 'sudo yum install -y python3 python3-venv python3-pip python3-dev',
- 'darwin' => 'brew install python3',
- 'windows' => null,
- default => 'sudo apt-get install -y python3 python3-venv python3-pip python3-dev'
- };
- }
-
- private function getSystemType(): string
- {
- return match (true) {
- file_exists('/etc/alpine-release') => 'alpine',
- file_exists('/etc/centos-release') ||
- file_exists('/etc/redhat-release') => 'redhat',
- stripos(php_uname('s'), 'Darwin') !== false => 'darwin',
- stripos(php_uname('s'), 'Windows') !== false => 'windows',
- default => 'linux',
- };
- }
-}
diff --git a/tools/src/Commands/ScanImport.php b/tools/src/Commands/ScanImport.php
deleted file mode 100644
index d2ace3a..0000000
--- a/tools/src/Commands/ScanImport.php
+++ /dev/null
@@ -1,100 +0,0 @@
-setName('scan:import')
- ->setDescription('Scan the code to see what python modules are imported.')
- ->addArgument('path', InputArgument::REQUIRED, 'The path to scan.');
- }
-
- private function findPhpFiles($directory): array
- {
- $phpFiles = [];
- $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($directory));
- foreach ($iterator as $file) {
- if ($file->isFile() && pathinfo($file->getFilename(), PATHINFO_EXTENSION) === 'php') {
- $phpFiles[] = $file->getPathname();
- }
- }
- return $phpFiles;
- }
-
- /** @inheritdoc */
- protected function handler(): int
- {
- $this->output('Scan the PHP code in the path directory to see what Python modules are imported ...');
- $srcPath = realpath($this->input->getArgument('path'));
- if (!is_dir($srcPath)) {
- return $this->error('The path is not a directory.');
- }
-
- $files = $this->findPhpFiles($srcPath);
- $packages = [];
- foreach ($files as $file) {
- $this->output->writeln('Scanning ' . str_replace($srcPath . '/', '', $file) . '');
- $packages = array_merge($packages, PackageCollector::parseFile($file));
- }
-
- $packages = array_unique($packages);
- if (!$packages) {
- $this->output('No Python modules found.');
- return 0;
- }
-
- $packages = array_filter($packages, function ($module) {
- return !PythonMetadata::isStdLibrary($module);
- });
- $this->output('Found Python modules: ' . implode(', ', array_map(function ($module) {
- return "$module";
- }, $packages)));
-
- $helper = new QuestionHelper();
- $question = new ConfirmationQuestion("[?] Should the dependent packages be written into requirements.txt (yes/no)?: \n", false);
- if ($helper->ask($this->getInput(), $this->getOutput(), $question)) {
- $pipPackages = array_unique(array_filter(array_map(function ($module) {
- return PythonMetadata::getPipPackage($module);
- }, $packages), fn($package) => $package !== null));
-
- $requirementsFile = 'requirements.txt';
-
- $original = file_get_contents($requirementsFile);
- $lines = explode("\n", $original);
- foreach ($lines as $line) {
- [$_package] = explode('==', $line);
- if (in_array($_package, $pipPackages)) {
- unset($pipPackages[array_search($_package, $pipPackages)]);
- }
- }
-
- $fp = fopen($requirementsFile, 'a');
- fwrite($fp, "\n" . implode("\n", $pipPackages));
- fclose($fp);
-
- $this->success("The dependent packages have been written to $requirementsFile.");
-
- if ($helper->ask($this->getInput(), $this->getOutput(), new ConfirmationQuestion("[?] Do you want to install the dependent packages (yes/no)?: \n", false))) {
- $this->output('Installing dependent packages ...');
- system('pip install -r ' . $requirementsFile);
- return $this->success('The dependent packages have been installed.');
- }
- }
- return 0;
- }
-}
diff --git a/tools/src/PackageCollector.php b/tools/src/PackageCollector.php
deleted file mode 100644
index 1c65a13..0000000
--- a/tools/src/PackageCollector.php
+++ /dev/null
@@ -1,56 +0,0 @@
-args[0]) && $node->args[0]->value instanceof Node\Scalar\String_) {
- $this->packages[] = $node->args[0]->value->value;
- }
- };
- if ($node instanceof Node\Expr\StaticCall) {
- if ($node->class instanceof Node\Name && strval($node->class) === 'PyCore' && strval($node->name) === 'import') {
- $foundPackageFn($node);
- }
- } elseif ($node instanceof Node\Expr\FuncCall) {
- if ($node->name instanceof Node\Name && strtolower($node->name) === 'pyimport') {
- $foundPackageFn($node);
- }
- }
- }
-
- public function getPackages(): array
- {
- return array_unique($this->packages); // 去重
- }
-
- static function parseFile($filePath): array
- {
- $code = file_get_contents($filePath);
- $parser = (new ParserFactory())->createForNewestSupportedVersion();
- $traverser = new NodeTraverser;
-
- $collector = new PackageCollector();
- $traverser->addVisitor($collector);
-
- try {
- $statements = $parser->parse($code);
- $traverser->traverse($statements);
- } catch (Error $e) {
- echo 'Parse Error: ' . $e->getMessage();
- }
-
- return $collector->getPackages();
- }
-}
diff --git a/tools/src/Phpy/Application.php b/tools/src/Phpy/Application.php
new file mode 100644
index 0000000..5c8f876
--- /dev/null
+++ b/tools/src/Phpy/Application.php
@@ -0,0 +1,231 @@
+ _____
+ | _ || | || _ | _ _
+ | __|| || __|| | |
+ |__| |__|__||__| |_ |
+ |___| by Swoole
+doc;
+
+ public function __construct()
+ {
+ System::setcwd(getcwd());
+ parent::__construct('PHPy', static::VERSION);
+ $this->addCommands([
+ new ScanCommand(),
+ new InitConfigCommand(),
+ new InstallCommand(),
+ new UpdateCommand(),
+ new ShowCommand(),
+ new PipModuleInstall(),
+ new PhpyInstall(),
+ new PythonInstall(),
+ new PipMirrorConfig(),
+ new ClearCacheCommand(),
+ ]);
+ }
+
+ /**
+ * @return string
+ */
+ public function getLogo(): string
+ {
+ return $this->logo;
+ }
+
+ /** @inheritdoc */
+ public function getLongVersion(): string
+ {
+ return \sprintf(<<%s version %s
+EOT, $this->getLogo(), $this->getName(), $this->getVersion());
+ }
+
+ /**
+ * 获取配置文件
+ *
+ * @param string $dir
+ * @return string|null
+ */
+ public static function getConfigFile(string $dir): ?string
+ {
+ $filePath = "$dir/phpy.json";
+ return file_exists($filePath) ? $filePath : null;
+ }
+
+ /**
+ * 设置配置文件
+ *
+ * @param string $dir
+ * @param array $data
+ * @return bool|int
+ */
+ public static function setConfigFile(string $dir, array $data): bool|int
+ {
+ $filePath = "$dir/phpy.json";
+ if (file_exists($filePath)) {
+ return false;
+ }
+ return file_put_contents($filePath, json_encode($data, JSON_UNESCAPED_UNICODE|JSON_PRETTY_PRINT));
+ }
+
+ /**
+ * 获取锁定文件
+ *
+ * @param string $dir
+ * @return string|null
+ */
+ public static function getLockFile(string $dir): ?string
+ {
+ $filePath = "$dir/phpy.lock";
+ return file_exists($filePath) ? $filePath : null;
+ }
+
+ /**
+ * 设置锁定文件
+ *
+ * @param string $dir
+ * @param array $data
+ * @return bool|int
+ */
+ public static function setLockFile(string $dir, array $data): bool|int
+ {
+ $filePath = "$dir/phpy.lock";
+ if (file_exists($filePath) or !isset($data['phpy-hash']) or !isset($data['composer-hash'])) {
+ return false;
+ }
+ return file_put_contents($filePath, json_encode($data, JSON_UNESCAPED_UNICODE|JSON_PRETTY_PRINT));
+ }
+
+ /**
+ * 获取锁定文件
+ *
+ * @param string $dir
+ * @return string|null
+ */
+ public static function getRequirementsFile(string $dir): ?string
+ {
+ $filePath = "$dir/requirements.txt";
+ return file_exists($filePath) ? $filePath : null;
+ }
+
+ /**
+ * 递归向上查找配置文件
+ *
+ * @param Closure|null $closure
+ * @return string|null
+ */
+ public static function findConfigFile(?Closure $closure = null): string|null
+ {
+ $currentDir = $startDir = System::getcwd();
+ while ($currentDir !== dirname($currentDir)) {
+ if ($filePath = static::getConfigFile($currentDir)) {
+ if ($closure) {
+ call_user_func($closure, $filePath, $currentDir, $startDir);
+ }
+ return $filePath;
+ }
+ $currentDir = dirname($currentDir);
+ }
+ return null;
+ }
+
+ /**
+ * 递归向上查找锁定文件
+ *
+ * @param Closure|null $closure
+ * @return string|null
+ */
+ public static function findLockFile(?Closure $closure = null): ?string
+ {
+ $currentDir = $startDir = System::getcwd();
+ while ($currentDir !== dirname($currentDir)) {
+ if ($filePath = static::getLockFile($currentDir)) {
+ if ($closure) {
+ call_user_func($closure, $filePath, $currentDir, $startDir);
+ }
+ return $filePath;
+ }
+ $currentDir = dirname($currentDir);
+ }
+ return null;
+ }
+
+ /**
+ * 递归向上查找requirements.txt文件
+ *
+ * @param Closure|null $closure
+ * @return string|null
+ */
+ public static function findRequirementsFile(?Closure $closure = null): ?string
+ {
+ $currentDir = $startDir = System::getcwd();
+ while ($currentDir !== dirname($currentDir)) {
+ if ($filePath = static::getRequirementsFile($currentDir)) {
+ if ($closure) {
+ call_user_func($closure, $filePath, $currentDir, $startDir);
+ }
+ return $filePath;
+ }
+ $currentDir = dirname($currentDir);
+ }
+ return null;
+ }
+
+ /**
+ * 获取所有配置文件树
+ *
+ * @param Closure|null $closure = function ($organization, $package, $configFilePath) {}
+ * @return array>|null
+ */
+ public static function getVendorConfigFiles(?Closure $closure = null): ?array
+ {
+ if (!is_dir($vendorDir = System::getcwd() . DIRECTORY_SEPARATOR . 'vendor')) {
+ return null;
+ }
+
+ $configFilesTree = [];
+ $organizationDirs = array_filter(glob($vendorDir . '/*'), 'is_dir');
+
+ foreach ($organizationDirs as $organizationDir) {
+ $organization = basename($organizationDir);
+ $packageDirs = array_filter(glob($organizationDir . '/*'), 'is_dir');
+
+ foreach ($packageDirs as $packageDir) {
+ $package = basename($packageDir);
+ $configFilePath = "$packageDir/phpy.json";
+
+ if (file_exists($configFilePath)) {
+ $configFilesTree[$organization][$package] = $closure ? call_user_func($closure, $organization, $package, $configFilePath) : $configFilePath;
+ }
+ }
+ }
+
+ return $configFilesTree;
+ }
+}
diff --git a/tools/src/Phpy/Commands/AbstractCommand.php b/tools/src/Phpy/Commands/AbstractCommand.php
new file mode 100644
index 0000000..6549829
--- /dev/null
+++ b/tools/src/Phpy/Commands/AbstractCommand.php
@@ -0,0 +1,71 @@
+addOption('debug', null, InputOption::VALUE_NONE, 'Enable debug mode.');
+ }
+
+ /**
+ * @return string|null
+ */
+ public function getRuntimeId(): ?string
+ {
+ return $this->process?->getRuntimeId();
+ }
+
+ /**
+ * @return Process|null
+ */
+ public function getProcess(): ?Process
+ {
+ return $this->process;
+ }
+
+ /**
+ * @return ConsoleIO|null
+ */
+ public function getConsoleIO(): ?ConsoleIO
+ {
+ return $this->consoleIO;
+ }
+
+ /** @inheritdoc */
+ final protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $this->consoleIO = new ConsoleIO($input, $output, $this->getHelperSet());
+ $this->process = new Process($this->consoleIO, uniqid(), (bool)$input->getOption('debug'));
+ return $this->handler();
+ }
+
+ /**
+ * @return int
+ */
+ abstract protected function handler(): int;
+
+}
diff --git a/tools/src/Phpy/Commands/ClearCacheCommand.php b/tools/src/Phpy/Commands/ClearCacheCommand.php
new file mode 100644
index 0000000..5dfa046
--- /dev/null
+++ b/tools/src/Phpy/Commands/ClearCacheCommand.php
@@ -0,0 +1,72 @@
+setName('clear-cache')
+ ->setDescription("Clears phpy's Python-source and ext-phpy cache")
+ ->setHelp(
+ <<clear-cache command deletes all cached Python-source and ext-phpy from
+phpy's cache directory.
+EOT
+ );
+ }
+
+ /** @inheritdoc */
+ protected function handler(): int
+ {
+ try {
+ // find json file
+ $jsonFile = Application::findConfigFile(function ($file, $cDir, $sDir) {
+ if ($cDir !== $sDir) {
+ if (!$this->consoleIO?->ask(
+ "No phpy.json in current directory, do you want to use the one at $cDir [Y,n]?",
+ true,
+ ConfirmationQuestion::class
+ )) {
+ throw new CommandStopException("PHPy could not find a phpy.json file in $sDir");
+ }
+ }
+ System::setcwd($cDir);
+ });
+ if (!$jsonFile) {
+ throw new CommandFailedException('PHPy could not find a phpy.json file in the project');
+ }
+ $config = new Config($jsonFile);
+ // Python
+ (new PythonInstaller($config, $this->consoleIO))->clearCache();
+ // ext-phpy
+ (new PhpyInstaller($config, $this->consoleIO))->clearCache();
+
+ } catch (CommandStopException) {
+ return $this->consoleIO?->success('Clear-cache stop.');
+ } catch (CommandSuccessedException $exception) {
+ return $this->consoleIO?->success($exception->getMessage());
+ } catch (CommandFailedException $exception) {
+ return $this->consoleIO?->error($exception->getMessage());
+ }
+ return $this->consoleIO?->success('Clear-cache completed.');
+ }
+}
diff --git a/tools/src/Phpy/Commands/InitConfigCommand.php b/tools/src/Phpy/Commands/InitConfigCommand.php
new file mode 100644
index 0000000..1a813d9
--- /dev/null
+++ b/tools/src/Phpy/Commands/InitConfigCommand.php
@@ -0,0 +1,47 @@
+setName('init-config')
+ ->setDescription('Initialize the configuration file.')
+ ->setHelp(
+ <<init-config command initializes the configuration file.
+If the configuration file already exists, it will not be overwritten.
+If the configuration file does not exist, it will be created.
+
+PHPy introduces modules through Python-pip, read more at
+https://pypi.org/help/
+EOT
+ );
+ }
+
+ /** @inheritdoc */
+ protected function handler(): int
+ {
+ if (!$jsonFile = Application::findConfigFile(function ($file, $cDir, $sDir) {
+ System::setcwd($cDir);
+ })) {
+ $config = new Config();
+ System::putFileContent($jsonFile = System::getcwd() . '/phpy.json', (string)$config, cache: false);
+ }
+ return $this->consoleIO?->success($jsonFile
+ ? "The configuration file already exists [$jsonFile]."
+ : "The configuration file has been created [$jsonFile]."
+ );
+ }
+}
diff --git a/tools/src/Phpy/Commands/InstallCommand.php b/tools/src/Phpy/Commands/InstallCommand.php
new file mode 100644
index 0000000..3bfcfb5
--- /dev/null
+++ b/tools/src/Phpy/Commands/InstallCommand.php
@@ -0,0 +1,111 @@
+setName('install')
+ ->setDescription('Installs the project dependencies from the phpy.lock file if present, or falls back on the phpy.json')
+ ->addOption('skip-build-tools', null, null, 'Skip the build tools installation.')
+ ->addOption('skip-env', null, null, 'Skip the environment installation.')
+ ->addOption('skip-ext', null, null, 'Skip the phpy extension installation.')
+ ->addOption('skip-module', null, null, 'Skip the module installation.')
+ ->setHelp(
+ <<install command checks and installs the Python
+environment based on phpy.json, with methods like conda or venv
+configurable through the Python module in php.json.
+Use --skip-env to skip this step.
+
+It also checks and installs phpy extensions based on phpy.json.
+The phpy version, automatic injection into php.ini, and other
+configurations can be set through the phpy module in php.json.
+Use --skip-ext to skip this step.
+
+Finally, the command reads phpy.lock from the current
+directory to install Python module dependencies, using the
+environment specified in phpy.json to execute pip install.
+Use --skip-module to skip this step.
+
+Use --skip-build-tools to skip the build tools installation.
+
+PHPy introduces modules through Python-pip, read more at
+https://pypi.org/help/
+EOT
+ );
+ }
+
+ /** @inheritdoc */
+ protected function handler(): int
+ {
+ try {
+ // find json file
+ $jsonFile = Application::findConfigFile(function ($file, $cDir, $sDir) {
+ if ($cDir !== $sDir) {
+ if (!$this->consoleIO?->ask(
+ "No phpy.json in current directory, do you want to use the one at $cDir [Y,n]?",
+ true,
+ ConfirmationQuestion::class
+ )) {
+ throw new CommandStopException("PHPy could not find a phpy.json file in $sDir");
+ }
+ }
+ System::setcwd($cDir);
+ });
+ if (!$jsonFile) {
+ throw new CommandFailedException('PHPy could not find a phpy.json file in the project');
+ }
+ // 尝试读取lock
+ if (!$lockFile = Application::getLockFile(System::getcwd())) {
+ $this->consoleIO?->comment("No phpy.lock file present. Updating dependencies to latest instead of installing from lock file.");
+ }
+ $config = new Config($lockFile ?: $jsonFile);
+ // build tools
+ (new BuildToolsInstaller($config, $this->consoleIO))->install();
+ // install python env
+ if (!$this->consoleIO?->getInput()->getOption('skip-env')) {
+ (new PythonInstaller($config, $this->consoleIO))->install();
+ }
+ // install phpy extensions
+ if (!$this->consoleIO?->getInput()->getOption('skip-ext')) {
+ (new PhpyInstaller($config, $this->consoleIO))->install();
+ }
+ // install modules
+ if (!$this->consoleIO?->getInput()->getOption('skip-module')) {
+ // module
+ (new ModuleInstaller($config, $this->consoleIO))->install();
+ }
+ if (!$lockFile) {
+ Application::setLockFile(System::getcwd(), $config->all());
+ }
+
+ } catch (CommandStopException) {
+ return $this->consoleIO?->success('Installation stop.');
+ } catch (CommandSuccessedException $exception) {
+ return $this->consoleIO?->success($exception->getMessage());
+ } catch (CommandFailedException $exception) {
+ return $this->consoleIO?->error($exception->getMessage());
+ }
+ return $this->consoleIO?->success('Installation completed.');
+ }
+}
diff --git a/tools/src/Phpy/Commands/PhpyInstall.php b/tools/src/Phpy/Commands/PhpyInstall.php
new file mode 100644
index 0000000..5b038fa
--- /dev/null
+++ b/tools/src/Phpy/Commands/PhpyInstall.php
@@ -0,0 +1,79 @@
+setName('install:phpy')
+ ->setDescription('Installs PHP-ext PHPy.')
+ ->addArgument('version', InputArgument::OPTIONAL, 'The version of PHPy to install', 'latest');
+ }
+
+ /** @inheritdoc */
+ protected function handler(): int
+ {
+ $config = new Config();
+ $pythonSourceUrl = $config->get('phpy.source-url', 'https://github.com/swoole/phpy.git');
+ $config->set('phpy.source-url', $this->consoleIO?->ask(
+ "Please enter the ext-phpy source code URL to install (default: $pythonSourceUrl).",
+ $pythonSourceUrl
+ ));
+
+ $pythonVersion = $config->get('phpy.install-version', 'latest');
+ $config->set('phpy.install-version', $this->consoleIO?->ask(
+ "Please enter the ext-phpy version to install (default: $pythonVersion).",
+ $pythonVersion
+ ));
+
+ if ($this->consoleIO?->ask(
+ 'Do you want to add ext-phpy in php.ini? [Y,n]:',
+ false,
+ questionClass: ConfirmationQuestion::class
+ )) {
+ $phpyIniPath = $config->get('phpy.ini-path', '/usr/local/etc/php/conf.d/xx-php-ext-phpy.ini');
+ $config->set('phpy.ini-path', $this->consoleIO?->ask(
+ "Please enter the ext-phpy php.ini path (default: $phpyIniPath).",
+ $phpyIniPath
+ ));
+ } else {
+ $config->set('phpy.ini-path', null);
+ }
+ try {
+ $buildTools = new BuildToolsInstaller($config, $this->consoleIO);
+ $buildTools->install();
+ (new PhpyInstaller($config, $this->consoleIO))->install();
+ if ($this->consoleIO?->ask(
+ 'Do you need to uninstall the build dependencies? [n,Y]',
+ false,
+ questionClass: ConfirmationQuestion::class
+ )) {
+ $buildTools->uninstall();
+ }
+ } catch (CommandStopException) {
+ return $this->consoleIO?->success('Installation stop.');
+ } catch (CommandSuccessedException $exception) {
+ return $this->consoleIO?->success($exception->getMessage());
+ } catch (CommandFailedException $exception) {
+ return $this->consoleIO?->error($exception->getMessage());
+ }
+
+ return $this->consoleIO?->success('PHPy installation completed. ');
+ }
+}
diff --git a/tools/src/Phpy/Commands/PipMirrorConfig.php b/tools/src/Phpy/Commands/PipMirrorConfig.php
new file mode 100644
index 0000000..4caecaf
--- /dev/null
+++ b/tools/src/Phpy/Commands/PipMirrorConfig.php
@@ -0,0 +1,52 @@
+setName('config:pip-mirror')
+ ->setDescription('Set pip mirror');
+ }
+
+ /** @inheritdoc */
+ protected function handler(): int
+ {
+ $this->consoleIO?->output('Current pip mirror url: ' . $this->process->executePip('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->process->executePip('config set global.index-url https://pypi.org/simple/'),
+ '清华' => $this->process->executePip('config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple'),
+ '阿里云' => $this->process->executePip('config set global.index-url http://mirrors.aliyun.com/pypi/simple/'),
+ '华为云' => $this->process->executePip('config set global.index-url https://mirrors.huaweicloud.com/repository/pypi/simple/'),
+ '腾讯云' => $this->process->executePip('config set global.index-url https://mirrors.cloud.tencent.com/pypi/simple/'),
+ '中科大' => $this->process->executePip('config set global.index-url https://pypi.mirrors.ustc.edu.cn/simple/'),
+ '网易' => $this->process->executePip('config set global.index-url http://mirrors.163.com/pypi/simple/'),
+ };
+
+ return $this->consoleIO?->success('已将 pip 源设置为: ' . $selectedOption);
+ }
+}
diff --git a/tools/src/Phpy/Commands/PipModuleInstall.php b/tools/src/Phpy/Commands/PipModuleInstall.php
new file mode 100644
index 0000000..08d90e9
--- /dev/null
+++ b/tools/src/Phpy/Commands/PipModuleInstall.php
@@ -0,0 +1,54 @@
+setName('install:pip-module')
+ ->setDescription('Installs Python module.')
+ ->addArgument('module', InputArgument::REQUIRED, 'The module name to install')
+ ->addArgument('version', InputArgument::OPTIONAL, 'The version of Python module to install', 'latest');
+ }
+
+ /** @inheritdoc */
+ protected function handler(): int
+ {
+ $module = $this->consoleIO?->getInput()->getArgument('module');
+ $version = $this->consoleIO?->getInput()?->getArgument('version');
+
+ $moduleInstaller = new ModuleInstaller(new Config(), $this->consoleIO);
+ $versions = $moduleInstaller->availableVersions($module);
+ if ($version === 'latest') {
+ $ver = $versions[0];
+ }
+ if ($index = array_search($version, $versions, true)) {
+ $ver = $versions[$index];
+ }
+ if ($ver ?? null) {
+ return $this->consoleIO?->error("Python module $module-$version is not available.");
+ }
+ if (
+ $this->process->executePip("install $module==$version --break-system-packages", subOutput: true)
+ !== 0
+ ) {
+ return $this->consoleIO?->error("Error installing Python module $module-$version.");
+ }
+
+ return $this->consoleIO?->success("Python module $module-$version installation complete.");
+ }
+}
diff --git a/tools/src/Phpy/Commands/PythonInstall.php b/tools/src/Phpy/Commands/PythonInstall.php
new file mode 100644
index 0000000..5859085
--- /dev/null
+++ b/tools/src/Phpy/Commands/PythonInstall.php
@@ -0,0 +1,71 @@
+setName('install:python')
+ ->setDescription('Installs Python 3.10+.')
+ ->addOption('venv', null, InputOption::VALUE_NONE, 'Install virtual environment');
+ }
+
+ /** @inheritdoc */
+ protected function handler(): int
+ {
+ $config = new Config();
+ $pythonSourceUrl = $config->get('python.source-url', 'https://github.com/python/cpython.git');
+ $config->set('python.source-url', $this->consoleIO?->ask(
+ "Please enter the Python source code URL to install (default: $pythonSourceUrl).",
+ $pythonSourceUrl
+ ));
+
+ $pythonVersion = $config->get('python.install-version', 'latest');
+ $config->set('python.install-version', $this->consoleIO?->ask(
+ "Please enter the Python version to install (default: $pythonVersion).",
+ $pythonVersion
+ ));
+
+ $pythonInstallDir = $config->get('python.install-dir', '/usr');
+ $config->set('python.install-dir', $this->consoleIO?->ask(
+ "Please enter the Python installation directory (default: $pythonInstallDir).",
+ $pythonInstallDir
+ ));
+
+ try {
+ $buildTools = new BuildToolsInstaller($config, $this->consoleIO);
+ $buildTools->install();
+ (new PythonInstaller($config, $this->consoleIO))->install();
+ if ($this->consoleIO?->ask(
+ 'Do you need to uninstall the build dependencies? [n,Y]',
+ false,
+ questionClass: QuestionHelper::class
+ )) {
+ $buildTools->uninstall();
+ }
+ } catch (CommandStopException) {
+ return $this->consoleIO?->success('Installation stop.');
+ } catch (CommandSuccessedException $exception) {
+ return $this->consoleIO?->success($exception->getMessage());
+ } catch (CommandFailedException $exception) {
+ return $this->consoleIO?->error($exception->getMessage());
+ }
+ return $this->consoleIO?->success('Python installation complete.');
+ }
+}
diff --git a/tools/src/Phpy/Commands/ScanCommand.php b/tools/src/Phpy/Commands/ScanCommand.php
new file mode 100644
index 0000000..6ae8b76
--- /dev/null
+++ b/tools/src/Phpy/Commands/ScanCommand.php
@@ -0,0 +1,78 @@
+setName('scan')
+ ->addArgument('dirs', InputArgument::IS_ARRAY|InputArgument::OPTIONAL, 'Scan directories', [])
+ ->setDescription('Scan python modules import and dependencies')
+ ->setHelp(
+ <<scan command scan python modules import and dependencies.
+
+PHPy introduces modules through Python-pip, read more at
+https://pypi.org/help/
+EOT
+ );
+ }
+
+ /** @inheritdoc */
+ protected function handler(): int
+ {
+ $config = new Config();
+ if (!$dirs = $this->consoleIO->getInput()->getArgument('dirs')) {
+ // find json file
+ $jsonFile = Application::findConfigFile(function ($file, $cDir, $sDir) {
+ if ($cDir !== $sDir) {
+ if (!$this->consoleIO?->ask(
+ "No phpy.json in current directory, do you want to use the one at $cDir [Y,n]?",
+ true,
+ ConfirmationQuestion::class
+ )) {
+ throw new CommandStopException("PHPy could not find a phpy.json file in $sDir");
+ }
+ }
+ System::setcwd($cDir);
+ });
+ if (!$jsonFile) {
+ throw new CommandFailedException('PHPy could not find a phpy.json file in the project');
+ }
+ $config->load($jsonFile);
+ } else {
+ $config->set('config.scan-dirs', $dirs);
+ }
+ try {
+ $moduleInstaller = new ModuleInstaller($config, $this->consoleIO);
+ // 解析 import依赖
+ $moduleInstaller->scan();
+ // 安装
+ $moduleInstaller->install();
+ } catch (CommandStopException $exception) {
+ return $this->consoleIO?->success($exception->getMessage() ?: 'Scan stop.');
+ } catch (CommandSuccessedException $exception) {
+ return $this->consoleIO?->success($exception->getMessage());
+ } catch (CommandFailedException $exception) {
+ return $this->consoleIO?->error($exception->getMessage());
+ }
+ return $this->consoleIO?->success('Scan completed.');
+ }
+}
diff --git a/tools/src/Phpy/Commands/ScanImport.php b/tools/src/Phpy/Commands/ScanImport.php
new file mode 100644
index 0000000..f1681f5
--- /dev/null
+++ b/tools/src/Phpy/Commands/ScanImport.php
@@ -0,0 +1,87 @@
+setName('scan:import')
+ ->setDescription('Scan the code to see what python modules are imported.')
+ ->addArgument('path', InputArgument::REQUIRED, 'The path to scan.');
+ }
+
+ private function findPhpFiles($directory): array
+ {
+ $phpFiles = [];
+ $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($directory));
+ foreach ($iterator as $file) {
+ if ($file->isFile() && pathinfo($file->getFilename(), PATHINFO_EXTENSION) === 'php') {
+ $phpFiles[] = $file->getPathname();
+ }
+ }
+ return $phpFiles;
+ }
+
+ /** @inheritdoc */
+ protected function handler(): int
+ {
+ $this->consoleIO?->output('Scan the PHP code in the path directory to see what Python modules are imported ...');
+ $srcPath = realpath($this->consoleIO?->getInput()->getArgument('path'));
+ if (!is_dir($srcPath)) {
+ return $this->consoleIO?->error('The path is not a directory.');
+ }
+
+ try {
+ $config = new Config();
+ $config->set('config.scan-dirs', [$srcPath]);
+ $moduleInstaller = new ModuleInstaller($config, $this->consoleIO);
+ $moduleInstaller->scan();
+
+ if ($config->get('modules')) {
+ $buildTools = new BuildToolsInstaller($config, $this->consoleIO);
+ if ($this->consoleIO?->ask(
+ 'Do you want to install the dependent packages? [Y,n]',
+ true,
+ questionClass: ConfirmationQuestion::class
+ )) {
+ $buildTools->install();
+ $moduleInstaller->install();
+ }
+ if ($this->consoleIO?->ask(
+ 'Do you need to uninstall the build dependencies? [n,Y]',
+ false,
+ questionClass: ConfirmationQuestion::class
+ )) {
+ $buildTools->uninstall();
+ }
+ }
+ } catch (CommandStopException) {
+ return $this->consoleIO?->success('Stop.');
+ } catch (CommandSuccessedException $exception) {
+ return $this->consoleIO?->success($exception->getMessage());
+ } catch (CommandFailedException $exception) {
+ return $this->consoleIO?->error($exception->getMessage());
+ }
+ return $this->consoleIO?->success('Scan import complete.');
+ }
+}
diff --git a/tools/src/Phpy/Commands/ShowCommand.php b/tools/src/Phpy/Commands/ShowCommand.php
new file mode 100644
index 0000000..8df96a4
--- /dev/null
+++ b/tools/src/Phpy/Commands/ShowCommand.php
@@ -0,0 +1,49 @@
+setName('show')
+ ->addArgument('module', InputArgument::OPTIONAL, 'The module name')
+ ->setDescription('Shows information about python modules and python-env')
+ ->setHelp(
+ <<show command displays detailed information about Python-env and a Python modules, or
+lists all Python modules available.
+
+PHPy introduces modules through Python-pip, read more at
+https://pypi.org/help/
+EOT
+ );
+ }
+
+ /** @inheritdoc */
+ protected function handler(): int
+ {
+ $this->consoleIO->output('Python-env: ');
+ $this->process->executePython('--version', subOutput: true);
+ $this->process->executePip('--version', subOutput: true);
+ $this->consoleIO->output('Python-includes: ');
+ $this->process->executePythonConfig('--includes', subOutput: true);
+
+ if ($module = $this->consoleIO->getInput()->getArgument('module')) {
+ $this->consoleIO->output("Python-module [$module]: ");
+ $resultCode = $this->process->executePip("show $module", subOutput: true);
+ } else {
+ $this->consoleIO->output('Python-modules: ');
+ $resultCode = $this->process->executePip('list', subOutput: true);
+ }
+ return $resultCode;
+ }
+}
diff --git a/tools/src/Phpy/Commands/UpdateCommand.php b/tools/src/Phpy/Commands/UpdateCommand.php
new file mode 100644
index 0000000..8ae63da
--- /dev/null
+++ b/tools/src/Phpy/Commands/UpdateCommand.php
@@ -0,0 +1,91 @@
+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')
+ ->setHelp(
+ <<update command reads the phpy.json file from the
+current directory, processes it, and updates, removes or installs all the
+dependencies.
+
+PHPy introduces modules through Python-pip, read more at
+https://pypi.org/help/
+EOT
+ );
+ }
+
+ /** @inheritdoc */
+ protected function handler(): int
+ {
+ try {
+ // find json file
+ $jsonFile = Application::findConfigFile(function ($file, $cDir, $sDir) {
+ if ($cDir !== $sDir) {
+ if (!$this->consoleIO?->ask(
+ "No phpy.json in current directory, do you want to use the one at $cDir [Y,n]?",
+ true,
+ ConfirmationQuestion::class
+ )) {
+ throw new CommandStopException("PHPy could not find a phpy.json file in $sDir");
+ }
+ }
+ System::setcwd($cDir);
+ });
+ if (!$jsonFile) {
+ throw new CommandFailedException('PHPy could not find a phpy.json file in the project');
+ }
+ // 尝试读取lock
+ $lockFile = Application::getLockFile(System::getcwd());
+ $config = new Config($lockFile ?: $jsonFile);
+ // build tools
+ (new BuildToolsInstaller($config, $this->consoleIO))->install();
+ // update python env
+ if (!$this->consoleIO?->getInput()->getOption('skip-env')) {
+ (new PythonInstaller($config, $this->consoleIO))->upgrade();
+ }
+ // install phpy extensions
+ if (!$this->consoleIO?->getInput()->getOption('skip-ext')) {
+ (new PhpyInstaller($config, $this->consoleIO))->upgrade();
+ }
+ // install modules
+ if (!$this->consoleIO?->getInput()->getOption('skip-module')) {
+ // module
+ (new ModuleInstaller($config, $this->consoleIO))->upgrade();
+ }
+ Application::setLockFile(System::getcwd(), $config->all());
+ } catch (CommandStopException) {
+ return $this->consoleIO?->success('Installation stop.');
+ } catch (CommandSuccessedException $exception) {
+ return $this->consoleIO?->success($exception->getMessage());
+ } catch (CommandFailedException $exception) {
+ return $this->consoleIO?->error($exception->getMessage());
+ }
+ return $this->consoleIO?->success('Installation completed.');
+ }
+}
diff --git a/tools/src/Phpy/Config.php b/tools/src/Phpy/Config.php
new file mode 100644
index 0000000..160b7ae
--- /dev/null
+++ b/tools/src/Phpy/Config.php
@@ -0,0 +1,130 @@
+ [
+ 'cache-dir' => '~/.cache/phpy',
+ 'scan-dirs' => [],
+ 'pip-index-url' => ''
+ ],
+ 'python' => [
+ 'source-url' => 'https://github.com/python/cpython.git',
+ 'install-dir' => '/usr',
+ 'install-version' => 'v3.13.2',
+ 'install-configure' => [
+ '--enable-shared',
+ '--with-system-expat',
+ '--with-system-ffi',
+ '--enable-ipv6',
+ '--enable-loadable-sqlite-extensions',
+ '--with-computed-gotos',
+ '--with-ensurepip=install',
+ ],
+ ],
+ 'phpy' => [
+ 'source-url' => 'https://github.com/swoole/phpy.git',
+ 'install-version' => 'latest',
+ 'install-configure' => [],
+ 'ini-path' => '/usr/local/etc/php/conf.d/xx-php-ext-phpy.ini'
+ ],
+ 'modules' => []
+ ];
+
+ /**
+ * @return string
+ */
+ 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);
+ }
+
+ /**
+ * @param string|null $file
+ */
+ public function __construct(?string $file = null)
+ {
+ if ($file) {
+ $this->load($file);
+ }
+ }
+
+ /**
+ * @param string $file
+ * @return void
+ */
+ public function load(string $file): void
+ {
+ try {
+ $config = json_decode(System::getFileContent($file), true, flags: JSON_THROW_ON_ERROR);
+ } catch (\Throwable) {}
+ $this->config = array_replace_recursive($this->config, $config ?? []);
+ }
+
+ /**
+ * 获取配置
+ *
+ * @param string|null $key
+ * @param mixed|null $default
+ * @return mixed
+ */
+ public function get(?string $key, mixed $default = null): mixed
+ {
+ $config = $this->config;
+ if ($key) {
+ $keyArray = explode('.', $key);
+ $found = true;
+ foreach ($keyArray as $index) {
+ if (!isset($config[$index])) {
+ $found = false;
+ break;
+ }
+ $config = $config[$index];
+ }
+
+ return $found ? $config : $default;
+ }
+
+ return $config;
+ }
+
+ /**
+ * @param string $key
+ * @param mixed $value
+ * @return void
+ */
+ public function set(string $key, mixed $value): void
+ {
+ $keys = explode('.', $key);
+ $config = &$this->config;
+
+ foreach ($keys as $k) {
+ if (!isset($config[$k]) or !is_array($config[$k])) {
+ $config[$k] = [];
+ }
+ $config = &$config[$k];
+ }
+
+ $config = $value;
+ }
+
+ /**
+ * @return array
+ */
+ public function all(): array
+ {
+ $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
new file mode 100644
index 0000000..00e12ac
--- /dev/null
+++ b/tools/src/Phpy/ConsoleIO.php
@@ -0,0 +1,205 @@
+input = $input;
+ $this->output = $output;
+ $this->helperSet = $helperSet;
+ $this->extra = $extra;
+ $this->getOutput()
+ ->getFormatter()
+ ->setStyle('sub-output', new OutputFormatterStyle('gray', null));
+ }
+
+ /**
+ * 获取input
+ *
+ * @return InputInterface
+ */
+ public function getInput(): InputInterface
+ {
+ return $this->input;
+ }
+
+ /**
+ * 获取output
+ *
+ * @return OutputInterface
+ */
+ public function getOutput(): OutputInterface
+ {
+ return $this->output;
+ }
+
+ /**
+ * 获取helperSet
+ *
+ * @return HelperSet
+ */
+ public function getHelperSet(): HelperSet
+ {
+ return $this->helperSet;
+ }
+
+ /**
+ * @param string $key
+ * @param mixed $value
+ * @return void
+ */
+ public function setExtra(string $key, mixed $value): void
+ {
+ $keys = explode('.', $key);
+ $extra = &$this->extra;
+
+ foreach ($keys as $k) {
+ if (!isset($extra[$k]) or !is_array($extra[$k])) {
+ $extra[$k] = [];
+ }
+ $extra = &$extra[$k];
+ }
+
+ $extra = $value;
+ }
+
+ /**
+ * 获取extra
+ *
+ * @param string|null $key
+ * @param mixed|null $default
+ * @return mixed
+ */
+ public function getExtra(?string $key = null, mixed $default = null): mixed
+ {
+ $extra = $this->extra;
+ if ($key) {
+ $keyArray = explode('.', $key);
+ $found = true;
+ foreach ($keyArray as $index) {
+ if (!isset($extra[$index])) {
+ $found = false;
+ break;
+ }
+ $extra = $extra[$index];
+ }
+
+ return $found ? $extra : $default;
+ }
+
+ return $extra;
+ }
+
+ /**
+ * @param string $message
+ * @param mixed|null $default
+ * @param string $tag
+ * @param class-string $questionClass
+ * @return mixed
+ */
+ public function ask(string $message, mixed $default = null, string $tag = '[?]', string $questionClass = Question::class): mixed
+ {
+ // 询问安装目录
+ $question = new $questionClass("$tag $message \n", $default);
+ /** @var QuestionHelper $questionHelper */
+ $questionHelper = $this->getHelperSet()->get('question');
+ return $questionHelper->ask($this->getInput(), $this->getOutput(), $question);
+ }
+
+ /**
+ * sub输出
+ *
+ * @param string $message
+ * @param string $tag
+ * @return void
+ */
+ public function subOutput(string $message, string $tag = '[>]'): void
+ {
+ $this->getOutput()
+ ->writeln(" $tag $message");
+ }
+
+ /**
+ * 普通输出
+ *
+ * @param string $message
+ * @param string $tag
+ * @return void
+ */
+ public function output(string $message, string $tag = '[>]'): void
+ {
+ $this->getOutput()->writeln("$tag $message");
+ }
+
+ /**
+ * 输出info
+ *
+ * @param string $message
+ * @return int
+ */
+ public function comment(string $message): int
+ {
+ $this->getOutput()->writeln("[i] $message");
+ return Command::SUCCESS;
+ }
+
+ /**
+ * 输出error
+ *
+ * @param string $message
+ * @return int
+ */
+ public function error(string $message): int
+ {
+ $output = $this->getOutput();
+ if ($output instanceof ConsoleOutput) {
+ $output->getErrorOutput()->writeln("[×] $message");
+ return Command::FAILURE;
+ }
+ $this->getOutput()->writeln("[×] $message");
+ return Command::FAILURE;
+ }
+
+ /**
+ * 输出success
+ *
+ * @param string $message
+ * @return int
+ */
+ public function success(string $message): int
+ {
+ $this->getOutput()->writeln("[√] $message");
+ return Command::SUCCESS;
+ }
+}
diff --git a/tools/src/Phpy/Exceptions/CommandFailedException.php b/tools/src/Phpy/Exceptions/CommandFailedException.php
new file mode 100644
index 0000000..ce92e7b
--- /dev/null
+++ b/tools/src/Phpy/Exceptions/CommandFailedException.php
@@ -0,0 +1,10 @@
+args[$index]) && $node->args[$index]->value instanceof Node\Scalar\String_) {
+ $this->packages[] = $node->args[$index]->value->value;
+ }
+ };
+ switch (true) {
+ // 静态方法调用
+ case ($node instanceof Node\Expr\StaticCall):
+ if ($node->class instanceof Node\Name && strval($node->class) === 'PyCore' && strval($node->name) === 'import') {
+ $foundPackageFn($node);
+ }
+ break;
+ // 函数调用
+ case ($node instanceof Node\Expr\FuncCall):
+ if ($node->name instanceof Node\Name && strtolower($node->name) === 'pyimport') {
+ $foundPackageFn($node);
+ }
+ break;
+ // 注解
+ case ($node instanceof Node\Attribute):
+ if (strval($node->name) === 'PyInherit') {
+ $foundPackageFn($node, 1); // 捕获第二个参数
+ } elseif (strval($node->name) === 'PyImport') {
+ $foundPackageFn($node); // 捕获第一个参数
+ }
+ break;
+
+ }
+ }
+
+ public function getPackages(): array
+ {
+ return array_unique($this->packages); // 去重
+ }
+
+ static function parseFile($filePath): array
+ {
+ $code = file_get_contents($filePath);
+ $parser = (new ParserFactory())->createForNewestSupportedVersion();
+ $traverser = new NodeTraverser;
+
+ $collector = new PackageCollector();
+ $traverser->addVisitor($collector);
+
+ try {
+ $statements = $parser->parse($code);
+ $traverser->traverse($statements);
+ } catch (Error $e) {
+ echo 'Parse Error: ' . $e->getMessage();
+ }
+
+ return $collector->getPackages();
+ }
+}
diff --git a/tools/src/Phpy/Helpers/Process.php b/tools/src/Phpy/Helpers/Process.php
new file mode 100644
index 0000000..44cb7ad
--- /dev/null
+++ b/tools/src/Phpy/Helpers/Process.php
@@ -0,0 +1,131 @@
+runtimeId = $runtimeId ?: uniqid();
+ $this->consoleIO = $consoleIO;
+ $this->debug = $debug;
+ }
+
+ /**
+ * @return string
+ */
+ public function getRuntimeId(): string
+ {
+ return $this->runtimeId;
+ }
+
+ /**
+ * @param string $info
+ * @return void
+ */
+ private function debugMode(string $info): void
+ {
+ if ($this->debug) {
+ $this->consoleIO?->subOutput($info = "DEBUG INFO -> $info");
+ $file = str_replace('\\', '_', get_called_class()) . '-' . $this->runtimeId;
+ if (!is_dir($dir = getcwd() . "/.log")) {
+ mkdir($dir, 0777, true);
+ }
+ file_put_contents("$dir/$file.log", "$info\n", FILE_APPEND | LOCK_EX);
+ }
+ }
+
+ /**
+ * 执行命令
+ *
+ * @param string $command 执行命令
+ * @param array|null $output stdout & stderr (结果列表始终为倒序)
+ * @param bool $subOutput 是否输出到subOutput(输出始终为正序)
+ * @return int 错误码 -1失败
+ */
+ public function execute(string $command, ?array &$output = null, bool $subOutput = false): int
+ {
+ $command = str_ends_with($command, ' 2>&1') ? $command : "$command 2>&1";
+ $this->debugMode("execute( $command )");
+ $resultCode = -1;
+ $output = $output === null ? [] : $output;
+ if (is_resource($handle = popen($command, 'r'))) {
+ // 逐行读取命令输出
+ while (!feof($handle)) {
+ $line = fgets($handle);
+ if ($line !== false) {
+ $line = $line ? trim($line) : null;
+ if ($line) {
+ array_unshift($output, $line);
+ if ($subOutput) {
+ $this->consoleIO?->subOutput($line);
+ }
+ }
+ }
+ }
+ $resultCode = pclose($handle);
+ }
+ return $resultCode;
+ }
+
+ /**
+ * 执行命令pip
+ *
+ * @param string $command
+ * @param mixed|null $output
+ * @param bool $subOutput
+ * @return int|null
+ */
+ public function executePip(string $command, ?array &$output = null, bool $subOutput = false): int|null
+ {
+ $pip = System::pip();
+ return $this->execute("$pip $command", $output, subOutput: $subOutput);
+ }
+
+ /**
+ * 执行命令python
+ *
+ * @param string $command
+ * @param mixed|null $output
+ * @param bool $subOutput
+ * @return int|null
+ */
+ public function executePython(string $command, ?array &$output = null, bool $subOutput = false): int|null
+ {
+ $python = System::python();
+ return $this->execute("$python $command", $output, subOutput: $subOutput);
+ }
+
+ /**
+ * 执行命令python-config
+ *
+ * @param string $command
+ * @param mixed|null $output
+ * @param bool $subOutput
+ * @return int|null
+ */
+ public function executePythonConfig(string $command, ?array &$output = null, bool $subOutput = false): int|null
+ {
+ $python = System::pythonConfig();
+ return $this->execute("$python $command", $output, subOutput: $subOutput);
+ }
+}
diff --git a/tools/src/PythonMetadata.php b/tools/src/Phpy/Helpers/PythonMetadata.php
similarity index 98%
rename from tools/src/PythonMetadata.php
rename to tools/src/Phpy/Helpers/PythonMetadata.php
index 7038a61..8c673b6 100644
--- a/tools/src/PythonMetadata.php
+++ b/tools/src/Phpy/Helpers/PythonMetadata.php
@@ -1,6 +1,6 @@
+ */
+ protected static array $fileContentCache = [];
+
+ /**
+ * @var array
+ */
+ protected static array $existingPackages = [];
+
+ /**
+ * @return false|string
+ */
+ public static function getcwd()
+ {
+ return $GLOBALS['PHPY_CWD'] ?? getcwd();
+ }
+
+ /**
+ * @param string $cwd
+ * @return void
+ */
+ public static function setcwd(string $cwd)
+ {
+ $GLOBALS['PHPY_CWD'] = $cwd;
+ }
+
+ /**
+ * 获取/设置 python所在路径
+ *
+ * @param string|null $path
+ * @return string
+ */
+ public static function python(?string $path = null): string
+ {
+ if (file_exists($command = System::getcwd() . '/python.command')) {
+ return trim(System::getFileContent($command));
+ }
+ if (!$path or !file_exists($path)) {
+ if (!$path = exec('command -v python')) {
+ throw new PhpyException('Python not found. ');
+ }
+ }
+// System::putFileContent($command, $path);
+ return $path;
+ }
+
+ /**
+ * 获取/设置 pip所在路径
+ *
+ * @param string|null $path
+ * @return string
+ */
+ public static function pip(?string $path = null): string
+ {
+ if (file_exists($command = System::getcwd() . '/pip.command')) {
+ return trim(System::getFileContent($command));
+ }
+ if (!$path or !file_exists($path)) {
+ if (!$path = exec('command -v pip')) {
+ throw new PhpyException('Python-pip not found. ');
+ }
+ }
+// System::putFileContent($command, $path);
+ return $path;
+ }
+
+ /**
+ * 获取/设置 python-config所在路径
+ *
+ * @param string|null $path
+ * @return string
+ */
+ public static function pythonConfig(?string $path = null): string
+ {
+ if (file_exists($command = System::getcwd() . '/python-config.command')) {
+ return trim(System::getFileContent($command));
+ }
+ if (!$path or !file_exists($path)) {
+ if (!$path = exec('command -v python-config')) {
+ throw new PhpyException('Python-config not found. ');
+ }
+ }
+// System::putFileContent($command, $path);
+ return $path;
+ }
+
+ /**
+ * 获取文件内容
+ *
+ * @param string $filePath
+ * @param bool $cache
+ * @return string
+ */
+ public static function getFileContent(string $filePath, bool $cache = true): string
+ {
+ if ($cache) {
+ return static::$fileContentCache[$filePath] ??= file_get_contents($filePath);
+ }
+ return file_get_contents($filePath);
+ }
+
+ /**
+ * 写入文件内容
+ *
+ * @param string $filePath
+ * @param string $content
+ * @param int $flag
+ * @param bool $cache
+ * @return bool|int
+ */
+ public static function putFileContent(string $filePath, string $content, int $flag = 0, bool $cache = true): bool|int
+ {
+ if (!$cache or $flag !== 0) {
+ unset(static::$fileContentCache[$filePath]);
+ } else {
+ static::$fileContentCache[$filePath] = $content;
+ }
+ return file_put_contents($filePath, $content, $flag);
+ }
+
+ /**
+ * 清除文件内容缓存
+ *
+ * @return void
+ */
+ public static function clearFileContentCache(): void
+ {
+ static::$fileContentCache = [];
+ }
+
+
+ /**
+ * 获取系统类型
+ *
+ * @return string
+ */
+ public static function getPackageManager(): string
+ {
+ return match (true) {
+ file_exists('/etc/alpine-release') => 'apk',
+ file_exists('/etc/centos-release') ||
+ file_exists('/etc/redhat-release') ||
+ file_exists('/etc/fedora-release') => 'yum',
+ file_exists('/etc/arch-release') => 'pacman',
+ file_exists('/etc/SuSE-release') => 'zypper',
+ stripos(php_uname('s'), 'Darwin') !== false => 'brew',
+ stripos(php_uname('s'), 'Windows') !== false => 'winget',
+ default => 'apt-get',
+ };
+ }
+
+ /**
+ * @return string[]
+ */
+ public static function getBuildToolsList(): array
+ {
+ return match (static::getPackageManager()) {
+ 'apk' => [
+ 'gcc', 'g++', 'make', 'autoconf',
+ 'musl-dev',
+ 'expat-dev',
+ 'libffi-dev',
+ 'openssl-dev',
+ 'zlib-dev',
+ 'xz-dev',
+ 'bzip2-dev',
+ 'sqlite-dev',
+ 'gdbm-dev',
+ 'gmp-dev',
+ 'pcre-dev',
+ 'icu-dev'
+ ],
+ 'apt-get' => [
+ 'build-essential',
+ 'libexpat1-dev',
+ 'libffi-dev',
+ 'libssl-dev',
+ 'zlib1g-dev',
+ 'libbz2-dev',
+ 'libsqlite3-dev',
+ 'libgdbm-dev',
+ 'liblzma-dev',
+ 'libgmp-dev',
+ 'linux-headers-generic'
+ ],
+ 'yum' => [
+ 'gcc',
+ 'gcc-c++',
+ 'make',
+ 'autoconf',
+ 'expat-devel',
+ 'libffi-devel',
+ 'openssl-devel',
+ 'zlib-devel',
+ 'bzip2-devel',
+ 'sqlite-devel',
+ 'gdbm-devel',
+ 'gmp-devel',
+ 'kernel-headers'
+ ],
+ 'zypper' => [
+ 'gcc',
+ 'gcc-c++',
+ 'make',
+ 'autoconf',
+ 'libexpat-devel',
+ 'libffi-devel',
+ 'libopenssl-devel',
+ 'zlib-devel',
+ 'bzip2-devel',
+ 'sqlite3-devel',
+ 'gdbm-devel',
+ 'gmp-devel',
+ 'kernel-default-devel'
+ ],
+ 'pacman' => [
+ 'gcc',
+ 'make',
+ 'autoconf',
+ 'expat',
+ 'libffi',
+ 'openssl',
+ 'zlib',
+ 'bzip2',
+ 'sqlite',
+ 'gdbm',
+ 'gmp',
+ 'linux-headers'
+ ],
+ 'brew' => ['autoconf'],
+ 'winget' => ['make', 'autoconf'],
+ default => [],
+ };
+ }
+
+ /**
+ * 获取系统编译依赖卸载命令
+ *
+ * @return string|null
+ */
+ public static function getBuildToolsUninstall(): ?string
+ {
+ $uninstallPackages = array_diff(static::getBuildToolsList(), static::$existingPackages);
+ static::$existingPackages = [];
+ return match (static::getPackageManager()) {
+ 'apk' => 'apk del ' . implode(' ', $uninstallPackages),
+ 'yum' => 'sudo yum remove ' . implode(' ', $uninstallPackages),
+ 'brew' => 'brew uninstall ' . implode(' ', $uninstallPackages),
+ 'zypper' => 'sudo zypper remove ' . implode(' ', $uninstallPackages),
+ 'pacman' => 'sudo pacman -R ' . implode(' ', $uninstallPackages),
+ 'winget' => 'winget uninstall ' . implode(' ', $uninstallPackages),
+ 'apt-get' => 'sudo apt-get install ' . implode(' ', $uninstallPackages),
+ default => null,
+ };
+ }
+
+ /**
+ * 获取系统编译依赖安装命令
+ *
+ * @param bool $check
+ * @return string|null
+ */
+ public static function getBuildToolsInstall(bool $check = true): ?string
+ {
+ $packages = static::getBuildToolsList();
+ self::$existingPackages = $check ? self::checkExistingPackages($packages) : [];
+
+ $installPackages = array_diff($packages, self::$existingPackages);
+ if (!$installPackages) {
+ return null;
+ }
+ return match (self::getPackageManager()) {
+ 'apk' => 'apk add --no-cache ' . implode(' ', $installPackages),
+ 'yum' => 'sudo yum install ' . implode(' ', $installPackages),
+ 'brew' => 'brew install ' . implode(' ', $installPackages),
+ 'zypper' => 'sudo zypper install ' . implode(' ', $installPackages),
+ 'pacman' => 'sudo pacman -S ' . implode(' ', $installPackages),
+ 'winget' => 'winget install ' . implode(' ', $installPackages),
+ 'apt-get' => 'sudo apt-get install ' . implode(' ', $installPackages),
+ default => null,
+ };
+ }
+
+ /**
+ * 检查系统中已存在的包
+ *
+ * @param array $packages
+ * @return array
+ */
+ protected static function checkExistingPackages(array $packages): array
+ {
+ $existingPackages = [];
+ foreach ($packages as $package) {
+ $result = match (self::getPackageManager()) {
+ 'apk' => shell_exec("apk info | grep ^$package\$"),
+ 'yum' => shell_exec("rpm -q $package"),
+ 'brew' => shell_exec("brew list --formula | grep ^$package\$"),
+ 'zypper' => shell_exec("zypper se -i | grep ^$package\$"),
+ 'pacman' => shell_exec("pacman -Qi $package"),
+ 'winget' => shell_exec("winget list $package"),
+ default => shell_exec("dpkg -l | grep ^ii | grep $package") ||
+ shell_exec("apt list --installed | grep ^$package/"),
+ };
+
+ if ($result and !empty(trim($result))) {
+ $existingPackages[] = $package;
+ }
+ }
+ return $existingPackages;
+ }
+}
diff --git a/tools/src/Phpy/Helpers/Version.php b/tools/src/Phpy/Helpers/Version.php
new file mode 100644
index 0000000..0835d40
--- /dev/null
+++ b/tools/src/Phpy/Helpers/Version.php
@@ -0,0 +1,41 @@
+normalize($version);
+ return true;
+ } catch (\UnexpectedValueException) {
+ return false;
+ }
+ }
+
+ /**
+ * @param string $version
+ * @return int[]
+ */
+ public static function splitVersion(string $version): array {
+ $cleanVer = ltrim(strtolower($version), 'v');
+ preg_match('/^(\d+)(?:\.(\d+))?(?:\.(\d+))?/', $cleanVer, $matches);
+
+ return [
+ (int)($matches[1] ?? 0),
+ (int)($matches[2] ?? 0),
+ (int)($matches[3] ?? 0)
+ ];
+ }
+
+}
diff --git a/tools/src/Phpy/Installers/BuildToolsInstaller.php b/tools/src/Phpy/Installers/BuildToolsInstaller.php
new file mode 100644
index 0000000..e745e33
--- /dev/null
+++ b/tools/src/Phpy/Installers/BuildToolsInstaller.php
@@ -0,0 +1,87 @@
+config = $config;
+ $this->consoleIO = $consoleIO;
+ $this->process = $consoleIO?->getExtra('process') ?: new Process($consoleIO);
+ }
+
+ /** @inheritdoc */
+ public function install(): void
+ {
+ $this->consoleIO?->output('Installing/Upgrading dependency tools ...');
+ // 编译依赖工具
+ if ($command = System::getBuildToolsInstall()) {
+ if ($this->consoleIO?->ask(
+ "Do you want to install dependency tools? $command [Y,n]",
+ true,
+ questionClass: ConfirmationQuestion::class
+ )) {
+ if ($this->process->execute(
+ "$command", subOutput: true
+ ) !== 0) {
+ throw new CommandFailedException('Error installing dependency tools.');
+ }
+ }
+ }
+ }
+
+ /** @inheritdoc */
+ public function uninstall(): void
+ {
+ $this->consoleIO?->output('Uninstalling dependency tools ...');
+ // 卸载编译依赖工具
+ if ($command = System::getBuildToolsUninstall()) {
+ if ($this->consoleIO?->ask(
+ "Do you want to uninstall dependency tools?> [y,N])",
+ false,
+ questionClass: ConfirmationQuestion::class
+ )) {
+ $this->process->execute(
+ "$command", subOutput: true
+ );
+ }
+ }
+ }
+
+ /** @inheritdoc */
+ public function upgrade(): void
+ {
+ $this->install();
+ }
+
+ /** @inheritdoc */
+ public function clearCache(): void
+ {
+ }
+}
diff --git a/tools/src/Phpy/Installers/InstallerInterface.php b/tools/src/Phpy/Installers/InstallerInterface.php
new file mode 100644
index 0000000..2e52c2e
--- /dev/null
+++ b/tools/src/Phpy/Installers/InstallerInterface.php
@@ -0,0 +1,48 @@
+config = $config;
+ $this->consoleIO = $consoleIO;
+ $this->process = $consoleIO?->getExtra('process') ?: new Process($consoleIO);
+ }
+
+ /**
+ * 查询 pip 库中的模块版本
+ *
+ * @param string $module
+ * @return array|null
+ */
+ public function moduleVersions(string $module): ?array
+ {
+ $resultCode = $this->process->executePip("index versions $module", output: $output);
+ if ($resultCode === 0) {
+ $res = [];
+ foreach ($output as $line) {
+ if (str_starts_with($line, 'Available versions:')) {
+ // 解析 pip 输出,获取模块的可用版本
+ preg_match_all('/Available versions: (.+)/', $line, $matches);
+ $res = explode(', ', $matches[1][0] ?? '');
+ }
+ }
+ return $res;
+ }
+ return null;
+ }
+
+ /**
+ * @return void
+ */
+ public function scan(): void
+ {
+ $this->consoleIO?->output('Scanning for modules ...');
+ $dirs = $this->config->get('config.scan-dirs');
+ if (!$dirs) {
+ throw new CommandStopException('Nothing to scan');
+ }
+ $packages = [];
+ foreach ($dirs as $dir) {
+
+ if (!is_dir($dir = System::getcwd() . $dir)) {
+ continue;
+ }
+ $files = $this->findPhpFiles(realpath($dir));
+
+ foreach ($files as $file) {
+ $this->consoleIO?->output("Scanning $file");
+ $scannedPackages = PackageCollector::parseFile($file);
+ foreach ($scannedPackages as $package) {
+ $this->consoleIO?->subOutput("-- scanned: $package");
+ }
+ $packages = array_merge($packages, $scannedPackages);
+ }
+ }
+ $packages = array_unique($packages);
+ $modules = [];
+ foreach ($packages as $key => $package) {
+ $package = explode('.', $package)[0];
+ if (PythonMetadata::isStdLibrary($package)) {
+ continue;
+ }
+ if ($availableVersions = $this->moduleVersions($package)) {
+ $modules[$package] = Semver::rsort($availableVersions)[0];
+ }
+ }
+ if ($modules) {
+ $count = count($modules);
+ $this->consoleIO?->output("Installs: $count");
+ foreach ($modules as $module => $version) {
+ $this->consoleIO?->subOutput("-- $module - $version");
+ }
+ }
+ if (!$this->consoleIO?->ask(
+ "Do you want to install these modules? [Y,n]",
+ true,
+ ConfirmationQuestion::class
+ )) {
+ throw new CommandStopException('PHPy will not install any modules');
+ }
+ $this->config->set('modules', array_merge($this->config->get('modules', []), $modules));
+ }
+
+ /** @inheritdoc */
+ public function install(): void
+ {
+ $this->consoleIO?->output('Installing/Updating modules ...');
+ $modules = $this->config->get('modules', []);
+ $vendorModules = $this->config->get('vendor-modules', []);
+ $phpyHash = $this->config->get('phpy-hash');
+ $composerHash = $this->config->get('composer-hash');
+
+ // 如果存在 phpy-hash 和 composer-hash, 则为install
+ if ($phpyHash and $composerHash) {
+ $installModules = array_merge($modules, $vendorModules);
+ }
+ // 不存在,则为update
+ else {
+ $this->config->set('phpy-hash', hash_file('SHA256', System::getcwd() . '/phpy.json'));
+ $this->config->set('composer-hash', hash_file('SHA256', System::getcwd() . '/composer.json'));
+ $localModules = [];
+ foreach ($modules as $module => $versionConstraint) {
+ $localModules[$module] = $this->satisfyingVersions($module, $this->availableVersions($module), $versionConstraint);
+ }
+
+ // vendor
+ $vendors = [];
+ Application::getVendorConfigFiles(function ($organization, $package, $configFilePath) use (&$vendors) {
+ $config = new Config($configFilePath);
+ $modules = $config->get('modules', []);
+ foreach ($modules as $module => $versionConstraint) {
+ $vendors[$module][$versionConstraint] = [
+ 'organization' => $organization,
+ 'package' => $package,
+ ];
+ }
+ });
+ $this->config->set('vendors', $vendors);
+
+ $vendorModules = [];
+ foreach ($vendors as $module => $item) {
+ $availableVersions = ($local = isset($localModules[$module]))
+ ? $localModules[$module]
+ : $this->availableVersions($module);
+ // 循环检查所有组织/包的版本约束
+ foreach ($item as $versionConstraint => $info) {
+ $availableVersions = $this->satisfyingVersions($module, $availableVersions, $versionConstraint, $info);
+ }
+ if ($availableVersions) {
+ if (!$local) {
+ $vendorModules[$module] = $availableVersions[0];
+ } else {
+ $localModules[$module] = $availableVersions[0];
+ }
+ }
+ }
+ $this->config->set('modules', $localModules);
+ $this->config->set('vendor-modules', $vendorModules);
+ $installModules = array_merge($localModules, $vendorModules);
+ }
+
+ if ($installModules) {
+ $this->pipModulesInstall($installModules);
+ } else {
+ $this->consoleIO->output('No modules.');
+ }
+ }
+
+ /** @inheritdoc */
+ public function uninstall(): void
+ {
+ }
+
+ /** @inheritdoc */
+ public function upgrade(): void
+ {
+ if ($hash = $this->config->get('phpy-hash')) {
+ $phpyHash = hash_file('SHA256', System::getcwd() . '/phpy.json');
+ if ($phpyHash !== $hash) {
+ $this->config->set('phpy-hash', null);
+ }
+ }
+ if ($hash = $this->config->get('composer-hash')) {
+ $composerHash = hash_file('SHA256', System::getcwd() . '/composer.json');
+ if ($composerHash !== $hash) {
+ $this->config->set('composer-hash', null);
+ }
+ }
+ $this->install();
+ }
+
+ /** @inheritdoc */
+ public function clearCache(): void
+ {
+ }
+
+
+ /**
+ * 查找php文件
+ *
+ * @param $directory
+ * @return array
+ */
+ private function findPhpFiles($directory): array
+ {
+ $phpFiles = [];
+ $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($directory));
+ foreach ($iterator as $file) {
+ if ($file->isFile() && pathinfo($file->getFilename(), PATHINFO_EXTENSION) === 'php') {
+ $phpFiles[] = $file->getPathname();
+ }
+ }
+ return $phpFiles;
+ }
+
+ /**
+ * 获取可用的Python模块版本
+ *
+ * @param string $module
+ * @param array $availableVersions
+ * @param string $versionConstraint
+ * @param array $info
+ * @return array
+ */
+ private function satisfyingVersions(string $module, array $availableVersions, string $versionConstraint, array $info = []): array
+ {
+ $organization = $info['organization'] ?? null;
+ $package = $info['package'] ?? null;
+ $msg = ($organization and $package)
+ ? "Package {$info['organization']}/{$info['package']}"
+ : "Local";
+ // 检查版本是否满足约束
+ $satisfyingVersions = Semver::satisfiedBy($availableVersions, $versionConstraint);
+ if (!$satisfyingVersions) {
+ $this->consoleIO?->output(<<$module-$versionConstraint> not found in pip.
+
+Read more about https://pypi.org/search
+EOT
+ );
+ throw new CommandFailedException('Failed.');
+ }
+ // 选择满足约束的版本列表
+ return Semver::rsort($satisfyingVersions);
+ }
+
+ /**
+ * 返回pip可用版本列表
+ *
+ * @param string $module
+ * @return array
+ */
+ public function availableVersions(string $module): array
+ {
+ // 查询 pip 库中的模块版本
+ if (!$availableVersions = $this->moduleVersions($module)) {
+ $this->consoleIO?->output(<<$module not found in pip.
+
+Read more about https://pypi.org/search
+EOT
+ );
+ throw new CommandFailedException('Failed.');
+ }
+ return $availableVersions;
+ }
+
+ /**
+ * pip安装模块
+ *
+ * @param array $modules
+ * @return void
+ */
+ private function pipModulesInstall(array $modules): void
+ {
+ // 生成 requirements.txt 且安装
+ $installModulesContent = '';
+ foreach ($modules as $module => $version) {
+ $installModulesContent .= "$module==$version\n";
+ }
+ System::putFileContent($requirementsFile = System::getcwd() . '/requirements.txt', $installModulesContent, cache: false);
+ $this->consoleIO->subOutput(<<config->get('config.pip-index-url')) {
+ $this->process->executePip("config set global.index-url $pipGlobalIndex");
+ }
+ if ($this->process->executePip("install -r $requirementsFile", subOutput: true) !== 0) {
+ throw new CommandFailedException('Failed.');
+ }
+ }
+}
diff --git a/tools/src/Phpy/Installers/PhpyInstaller.php b/tools/src/Phpy/Installers/PhpyInstaller.php
new file mode 100644
index 0000000..76f0e35
--- /dev/null
+++ b/tools/src/Phpy/Installers/PhpyInstaller.php
@@ -0,0 +1,125 @@
+config = $config;
+ $this->consoleIO = $consoleIO;
+ $this->process = $consoleIO?->getExtra('process') ?: new Process($consoleIO);
+ if (!$config->get('phpy')) {
+ $this->skipInfo = 'PHPy not configured. Skip install.';
+ return;
+ }
+ if (extension_loaded('phpy')){
+ $this->skipInfo = "PHPy already installed.";
+ return;
+ }
+ }
+
+ /** @inheritdoc */
+ public function install(): void
+ {
+ if ($this->skipInfo) {
+ $this->consoleIO?->comment($this->skipInfo);
+ return;
+ }
+ $this->consoleIO?->output('PHPy Installing/Upgrading ...');
+ $url = $this->config->get('phpy.source-url');
+ $version = $this->config->get('phpy.install-version', 'latest');
+ $cacheDir = $this->config->get('config.cache-dir');
+ if (str_starts_with($cacheDir, '~')) {
+ $cacheDir = str_replace('~', getenv('HOME'), $cacheDir);
+ }
+ $versionOpt = ($version === 'latest') ? '' : "--branch $version";
+ // 下载源码
+ $sourceDir = "$cacheDir/phpy-$version";
+ if (!file_exists($sourceDir)) {
+ $this->consoleIO?->output('PHPy-source Downloading ...');
+ if ($this->process->execute(
+ "git clone --depth 1 $versionOpt $url $sourceDir", subOutput: true
+ ) !== 0) {
+ throw new CommandFailedException('Error downloading PHPy-source.');
+ }
+ $this->consoleIO?->output('PHPy-source Downloaded.');
+ }
+ // 编译安装
+ $pythonConfigDir = System::pythonConfig();
+ $phpyInstallConfigure = $this->config->get('phpy.install-configure') ?: [
+ "--with-python-config=$pythonConfigDir"
+ ];
+ $phpyInstallConfigure = implode(' ', $phpyInstallConfigure);
+ $phpIniPath = $this->config->get('phpy.ini-path');
+ $iniCmd = $phpIniPath ? "&& echo 'extension=phpy.so' > $phpIniPath" : '';
+ if (
+ $this->process->execute(
+ "cd $sourceDir && phpize && ./configure $phpyInstallConfigure && make clean && make && make install $iniCmd",
+ subOutput: true
+ ) !== 0
+ ) {
+ throw new CommandFailedException('Error building and installing PHPy extension.');
+ }
+ }
+
+ /** @inheritdoc */
+ public function uninstall(): void
+ {
+ $this->consoleIO?->output('PHPy Uninstalling ...');
+ $phpIniPath = $this->config->get('phpy.ini-path');
+ if (file_exists($phpIniPath)) {
+ $this->process->execute("rm $phpIniPath", subOutput: true);
+ }
+ }
+
+ /** @inheritdoc */
+ public function upgrade(): void
+ {
+ $this->skipInfo = null;
+ $this->install();
+ }
+
+ /** @inheritdoc */
+ public function clearCache(): void
+ {
+ $cacheDir = $this->config->get('config.cache-dir');
+ if (str_starts_with($cacheDir, '~')) {
+ $cacheDir = str_replace('~', getenv('HOME'), $cacheDir);
+ }
+ if ($this->process->execute(
+ "rm -rf $cacheDir/phpy-*", subOutput: true
+ ) !== 0) {
+ throw new CommandFailedException('Error clearing PHPy cache.');
+ }
+ }
+}
diff --git a/tools/src/Phpy/Installers/PythonInstaller.php b/tools/src/Phpy/Installers/PythonInstaller.php
new file mode 100644
index 0000000..182d070
--- /dev/null
+++ b/tools/src/Phpy/Installers/PythonInstaller.php
@@ -0,0 +1,245 @@
+config = $config;
+ $this->consoleIO = $consoleIO;
+ $this->process = $consoleIO?->getExtra('process') ?: new Process($consoleIO);
+ if (!$config->get('python')) {
+ $this->skipInfo = 'Python not configured. Skip install.';
+ return;
+ }
+ if ($pythonInstallPath = $config->get('python.install-dir', '/usr') . '/bin/python3'){
+ if (file_exists($pythonInstallPath)) {
+ $this->skipInfo = "Python already installed at $pythonInstallPath.";
+ return;
+ }
+ }
+ }
+
+ /** @inheritdoc */
+ public function install(): void
+ {
+ $this->consoleIO?->output('Python Installing ...');
+ $version = $this->config->get('python.install-version', 'v3.12.2');
+ if (!Version::validateVersion($version)) {
+ throw new PhpyException("Invalid Python version: $version");
+ }
+ $cacheDir = $this->config->get('config.cache-dir');
+ if (str_starts_with($cacheDir, '~')) {
+ $cacheDir = str_replace('~', getenv('HOME'), $cacheDir);
+ }
+ $installDir = $this->config->get('python.install-dir');
+
+ $cwd = System::getcwd();
+ if (!$this->skipInfo) {
+ $this->consoleIO?->output('CPython-source make install ...');
+ $url = $this->config->get('python.source-url');
+ // 下载源码
+ $sourceDir = "$cacheDir/python-$version";
+ if (!file_exists($sourceDir)) {
+ $this->consoleIO?->output('CPython-source Downloading ...');
+ if ($this->process->execute(
+ "git clone --depth 1 --branch $version $url $sourceDir", subOutput: true
+ ) !== 0) {
+ throw new CommandFailedException('Error downloading Python.');
+ }
+ $this->consoleIO?->output('CPython-source Downloaded.');
+ }
+
+ // 编译安装
+ $pythonInstallConfigure = $this->config->get('python.install-configure', []);
+ $pythonInstallConfigure[] = "--prefix=$installDir";
+ $pythonInstallConfigure = implode(' ', $pythonInstallConfigure);
+ $this->consoleIO?->output("Building and installing Python-$version...");
+ if (
+ $this->process->execute(
+ "cd $sourceDir && ./configure $pythonInstallConfigure && make clean && make -j$(nproc) && make install",
+ subOutput: true
+ ) !== 0
+ ) {
+ throw new CommandFailedException("Error building and installing Python-$version.");
+ }
+ // 设置环境
+ $this->process->execute(
+ "echo '$installDir/bin/python3' > $cwd/python.command && echo '$installDir/bin/pip3' > $cwd/pip.command && echo '$installDir/bin/python3-config' > $cwd/python-config.command",
+ subOutput: true
+ );
+ } else {
+ $this->consoleIO?->comment($this->skipInfo);
+ }
+
+ // 虚拟环境
+ if (!file_exists($venvPath = "$cwd/py-vendor")) {
+ $this->consoleIO?->output('Creating virtual environment...');
+ // 安装虚拟
+ $this->process->executePython("-m venv $venvPath", subOutput: true);
+ $this->process->execute("source $venvPath/bin/activate", subOutput: true);
+ // 软链python-config
+ $this->consoleIO?->output('Creating python-config link...');
+ $pythonConfigPath = "$venvPath/bin/python3-config";
+ $this->process->execute("ln -s $installDir/bin/python3-config $pythonConfigPath", subOutput: true);
+ // 设置环境
+ $this->process->execute(
+ "echo '$venvPath/bin/python3' > $cwd/python.command && echo '$venvPath/bin/pip3' > $cwd/pip.command && echo '$pythonConfigPath' > $cwd/python-config.command",
+ subOutput: true
+ );
+ $this->consoleIO?->output('Creating python-include link...');
+ $this->process->executePythonConfig('--includes', $output);
+ preg_match('/-I(.+?)(\s|$)/', $output[0], $matches);
+ // 软链python-include
+ $this->process->execute("rm -rf $venvPath/include/python*", subOutput: true);
+ $this->process->execute("ln -s $matches[1]/ $venvPath/include", subOutput: true);
+ // 升级pip
+ $this->consoleIO?->output('Upgrading pip...');
+ $this->process->executePip('install --upgrade pip', subOutput: true);
+ }
+ }
+
+ /** @inheritdoc */
+ public function uninstall(): void
+ {
+ $this->consoleIO?->output('Python Uninstalling ...');
+ $version = $this->config->get('python.version', 'latest');
+ $cacheDir = $this->config->get('config.cache-dir');
+ if (str_starts_with($cacheDir, '~')) {
+ $cacheDir = str_replace('~', getenv('HOME'), $cacheDir);
+ }
+ // 卸载源码
+ $sourceDir = "$cacheDir/python-$version";
+ if (file_exists($sourceDir)) {
+ $this->process->execute("rm -rf $sourceDir", subOutput: true);
+ }
+ // 卸载虚拟环境
+ $cwd = System::getcwd();
+ if (file_exists($venvPath = "$cwd/py-vendor")) {
+ $this->process->execute("rm -rf $venvPath", subOutput: true);
+ }
+ }
+
+ /** @inheritdoc */
+ public function upgrade(): void
+ {
+ $this->consoleIO?->output('Python Upgrading ...');
+ $version = $this->config->get('python.version', 'latest');
+ $cacheDir = $this->config->get('config.cache-dir');
+ if (str_starts_with($cacheDir, '~')) {
+ $cacheDir = str_replace('~', getenv('HOME'), $cacheDir);
+ }
+ $installDir = $this->config->get('python.install-dir');
+
+ if (!$this->skipInfo) {
+ $url = $this->config->get('python.source-url');
+ $versionOpt = ($version === 'latest') ? '' : "--branch $version";
+ // 下载源码
+ $sourceDir = "$cacheDir/python-$version";
+ if (file_exists($sourceDir)) {
+ $this->process->execute("rm -rf $sourceDir", subOutput: true);
+ }
+ $this->consoleIO?->output('CPython-source Downloading ...');
+ if ($this->process->execute(
+ "git clone --depth 1 $versionOpt $url $sourceDir", subOutput: true
+ ) !== 0) {
+ throw new CommandFailedException('Error downloading Python.');
+ }
+ $this->consoleIO?->output('CPython-source Downloaded.');
+
+ // 编译安装
+ $pythonInstallConfigure = $this->config->get('python.install-configure', []);
+ $pythonInstallConfigure[] = "--prefix=$installDir";
+ $pythonInstallConfigure = implode(' ', $pythonInstallConfigure);
+ $this->consoleIO?->output("Building and installing Python-$version...");
+ if (
+ $this->process->execute(
+ "cd $sourceDir && ./configure $pythonInstallConfigure && make clean && make && make install",
+ subOutput: true
+ ) !== 0
+ ) {
+ throw new CommandFailedException("Error building and installing Python-$version.");
+ }
+ }
+
+ $python = $installDir . '/bin/python';
+ $pip = $installDir . '/bin/pip';
+ $cwd = System::getcwd();
+ // 虚拟环境
+ if (file_exists($venvPath = "$cwd/py-vendor/.venv")) {
+ $this->process->execute("rm -rf $venvPath", subOutput: true);
+ }
+ // 安装虚拟
+ $this->process->execute("$python -m venv $venvPath", subOutput: true);
+ $this->process->execute("source $venvPath/bin/activate", subOutput: true);
+ // 软链python-config
+ $pythonConfigPath = "$venvPath/bin/python-config";
+ $this->process->execute("ln -s $installDir/bin/python-config $pythonConfigPath", subOutput: true);
+ // 软链python-include
+ $this->process->execute("rm -rf $venvPath/include/python", subOutput: true);
+ $this->process->execute("ln -s $installDir/include/python $venvPath/include", subOutput: true);
+ // 设置环境
+ $this->process->execute(
+ "echo '$python' > $cwd/python.command && echo '$pip' > $cwd/pip.command && echo '' > $cwd/python-config.command",
+ subOutput: true
+ );
+ }
+
+ /** @inheritdoc */
+ public function clearCache(): void
+ {
+ $this->consoleIO?->output('Python Cache Clearing ...');
+ $cacheDir = $this->config->get('config.cache-dir');
+ if (str_starts_with($cacheDir, '~')) {
+ $cacheDir = str_replace('~', getenv('HOME'), $cacheDir);
+ }
+ if ($this->process->execute(
+ "rm -rf $cacheDir/python-*", subOutput: true
+ ) !== 0) {
+ throw new CommandFailedException('Error clearing Python cache.');
+ }
+ }
+}