From 64afbc9656faacfe405005e4aaa4f2bb152699f0 Mon Sep 17 00:00:00 2001 From: tianfenghan Date: Mon, 10 Aug 2026 15:43:57 +0800 Subject: [PATCH] feat(python): generate builtin helpers and convert native calls - Create python.php with Python builtin symbols for IDE completion - Scan builtins module when generating any module helper - Convert Python print() calls to PHP echo statements when behavior matches - Convert sys.exit() calls to PHP exit() when using integer codes - Fix attribute access on module aliases to use -> syntax after first member - Add proper dereferencing for f-string expressions - Update documentation to reflect builtin helper generation - Add tests for native statement conversion and attribute handling --- docs/python/tools.md | 14 +- examples/python/version.php | 17 +++ examples/python/version.py | 8 ++ .../PythonToTypePhpConverterTest.php | 53 ++++++++ .../PythonTools/PythonToolsCommandTest.php | 10 ++ src/PythonTools/Command.php | 15 +- .../Converter/PythonToTypePhpConverter.php | 128 +++++++++++++++++- 7 files changed, 238 insertions(+), 7 deletions(-) create mode 100644 examples/python/version.php create mode 100644 examples/python/version.py diff --git a/docs/python/tools.md b/docs/python/tools.md index 9317a4d1..9cfc9066 100644 --- a/docs/python/tools.md +++ b/docs/python/tools.md @@ -20,10 +20,15 @@ module attribute。PHPy 扩展以及目标 Python module 必须安装在执行 ` ```text ide-helper/python/math.php ide-helper/python/numpy/linalg.php +ide-helper/python.php ide-helper/PyObject.php ``` -首次生成 module helper 时,会同时生成公共的 `PyObject.php`。它包含 `PyObject` 的动态访问、调用、 +每次生成 module helper 时,会同时扫描 Python `builtins` 并生成根命名空间文件 +`python.php`,为 `python\tuple()`、`python\len()` 等内置符号提供 IDE 补全。该文件会 +随当前 Python 环境重新生成。 + +首次生成 module helper 时,还会生成公共的 `PyObject.php`。它包含 `PyObject` 的动态访问、调用、 数组访问、迭代以及 `toArray()`、`toValue()` 等方法提示,供所有 Python module helper 共享。若该文件 已经存在,生成器会保留原文件,不进行覆盖。 @@ -54,6 +59,9 @@ Python class 的构造函数会显式调用 `parent::__construct()`。Python 对 PHP function/class 名称大小写不敏感,而 Python 名称大小写敏感;PHP 保留字也不能声明为普通 stub symbol。生成器会以注释报告无法用合法 PHP 声明表达的符号,不会擅自重命名 Python API。 +`python\print()` 的调用语法合法,但 PHP 禁止声明名为 `print` 的函数,因此单纯的 +PHP helper 文件无法为它提供无语法错误的符号声明。`list`、`int`、`float` 等 PHP +保留字存在同样的限制。 ## Python 转 TypePHP @@ -80,6 +88,10 @@ function main(): void 当前支持普通 import、函数、赋值、调用、容器字面量、基础运算、单项比较、if/while/for、 lambda 和基础 f-string。module 顶层变量会转换为 PHP global,以保持函数读取 module 变量的能力。 +当语义可以严格保持时,转换器会直接使用 PHP 原生语法:无参数或可安全转换的 +`print()` 生成带换行的 `echo`,`sys.exit()` 和整数字面量退出码生成 `exit`。具有 +`sep`、`end`、`file`、`flush` 参数的 `print()`,以及字符串或对象形式的 `sys.exit()` +与 PHP 行为不完全一致,仍保留为 Python 调用。 转换器遵循“不能可靠保持语义就拒绝”的原则。class、async、generator、try/with、decorator、 destructuring assignment、chained comparison、嵌套函数以及 loop-else 等尚未完成的语法会抛出带 diff --git a/examples/python/version.php b/examples/python/version.php new file mode 100644 index 00000000..69197a26 --- /dev/null +++ b/examples/python/version.php @@ -0,0 +1,17 @@ +major->toString() . '.' . + sys\version_info->minor->toString() . '.' . + sys\version_info->micro->toString(), "\n"; + } +} diff --git a/examples/python/version.py b/examples/python/version.py new file mode 100644 index 00000000..23aac773 --- /dev/null +++ b/examples/python/version.py @@ -0,0 +1,8 @@ +import sys + +if sys.version_info < (3, 8): + print("此脚本需要 Python 3.8 或更高版本") + sys.exit(1) +else: + print(f"当前 Python 版本: {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}") + diff --git a/phpunit/src/PythonTools/PythonToTypePhpConverterTest.php b/phpunit/src/PythonTools/PythonToTypePhpConverterTest.php index f4a2dded..3fddb5ec 100644 --- a/phpunit/src/PythonTools/PythonToTypePhpConverterTest.php +++ b/phpunit/src/PythonTools/PythonToTypePhpConverterTest.php @@ -73,4 +73,57 @@ PYTHON, 'globals.py'); self::assertStringContainsString('function scale($value)' . "\n{\n" . ' global $factor;', $php); self::assertStringContainsString("function main(): void\n{\n" . ' global $factor;', $php); } + + public function testOnlyTheFirstAttributeAfterAModuleAliasIsAModuleMember(): void + { + $php = (new PythonToTypePhpConverter())->convertSource(<<<'PYTHON' +import sys + +print(sys.version_info.major) +sys.stdout.write("hello") +print(f"{sys.version_info.minor}") +PYTHON, 'module-attribute.py'); + + self::assertStringContainsString( + "echo sys\\version_info->major, \"\\n\";", + $php, + ); + self::assertStringContainsString( + "sys\\stdout->write('hello');", + $php, + ); + self::assertStringContainsString( + 'echo sys\\version_info->minor->toString(), "\\n";', + $php, + ); + self::assertStringNotContainsString('sys\\version_info\\major', $php); + self::assertStringNotContainsString('sys\\stdout\\write', $php); + } + + public function testLowersPrintAndSysExitOnlyWhenPhpHasTheSameBehavior(): void + { + $php = (new PythonToTypePhpConverter())->convertSource(<<<'PYTHON' +import sys + +print() +print("hello") +print(f"version: {sys.version_info.major}") +print(True) +print("same line", end="") +sys.exit() +sys.exit(2) +sys.exit("failure") +PYTHON, 'native-statements.py'); + + self::assertStringContainsString('echo "\\n";', $php); + self::assertStringContainsString('echo \'hello\', "\\n";', $php); + self::assertStringContainsString( + 'echo \'version: \' . sys\\version_info->major->toString(), "\\n";', + $php, + ); + self::assertStringContainsString('python\\print(true);', $php); + self::assertStringContainsString("python\\print('same line', end: '');", $php); + self::assertStringContainsString("exit;\n exit(2);", $php); + self::assertStringContainsString("sys\\exit('failure');", $php); + } } diff --git a/phpunit/src/PythonTools/PythonToolsCommandTest.php b/phpunit/src/PythonTools/PythonToolsCommandTest.php index 9e4ea4e9..56519d9c 100644 --- a/phpunit/src/PythonTools/PythonToolsCommandTest.php +++ b/phpunit/src/PythonTools/PythonToolsCommandTest.php @@ -24,10 +24,20 @@ final class PythonToolsCommandTest extends TestCase self::assertSame(0, $status); self::assertFileExists($output . '/python/math.php'); + self::assertFileExists($output . '/python.php'); + $builtins = file_get_contents($output . '/python.php'); + self::assertIsString($builtins); + self::assertStringContainsString('namespace python;', $builtins); + self::assertStringContainsString('function tuple(', $builtins); + self::assertStringContainsString( + '// Omitted Python callable not representable as a PHP function: print', + $builtins, + ); self::assertSame('keep-me', file_get_contents($output . '/PyObject.php')); } finally { @unlink($output . '/python/math.php'); @rmdir($output . '/python'); + @unlink($output . '/python.php'); @unlink($output . '/PyObject.php'); @rmdir($output); @rmdir($root); diff --git a/src/PythonTools/Command.php b/src/PythonTools/Command.php index 2f338be2..40bbc580 100644 --- a/src/PythonTools/Command.php +++ b/src/PythonTools/Command.php @@ -33,8 +33,9 @@ final class Command throw new RuntimeException('Unable to determine the working directory'); } [$module, $outputDirectory] = self::helperArguments($argv, $helperIndex, $root); - $metadata = (new PhpyModuleScanner())->scan($module); - $code = (new HelperRenderer())->render($metadata); + $scanner = new PhpyModuleScanner(); + $renderer = new HelperRenderer(); + $metadata = $scanner->scan($module); $relative = str_replace('.', DIRECTORY_SEPARATOR, $module) . '.php'; $file = $outputDirectory . DIRECTORY_SEPARATOR . 'python' . DIRECTORY_SEPARATOR . $relative; $pyObjectFile = $outputDirectory . DIRECTORY_SEPARATOR . 'PyObject.php'; @@ -42,8 +43,14 @@ final class Command self::writeFile($pyObjectFile, (new PyObjectHelperRenderer())->render()); fwrite(STDOUT, "Generated PyObject IDE helper: {$pyObjectFile}" . PHP_EOL); } - self::writeFile($file, $code); - fwrite(STDOUT, "Generated Python IDE helper: {$file}" . PHP_EOL); + $builtins = $module === 'builtins' ? $metadata : $scanner->scan('builtins'); + $builtinsFile = $outputDirectory . DIRECTORY_SEPARATOR . 'python.php'; + self::writeFile($builtinsFile, $renderer->render($builtins)); + fwrite(STDOUT, "Generated Python builtins IDE helper: {$builtinsFile}" . PHP_EOL); + if ($module !== 'builtins') { + self::writeFile($file, $renderer->render($metadata)); + fwrite(STDOUT, "Generated Python IDE helper: {$file}" . PHP_EOL); + } return 0; } diff --git a/src/PythonTools/Converter/PythonToTypePhpConverter.php b/src/PythonTools/Converter/PythonToTypePhpConverter.php index 94380481..e77d0844 100644 --- a/src/PythonTools/Converter/PythonToTypePhpConverter.php +++ b/src/PythonTools/Converter/PythonToTypePhpConverter.php @@ -232,9 +232,115 @@ final class PythonToTypePhpConverter if (($value['_type'] ?? '') === 'Constant' && is_string($value['value'] ?? null)) { return [$this->line('/** ' . $this->safeComment($value['value']) . ' */')]; } + if (($value['_type'] ?? '') === 'Call') { + $native = $this->nativeCallStatement($value); + if ($native !== null) { + return [$this->line($native)]; + } + } return [$this->line($this->expression($value) . ';')]; } + /** @param array $node */ + private function nativeCallStatement(array $node): ?string + { + if ($this->isBuiltinPrintCall($node)) { + $arguments = $node['args'] ?? []; + if (($node['keywords'] ?? []) !== []) { + return null; + } + foreach ($arguments as $argument) { + if (!$this->isEchoCompatiblePythonValue($argument)) { + return null; + } + } + if ($arguments === []) { + return 'echo "\\n";'; + } + $parts = []; + foreach ($arguments as $index => $argument) { + if ($index !== 0) { + $parts[] = "' '"; + } + $parts[] = $this->expression($argument); + } + $parts[] = '"\\n"'; + return 'echo ' . implode(', ', $parts) . ';'; + } + + if ($this->isSysExitCall($node) && ($node['keywords'] ?? []) === []) { + $arguments = $node['args'] ?? []; + if ($arguments === []) { + return 'exit;'; + } + if (count($arguments) === 1 && $this->isIntegerLiteral($arguments[0])) { + return 'exit(' . $this->expression($arguments[0]) . ');'; + } + } + return null; + } + + /** @param array $node */ + private function isBuiltinPrintCall(array $node): bool + { + $function = $node['func'] ?? []; + return ($function['_type'] ?? '') === 'Name' + && ($function['id'] ?? '') === 'print' + && !isset($this->definedFunctions['print']) + && !isset($this->importedSymbols['print']) + && !isset($this->moduleGlobals['print']); + } + + /** @param array $node */ + private function isSysExitCall(array $node): bool + { + $function = $node['func'] ?? []; + if (($function['_type'] ?? '') === 'Name') { + $symbol = $this->importedSymbols[(string) ($function['id'] ?? '')] ?? null; + return $symbol !== null && $symbol['module'] === 'sys' && $symbol['member'] === 'exit'; + } + if (($function['_type'] ?? '') !== 'Attribute' || ($function['attr'] ?? '') !== 'exit') { + return false; + } + $owner = $function['value'] ?? []; + if (($owner['_type'] ?? '') !== 'Name') { + return false; + } + return ($this->moduleAliases[(string) ($owner['id'] ?? '')] ?? null) === 'sys'; + } + + /** @param array $node */ + private function isEchoCompatiblePythonValue(array $node): bool + { + $type = $node['_type'] ?? ''; + if ($type === 'Constant') { + $value = $node['value'] ?? null; + return is_string($value) || is_int($value); + } + if ($type === 'Attribute') { + $cursor = $node; + while (($cursor['_type'] ?? '') === 'Attribute') { + $cursor = $cursor['value']; + } + return $this->attributeStartsWithModuleAlias($node) + || (($cursor['_type'] ?? '') === 'Name' + && isset($this->importedSymbols[(string) ($cursor['id'] ?? '')])); + } + return in_array($type, ['JoinedStr', 'List', 'Tuple', 'Set', 'Dict'], true); + } + + /** @param array $node */ + private function isIntegerLiteral(array $node): bool + { + if (($node['_type'] ?? '') === 'Constant') { + return is_int($node['value'] ?? null); + } + return ($node['_type'] ?? '') === 'UnaryOp' + && in_array($node['op']['_type'] ?? '', ['USub', 'UAdd'], true) + && ($node['operand']['_type'] ?? '') === 'Constant' + && is_int($node['operand']['value'] ?? null); + } + /** @param array $node @return list */ private function ifStatement(array $node, bool $elseif = false): array { @@ -379,7 +485,16 @@ final class PythonToTypePhpConverter $cursor = $cursor['value']; } if (($cursor['_type'] ?? '') === 'Name' && isset($this->moduleAliases[$cursor['id']])) { - return $cursor['id'] . '\\' . implode('\\', $parts); + // A Python module is represented by a TypePHP namespace, but only + // the first attribute is a member of that module. Any remaining + // attributes belong to the PyObject returned by that member. + // For example, sys.version_info.major becomes + // sys\version_info->major, not sys\version_info\major. + $result = $cursor['id'] . '\\' . array_shift($parts); + foreach ($parts as $part) { + $result .= '->' . $part; + } + return $result; } $result = $this->expression($cursor); foreach ($parts as $part) { @@ -540,7 +655,16 @@ final class PythonToTypePhpConverter if (($value['format_spec'] ?? null) !== null || ($value['conversion'] ?? -1) !== -1) { $this->unsupported($value, 'formatted f-string conversions are not supported yet'); } - $parts[] = '(' . $this->expression($value['value']) . ')->toString()'; + $expression = $this->expression($value['value']); + // PHP permits direct dereferencing of these expressions. Keep + // parentheses for operators and other precedence-sensitive + // expressions, whose result must be converted as a whole. + if (!in_array($value['value']['_type'] ?? '', [ + 'Name', 'Attribute', 'Call', 'Subscript', 'List', 'Tuple', 'Set', 'Dict', + ], true)) { + $expression = '(' . $expression . ')'; + } + $parts[] = $expression . '->toString()'; } else { $parts[] = $this->expression($value); }