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
pull/48/head
韩天峰 2 weeks ago
parent 6c97b4f301
commit 64afbc9656
  1. 14
      docs/python/tools.md
  2. 17
      examples/python/version.php
  3. 8
      examples/python/version.py
  4. 53
      phpunit/src/PythonTools/PythonToTypePhpConverterTest.php
  5. 10
      phpunit/src/PythonTools/PythonToolsCommandTest.php
  6. 15
      src/PythonTools/Command.php
  7. 128
      src/PythonTools/Converter/PythonToTypePhpConverter.php

@ -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 等尚未完成的语法会抛出带

@ -0,0 +1,17 @@
<?php
/** @generated from examples/python/version.py */
use python\sys;
function main(): void
{
if (sys\version_info < python\tuple([3, 8])) {
echo '此脚本需要 Python 3.8 或更高版本', "\n";
exit(1);
} else {
echo '当前 Python 版本: ' .
sys\version_info->major->toString() . '.' .
sys\version_info->minor->toString() . '.' .
sys\version_info->micro->toString(), "\n";
}
}

@ -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}")

@ -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);
}
}

@ -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);

@ -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;
}

@ -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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $node @return list<string> */
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);
}

Loading…
Cancel
Save