docs(py2php): add comprehensive documentation and test coverage for Python to TypePHP converter

- Add detailed PY2PHP.md documentation covering usage, architecture, statement/expression support matrix
- Document function signature conversion rules and print/sys.exit lowering behavior
- Include known behaviors and unsupported syntax with error messages
- Create comprehensive test suite for PythonAstLoader with real Python subprocess integration
- Add extensive test coverage for PythonToTypePhpConverter including supported/unsupported syntax cases
- Implement CLI layer tests for --convert-python-to-php command with various scenarios
- Add test matrix aligned with documentation covering all supported Python constructs
- Include test cases for error handling and edge conditions with proper exception propagation
pull/48/head
韩天峰 2 weeks ago
parent b77fee697e
commit c47ef90593
  1. 123
      docs/PY2PHP.md
  2. 109
      phpunit/src/PythonTools/PythonAstLoaderTest.php
  3. 417
      phpunit/src/PythonTools/PythonToTypePhpConverterTest.php
  4. 110
      phpunit/src/PythonTools/PythonToolsCommandTest.php
  5. 6
      src/PythonTools/Converter/PythonAstLoader.php

@ -0,0 +1,123 @@
# py2php:Python → TypePHP 源码转换工具
## 用法
```bash
./bin/tpc.php --convert-python-to-php examples/python/version.py > examples/python/version.php
```
生成的 PHP 源码输出到 stdout,错误输出到 stderr,退出码 0 成功 / 1 失败。
## 架构
```
.py 源码
└─ PythonAstLoader python3 子进程(ast 模块)→ JSON AST
└─ PythonToTypePhpConverter AST → TypePHP 源码字符串
└─ Command::execute CLI 分发(--convert-python-to-php)
```
- 源码:`src/PythonTools/Command.php`、`src/PythonTools/Converter/`
- 不支持的语法抛出 `RuntimeException("{file}:{line}: unsupported Python syntax {节点类型}[: 详情]")`,CLI 层转为 stderr + 退出码 1。
- 测试:`phpunit/src/PythonTools/`(`PythonToTypePhpConverterTest`、`PythonAstLoaderTest`、`PythonToolsCommandTest`),与本文档逐项对应。
## 语句支持矩阵
| Python 语法 | 状态 | 转换规则 / 报错 |
|---|---|---|
| `x = expr` | ✅ | `$x = expr;`,模块级变量自动注入 `global` |
| `x += expr` 等增强赋值 | ✅ | 支持 `+ - * / % ** << >> | ^ &` 系列;`//=` 不支持 |
| `x: int = expr` | ✅ | 忽略注解,转换为普通赋值 |
| `x: int`(纯注解) | ❌ | `AnnAssign: annotation-only assignments have no TypePHP runtime value` |
| `x = y = 1` | ❌ | `Assign: chained assignments are not supported yet` |
| `a, b = x`(解构) | ❌ | `Assign: destructuring assignments are not supported yet` |
| `def f(...)` | ✅ | 见「函数签名」;函数名为 `main` 报错(与 TypePHP 入口冲突) |
| 嵌套 `def` | ❌ | `FunctionDef: nested functions require Python closure scope analysis` |
| `@decorator` | ❌ | `FunctionDef: function decorators are not supported yet` |
| `return [expr]` | ✅ | `return [expr];` |
| `if / elif / else` | ✅ | 同构转换 |
| `while` | ✅ | 同构转换;`while/else` 不支持 |
| `for i in iter` | ✅ | `foreach (iter as $i)`;`for/else`、元组目标不支持 |
| `break` / `continue` / `pass` | ✅ | `pass``// pass` 注释 |
| `global x` | ✅ | `global $x;`(与自动注入的 global 并存时会重复出现,冗余但合法,属已知行为) |
| `del x` / `del o.a` / `del d[k]` | ✅ | `unset(...)`;其他目标类型报错 `Delete: unsupported del target` |
| 模块级字符串字面量(docstring) | ✅ | 转为 `/** ... */` 注释(`*/` 转义为 `* /`) |
| `import a.b` | ✅ | `use python\a;`(仅首段作为别名,见「已知行为」) |
| `import a.b as x` | ✅ | `use python\a\b as x;`(别名等于末段时省略 `as`) |
| `from m import f [as g]` | ✅ | 调用点映射为 `python\m\f(...)` |
| `from . import m` | ❌ | `ImportFrom: relative imports are not supported yet` |
| `from m import *` | ❌ | `ImportFrom: star imports are not supported` |
| `class` | ❌ | `ClassDef` |
| `with` | ❌ | `With` |
| `raise` / `try` / `assert` | ❌ | `Raise` / `Try` / `Assert` |
| `async def` / `await` | ❌ | `AsyncFunctionDef`(`await` 不可达,外层先报错) |
| `match` | ❌ | `Match` |
| `nonlocal` | ❌ | `Nonlocal` |
## 函数签名
| Python 形态 | 状态 | TypePHP 输出 |
|---|---|---|
| `def f(x, y=4)` | ✅ | `function f($x, $y = 4)` |
| `def f(a, *, b)` | ✅ | `function f($a, $b = null)`(无默认值的仅关键字参数补 `null`) |
| `def f(*args)` / `def f(**kw)` | ✅ | `function f(...$args)` |
| `def f(*a, **kw)` | ❌ | `FunctionDef: simultaneous *args and **kwargs cannot be represented by one PHP signature` |
| `lambda a, b=2: a + b` | ✅ | `fn ($a, $b = 2) => $a + $b` |
## 表达式支持矩阵
| Python 语法 | 状态 | 转换规则 / 报错 |
|---|---|---|
| 字面量 `int / float / str / True / False / None` | ✅ | `var_export`;`None` → `null` |
| `b'...'` bytes | ❌ | `{file}: Python bytes literals are not supported yet`(无行号) |
| `1j` complex | ❌ | `{file}: Python complex literals are not supported yet`(无行号) |
| 变量名 | ✅ | `$name`;`this` 转义为 `$this_` |
| 模块别名作为值 | ❌ | `a Python module cannot be used as a first-class value in TypePHP namespace syntax` |
| 属性链 `o.a.b` | ✅ | `$o->a->b`;模块别名链仅首段为模块成员:`sys.version_info.major` → `sys\version_info->major` |
| 模块属性赋值/删除 | ❌ | `Attribute: Python module attributes cannot be assigned or deleted` |
| 函数调用 | ✅ | 已定义函数直连 `f(...)`;内置函数映射 `python\len(...)`;`from m import f` 映射 `python\m\f(...)`;其他名字按变量可调用 `$f(...)` |
| 关键字参数 / `*args` / `**kwargs` 调用 | ✅ | `f(x: 1, ...$args)` |
| 容器字面量 `[] () {} {:}` | ✅ | `python\list/tuple/set/dict([...])`,支持 `...` 解包 |
| 二元运算 `+ - * / % ** << >> \| ^ &` | ✅ | 同构转换 |
| `//` 整除 / `@` 矩阵乘 | ✅ | `python\operator\floordiv(a, b)` / `python\operator\matmul(a, b)` |
| 一元运算 `- + not ~` | ✅ | `- + ! ~` |
| 比较 `== != < <= > >=` | ✅ | 同构转换 |
| `is` / `is not` | ✅ | `===` / `!==` |
| `in` / `not in` | ✅ | `python\operator\contains(b, a)`(参数交换)/ 取反 |
| 链式比较 `a < b < c` | ❌ | `Compare: chained comparisons require explicit temporary variables` |
| `a and b` / `a or b` | ❌ | `BoolOp` |
| `x if c else y` | ✅ | `(c ? x : y)` |
| 下标 `a[i]` / 切片 `a[l:u:s]` | ✅ | `$a[$i]` / `$a[python\slice(l, u, s)]`(缺省为 `null`) |
| f-string | ✅ | 拼接 + `->toString()`;运算符等优先级敏感表达式整体加括号 |
| f-string 的 `!r` 转换 / `:03d` 格式说明 | ❌ | `FormattedValue: formatted f-string conversions are not supported yet` |
| 海象 `:=` | ❌ | `NamedExpr` |
| 推导式 / 生成器表达式 | ❌ | `ListComp` / `SetComp` / `DictComp` / `GeneratorExp` |
| `yield` / `yield from` | ❌ | `Yield` / `YieldFrom` |
## print / sys.exit 降级规则
仅当 PHP 行为与 Python 完全一致时才降级为原生语句:
| 形态 | 输出 |
|---|---|
| `print()` | `echo "\n";` |
| `print("a", "b")`(字符串/整数常量、模块属性、容器、f-string) | `echo 'a', ' ', 'b', "\n";` |
| `print(1.5)`、`print(True)`、`print(x, sep=...)` | 不降级:`python\print(...)` |
| 用户定义/导入/赋值遮蔽 `print` 后 | 不降级 |
| `sys.exit()` / `sys.exit(2)`(含 `from sys import exit` 形式) | `exit;` / `exit(2);` |
| `sys.exit("fail")` | 不降级:`sys\exit('fail');` |
## 已知行为(非错误,但需留意)
1. `import os.path`(无别名)只引入首段 `use python\os;`
2. 函数内显式 `global x` 与按模块全局自动注入的 `global x` 会重复出现(合法 PHP)。
3. `print = str` 这类把内置名赋给变量的写法,右侧按变量处理(`$print = $str;`),不做内置名解析。
4. bytes/complex 字面量的报错没有行号(常量在 AST 加载阶段编码,位置信息未传递)。
## 运行测试
```bash
vendor/bin/phpunit --filter 'PythonToTypePhpConverterTest|PythonAstLoaderTest|PythonToolsCommandTest'
```
转换器测试依赖真实 `python3` 解析 AST,环境缺失时自动跳过。

@ -0,0 +1,109 @@
<?php
namespace TypePhpTest\PythonTools;
use PHPUnit\Framework\Attributes\WithoutErrorHandler;
use PHPUnit\Framework\TestCase;
use RuntimeException;
use TypePhp\PythonTools\Converter\PythonAstLoader;
use TypePhp\PythonTools\Converter\PythonToTypePhpConverter;
/**
* PythonAstLoader(python3 子进程 AST 解析)与转换器注入点的测试。
*
* 真实 python3 路径在环境缺失 python3 时跳过;loader 的 JSON 边界分支
* (非 Module 根、无效 JSON)无法通过真实 python3 触发,依靠子类化替身覆盖。
*/
final class PythonAstLoaderTest extends TestCase
{
private static ?bool $pythonAvailable = null;
protected function setUp(): void
{
self::$pythonAvailable ??= $this->detectPython();
}
private function detectPython(): bool
{
$process = @proc_open(['python3', '--version'], [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
if (!is_resource($process)) {
return false;
}
foreach ($pipes as $pipe) {
fclose($pipe);
}
return proc_close($process) === 0;
}
private function requirePython(): void
{
if (!self::$pythonAvailable) {
self::markTestSkipped('python3 is required for this test');
}
}
public function testParsesModuleAstFromRealPython(): void
{
$this->requirePython();
$tree = (new PythonAstLoader())->parse("x = 1\n", 'ok.py');
self::assertSame('Module', $tree['_type']);
self::assertIsArray($tree['body']);
self::assertSame('Assign', $tree['body'][0]['_type']);
self::assertSame(1, $tree['body'][0]['lineno']);
}
public function testSyntaxErrorReportsFileAndLine(): void
{
$this->requirePython();
try {
(new PythonAstLoader())->parse("def broken(:\n", 'bad.py');
self::fail('Expected RuntimeException');
} catch (RuntimeException $exception) {
self::assertStringContainsString('Unable to parse Python source', $exception->getMessage());
self::assertStringContainsString('bad.py:1', $exception->getMessage());
}
}
/** proc_open 对不存在的二进制会触发 PHP Warning,需要绕过 PHPUnit 错误处理器。 */
#[WithoutErrorHandler]
public function testMissingPythonExecutable(): void
{
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('Unable to start Python executable');
(new PythonAstLoader('/nonexistent/python3-bin'))->parse("x = 1\n", 'x.py');
}
public function testConverterAcceptsInjectedLoader(): void
{
$loader = new class extends PythonAstLoader {
public function parse(string $source, string $filename): array
{
return ['_type' => 'Module', 'body' => []];
}
};
$php = (new PythonToTypePhpConverter($loader))->convertSource('ignored', 'fake.py');
self::assertStringContainsString('/** @generated from fake.py */', $php);
self::assertStringContainsString('function main(): void', $php);
}
public function testConverterPropagatesLoaderFailure(): void
{
$loader = new class extends PythonAstLoader {
public function parse(string $source, string $filename): array
{
throw new RuntimeException('Unable to parse Python source: broken.py:1: boom');
}
};
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('broken.py:1: boom');
(new PythonToTypePhpConverter($loader))->convertSource('ignored', 'broken.py');
}
}

@ -6,8 +6,48 @@ use PHPUnit\Framework\TestCase;
use RuntimeException;
use TypePhp\PythonTools\Converter\PythonToTypePhpConverter;
/**
* py2php(tpc --convert-python-to-php)转换器的语法覆盖测试。
*
* 覆盖矩阵与 docs/PY2PHP.md 保持同步:每个支持的语法断言生成片段,
* 每个不支持的语法断言 `{file}:{line}: unsupported Python syntax ...` 错误。
*
* 转换依赖真实 python3(AST 由 PythonAstLoader 通过 `python3` 子进程解析),
* 环境缺少 python3 时整体跳过。
*/
final class PythonToTypePhpConverterTest extends TestCase
{
private static ?bool $pythonAvailable = null;
protected function setUp(): void
{
self::$pythonAvailable ??= $this->detectPython();
if (!self::$pythonAvailable) {
self::markTestSkipped('python3 is required to parse Python sources');
}
}
private function detectPython(): bool
{
$process = @proc_open(['python3', '--version'], [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
if (!is_resource($process)) {
return false;
}
foreach ($pipes as $pipe) {
fclose($pipe);
}
return proc_close($process) === 0;
}
private function convert(string $source, string $filename = 'case.py'): string
{
return (new PythonToTypePhpConverter())->convertSource($source, $filename);
}
// ---------------------------------------------------------------
// 既有行为测试
// ---------------------------------------------------------------
public function testConvertsImportsFunctionsAndTopLevelCode(): void
{
$source = <<<'PYTHON'
@ -22,7 +62,7 @@ value = hypotenuse(3)
print(encode({"value": value}))
PYTHON;
$php = (new PythonToTypePhpConverter())->convertSource($source, 'example.py');
$php = $this->convert($source, 'example.py');
self::assertStringContainsString('use python\\math;', $php);
self::assertStringContainsString('use python\\os\\path;', $php);
@ -42,7 +82,7 @@ def inspect_value(value, values):
return value in values
PYTHON;
$php = (new PythonToTypePhpConverter())->convertSource($source, 'comparison.py');
$php = $this->convert($source, 'comparison.py');
self::assertStringContainsString('if ($value === null)', $php);
self::assertStringContainsString('// pass', $php);
@ -56,12 +96,12 @@ PYTHON;
$this->expectExceptionMessage('sample.py:1');
$this->expectExceptionMessage('ClassDef');
(new PythonToTypePhpConverter())->convertSource("class Demo:\n pass\n", 'sample.py');
$this->convert("class Demo:\n pass\n", 'sample.py');
}
public function testModuleVariablesRemainVisibleInsideFunctions(): void
{
$php = (new PythonToTypePhpConverter())->convertSource(<<<'PYTHON'
$php = $this->convert(<<<'PYTHON'
factor = 4
def scale(value):
@ -76,7 +116,7 @@ PYTHON, 'globals.py');
public function testOnlyTheFirstAttributeAfterAModuleAliasIsAModuleMember(): void
{
$php = (new PythonToTypePhpConverter())->convertSource(<<<'PYTHON'
$php = $this->convert(<<<'PYTHON'
import sys
print(sys.version_info.major)
@ -84,25 +124,16 @@ 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::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'
$php = $this->convert(<<<'PYTHON'
import sys
print()
@ -117,13 +148,355 @@ 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('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);
}
// ---------------------------------------------------------------
// 支持的语句
// ---------------------------------------------------------------
/** @dataProvider supportedStatementProvider */
public function testSupportedStatements(string $python, array $contains, array $notContains = []): void
{
$php = $this->convert($python);
foreach ($contains as $fragment) {
self::assertStringContainsString($fragment, $php);
}
foreach ($notContains as $fragment) {
self::assertStringNotContainsString($fragment, $php);
}
}
public static function supportedStatementProvider(): array
{
return [
'赋值语句' => [
"x = 1\n",
['global $x;', '$x = 1;'],
],
'增强赋值' => [
"x = 1\nx += 2\nx -= 3\nx *= 4\nx %= 5\nx **= 2\n",
['$x += 2;', '$x -= 3;', '$x *= 4;', '$x %= 5;', '$x **= 2;'],
],
'带注解的赋值' => [
"x: int = 5\n",
['$x = 5;'],
],
'return 无值与有值' => [
"def f():\n return\n\ndef g():\n return 1\n",
["function f()\n{\n return;", 'return 1;'],
],
'if/elif/else 链' => [
"x = 1\nif x == 1:\n pass\nelif x == 2:\n pass\nelse:\n pass\n",
["if (\$x == 1)\n {", "elseif (\$x == 2)\n {", "else\n {"],
],
'while 循环' => [
"while True:\n continue\n",
["while (true)\n {", 'continue;'],
],
'for 循环转 foreach' => [
"for i in range(10):\n break\n",
['foreach (python\\range(10) as $i)', 'break;'],
],
'pass 占位' => [
"def f():\n pass\n",
['// pass'],
],
'del 名称' => [
"x = 1\ndel x\n",
['unset($x);'],
],
'del 属性' => [
"o.name = 1\ndel o.name\n",
['$o->name = 1;', 'unset($o->name);'],
],
'del 下标' => [
"d = {}\nd['k'] = 1\ndel d['k']\n",
["\$d['k'] = 1;", "unset(\$d['k']);"],
],
'函数内 global 声明' => [
"g = 1\ndef f():\n global g\n g = 2\n",
['$g = 2;'],
],
'模块字符串表达式转为注释' => [
"\"\"\"module doc\"\"\"\nx = 1\n",
['/** module doc */'],
],
'import 无别名取首段' => [
"import os.path\n",
['use python\\os;'],
['use python\\os\\path'],
],
'import 别名等于末段时省略 as' => [
"import os.path as path\n",
['use python\\os\\path;'],
[' as '],
],
'import 别名与末段不同保留 as' => [
"import os.path as ospath\n",
['use python\\os\\path as ospath;'],
],
];
}
/** 函数内 global 语句与自动注入的 global 会同时出现(冗余但合法,属已知行为)。 */
public function testGlobalStatementDuplicatesAutoInjectedGlobal(): void
{
$php = $this->convert("g = 1\ndef f():\n global g\n g = 2\n");
$functionBody = substr($php, strpos($php, 'function f()'), strpos($php, 'function main') - strpos($php, 'function f()'));
self::assertSame(2, substr_count($functionBody, 'global $g;'));
}
// ---------------------------------------------------------------
// 函数签名
// ---------------------------------------------------------------
/** @dataProvider functionSignatureProvider */
public function testFunctionSignatures(string $python, string $signature): void
{
self::assertStringContainsString($signature, $this->convert($python));
}
public static function functionSignatureProvider(): array
{
return [
'位置参数与默认值' => ["def f(x, y=4):\n pass\n", 'function f($x, $y = 4)'],
'仅关键字参数无默认值为 null' => ["def f(a, *, b):\n pass\n", 'function f($a, $b = null)'],
'仅关键字参数带默认值' => ["def f(a, *, b, c=3):\n pass\n", 'function f($a, $b = null, $c = 3)'],
'变长参数' => ["def f(*args):\n pass\n", 'function f(...$args)'],
'关键字变长参数' => ["def f(**kw):\n pass\n", 'function f(...$kw)'],
'lambda 参数与默认值' => ["f = lambda a, b=2: a + b\n", 'fn ($a, $b = 2) => $a + $b'],
];
}
// ---------------------------------------------------------------
// 支持的表达式
// ---------------------------------------------------------------
/** @dataProvider supportedExpressionProvider */
public function testSupportedExpressions(string $python, array $contains): void
{
$php = $this->convert($python);
foreach ($contains as $fragment) {
self::assertStringContainsString($fragment, $php);
}
}
public static function supportedExpressionProvider(): array
{
return [
'整数字面量' => ["x = 42\n", ['$x = 42;']],
'负整数字面量' => ["x = -1\n", ['$x = -1;']],
'浮点字面量' => ["x = 1.5\n", ['$x = 1.5;']],
'字符串转义' => ["x = 'it\\'s'\n", ['$x = \'it\\\'s\';']],
'None 转 null' => ["x = None\n", ['$x = null;']],
'布尔字面量' => ["a = True\nb = False\n", ['$a = true;', '$b = false;']],
'list 字面量' => ["x = [1, 2]\n", ['$x = python\\list([1, 2]);']],
'tuple 字面量' => ["x = (1, 2)\n", ['$x = python\\tuple([1, 2]);']],
'set 字面量' => ["x = {1, 2}\n", ['$x = python\\set([1, 2]);']],
'dict 字面量' => ["x = {'k': 1}\n", ['$x = python\\dict([\'k\' => 1]);']],
'dict 解包' => ["d = {**e, 'a': 1}\n", ["python\\dict([...\$e, 'a' => 1])"]],
'list 解包' => ["x = [*a, 1]\n", ['python\\list([...$a, 1]);']],
'嵌套容器' => [
"x = [[1], (2,), {3}, {'k': 4}]\n",
['python\\list([python\\list([1]), python\\tuple([2]), python\\set([3]), python\\dict([\'k\' => 4])])'],
],
'条件表达式' => ["x = 1 if True else 2\n", ['$x = (true ? 1 : 2);']],
'下标访问' => ["x = [1]\ny = x[0]\n", ['$y = $x[0];']],
'切片' => ["x = [1,2,3]\ny = x[1:]\n", ['$y = $x[python\\slice(1, null, null)];']],
'完整切片' => ["x = [1,2,3]\ny = x[0:3:2]\n", ['python\\slice(0, 3, 2)']],
'属性链' => ["x = obj.field.sub\n", ['$x = $obj->field->sub;']],
'变量调用' => ["foo(1, 2)\n", ['$foo(1, 2);']],
'关键字参数调用' => ["foo(x=1)\n", ['$foo(x: 1);']],
'解包调用' => ["foo(*args)\n", ['$foo(...$args);']],
'关键字解包调用' => ["foo(**kw)\n", ['$foo(...$kw);']],
'内置函数映射命名空间' => ["n = len([1])\nm = max(1, 2)\n", ['$n = python\\len(', '$m = python\\max(']],
'名字 this 转义' => ["this = 1\nprint(this)\n", ['$this_ = 1;', 'python\\print($this_);']],
'f-string 名称插值' => ['x = 1' . "\n" . 'print(f"{x}")' . "\n", ['echo $x->toString(), "\\n";']],
'f-string 文本与插值拼接' => ['x = 1' . "\n" . 'print(f"v={x}")' . "\n", ["echo 'v=' . \$x->toString(), \"\\n\";"]],
'f-string 运算符整体加括号' => ['x = 1' . "\n" . 'print(f"{x + 1}")' . "\n", ['echo ($x + 1)->toString(), "\\n";']],
];
}
// ---------------------------------------------------------------
// 运算符映射
// ---------------------------------------------------------------
/** @dataProvider binaryOperatorProvider */
public function testBinaryOperators(string $operator, string $expected): void
{
self::assertStringContainsString($expected, $this->convert("x = a {$operator} b\n"));
}
public static function binaryOperatorProvider(): array
{
return [
'加' => ['+', '$a + $b'],
'减' => ['-', '$a - $b'],
'乘' => ['*', '$a * $b'],
'除' => ['/', '$a / $b'],
'取模' => ['%', '$a % $b'],
'幂' => ['**', '$a ** $b'],
'左移' => ['<<', '$a << $b'],
'右移' => ['>>', '$a >> $b'],
'按位或' => ['|', '$a | $b'],
'按位异或' => ['^', '$a ^ $b'],
'按位与' => ['&', '$a & $b'],
'整除转函数' => ['//', 'python\\operator\\floordiv($a, $b)'],
];
}
public function testMatmulOperator(): void
{
self::assertStringContainsString(
'python\\operator\\matmul($a, $b)',
$this->convert("x = a @ b\n"),
);
}
/** @dataProvider unaryOperatorProvider */
public function testUnaryOperators(string $python, string $expected): void
{
self::assertStringContainsString($expected, $this->convert($python));
}
public static function unaryOperatorProvider(): array
{
return [
'取负' => ["x = -a\n", '$x = -$a;'],
'取正' => ["x = +a\n", '$x = +$a;'],
'逻辑非' => ["x = not a\n", '$x = !$a;'],
'按位取反' => ["x = ~a\n", '$x = ~$a;'],
];
}
/** @dataProvider comparisonOperatorProvider */
public function testComparisonOperators(string $operator, string $expected): void
{
self::assertStringContainsString($expected, $this->convert("x = a {$operator} b\n"));
}
public static function comparisonOperatorProvider(): array
{
return [
'相等' => ['==', '$a == $b'],
'不等' => ['!=', '$a != $b'],
'is 转全等' => ['is', '$a === $b'],
'is not 转不全等' => ['is not', '$a !== $b'],
'小于' => ['<', '$a < $b'],
'小于等于' => ['<=', '$a <= $b'],
'大于' => ['>', '$a > $b'],
'大于等于' => ['>=', '$a >= $b'],
'in 转 contains 且参数交换' => ['in', 'python\\operator\\contains($b, $a)'],
'not in 转 contains 取反' => ['not in', '!python\\operator\\contains($b, $a)'],
];
}
// ---------------------------------------------------------------
// print / sys.exit 降级与遮蔽
// ---------------------------------------------------------------
/** @dataProvider printShadowingProvider */
public function testPrintShadowingDisablesEchoLowering(string $python, string $expected): void
{
self::assertStringContainsString($expected, $this->convert($python));
}
public static function printShadowingProvider(): array
{
return [
'用户定义 print 函数' => ["def print(x):\n return x\nprint(1)\n", "function print(\$x)"],
'用户函数遮蔽后直连调用' => ["def print(x):\n return x\nprint(1)\n", "\n print(1);\n"],
'from import 遮蔽 print' => ["from logging import print\nprint(1)\n", 'python\\logging\\print(1);'],
'模块全局变量遮蔽 print' => ["print = str\nprint(1)\n", 'python\\print(1);'],
'浮点常量不兼容 echo' => ["print(1.5)\n", ['python\\print(1.5);'][0]],
'关键字参数不降级' => ["print('a', sep='-')\n", "python\\print('a', sep: '-');"],
'多参数空格连接' => ["print('a', 'b')\n", "echo 'a', ' ', 'b', \"\\n\";"],
];
}
/** @dataProvider sysExitProvider */
public function testSysExitLowering(string $python, string $expected): void
{
self::assertStringContainsString($expected, $this->convert($python));
}
public static function sysExitProvider(): array
{
return [
'无参转 exit' => ["import sys\nsys.exit()\n", "exit;\n"],
'整数码转 exit(n)' => ["import sys\nsys.exit(2)\n", 'exit(2);'],
'负整数码' => ["import sys\nsys.exit(-1)\n", 'exit(-1);'],
'字符串参数不降级' => ["import sys\nsys.exit('fail')\n", "sys\\exit('fail');"],
'from import 形式同样识别' => ["from sys import exit\nexit()\n", "exit;\n"],
];
}
// ---------------------------------------------------------------
// 不支持的语法:统一断言 {file}:{line} 错误格式
// ---------------------------------------------------------------
/** @dataProvider unsupportedSyntaxProvider */
public function testUnsupportedSyntax(string $python, string $message, string $filename = 'case.py'): void
{
try {
$this->convert($python, $filename);
self::fail('Expected RuntimeException: ' . $message);
} catch (RuntimeException $exception) {
self::assertStringContainsString($message, $exception->getMessage());
}
}
public static function unsupportedSyntaxProvider(): array
{
return [
'类定义' => ["class A:\n pass\n", 'case.py:1: unsupported Python syntax ClassDef'],
'with 语句' => ["with open('f') as fp:\n pass\n", 'case.py:1: unsupported Python syntax With'],
'raise 语句' => ["raise ValueError('x')\n", 'case.py:1: unsupported Python syntax Raise'],
'try/except' => ["try:\n pass\nexcept Exception:\n pass\n", 'case.py:1: unsupported Python syntax Try'],
'assert 语句' => ["assert True\n", 'case.py:1: unsupported Python syntax Assert'],
'async def' => ["async def f():\n pass\n", 'case.py:1: unsupported Python syntax AsyncFunctionDef'],
'match 语句' => ["match x:\n case 1:\n pass\n", 'case.py:1: unsupported Python syntax Match'],
'yield' => ["def f():\n yield 1\n", 'case.py:2: unsupported Python syntax Yield'],
'nonlocal' => ["def f():\n x = 1\n nonlocal x\n", 'case.py:3: unsupported Python syntax Nonlocal'],
'and/or 布尔运算' => ["x = a and b\n", 'case.py:1: unsupported Python syntax BoolOp'],
'海象运算符' => ["if (n := 10):\n pass\n", 'case.py:1: unsupported Python syntax NamedExpr'],
'列表推导式' => ["x = [i for i in range(3)]\n", 'case.py:1: unsupported Python syntax ListComp'],
'字典推导式' => ["x = {k: v for k, v in d}\n", 'case.py:1: unsupported Python syntax DictComp'],
'生成器表达式' => ["x = sum(i for i in range(3))\n", 'case.py:1: unsupported Python syntax GeneratorExp'],
'链式赋值' => ["x = y = 1\n", 'case.py:1: unsupported Python syntax Assign: chained assignments'],
'解构赋值' => ["a, b = x\n", 'case.py:1: unsupported Python syntax Assign: destructuring assignments'],
'纯注解赋值' => ["x: int\n", 'case.py:1: unsupported Python syntax AnnAssign: annotation-only'],
'整除增强赋值' => ["x = 1\nx //= 2\n", 'case.py:2: unsupported Python syntax AugAssign: unsupported binary operator FloorDiv'],
'while/else' => ["while True:\n pass\nelse:\n pass\n", 'case.py:1: unsupported Python syntax While: while/else'],
'for/else' => ["for i in x:\n pass\nelse:\n pass\n", 'case.py:1: unsupported Python syntax For: for/else'],
'for 元组目标' => ["for a, b in x:\n pass\n", 'case.py:1: unsupported Python syntax For: only a simple for-loop target'],
'del 不支持的元组目标' => ["x = (1, 2)\ndel (x)\n", 'case.py:2: unsupported Python syntax Delete: unsupported del target'],
'模块属性赋值' => ["import sys\nsys.stdout = None\n", 'case.py:2: unsupported Python syntax Attribute: Python module attributes cannot be assigned'],
'相对导入' => ["from . import mod\n", 'case.py:1: unsupported Python syntax ImportFrom: relative imports'],
'星号导入' => ["from os import *\n", 'case.py:1: unsupported Python syntax ImportFrom: star imports'],
'main 函数名冲突' => ["def main():\n pass\n", 'case.py:1: unsupported Python syntax FunctionDef: a Python function named main conflicts'],
'嵌套函数' => ["def f():\n def g():\n pass\n", 'case.py:2: unsupported Python syntax FunctionDef: nested functions'],
'函数装饰器' => ["@decorator\ndef f():\n pass\n", 'case.py:2: unsupported Python syntax FunctionDef: function decorators'],
'同时变长与关键字变长' => ["def f(*a, **kw):\n pass\n", 'case.py:1: unsupported Python syntax FunctionDef: simultaneous *args and **kwargs'],
'链式比较' => ["x = 1 < 2 < 3\n", 'case.py:1: unsupported Python syntax Compare: chained comparisons'],
'f-string 转换符' => ['x = 1' . "\n" . 'print(f"{x!r}")' . "\n", 'case.py:2: unsupported Python syntax FormattedValue'],
'f-string 格式说明' => ['x = 1' . "\n" . 'print(f"{x:03d}")' . "\n", 'case.py:2: unsupported Python syntax FormattedValue'],
'bytes 字面量' => ["x = b'abc'\n", 'case.py: Python bytes literals are not supported yet'],
'complex 字面量' => ["x = 1j\n", 'case.py: Python complex literals are not supported yet'],
];
}
/** 模块别名不能作为一等值使用,错误格式与其他语法不同(无行号)。 */
public function testModuleAliasCannotBeUsedAsValue(): void
{
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('a Python module cannot be used as a first-class value');
$this->convert("import sys\nx = sys\n");
}
}

@ -43,4 +43,114 @@ final class PythonToolsCommandTest extends TestCase
@rmdir($root);
}
}
// ---------------------------------------------------------------
// --convert-python-to-php(py2php)CLI 层
// ---------------------------------------------------------------
/** 非 Python 工具子命令的普通编译器调用返回 null。 */
public function testReturnsNullForRegularCompilerInvocation(): void
{
self::assertNull(Command::execute(['tpc', 'app.php', '-o', 'app']));
}
public function testConvertWritesGeneratedPhpToStdout(): void
{
$file = $this->writeTempPython("x = 1\nprint(x)\n");
try {
['status' => $status, 'stdout' => $stdout] = $this->runTpc([Command::CONVERT_SOURCE, $file]);
} finally {
@unlink($file);
}
self::assertSame(0, $status);
self::assertStringContainsString('/** @generated from ', $stdout);
self::assertStringContainsString('function main(): void', $stdout);
self::assertStringContainsString('global $x;', $stdout);
}
public function testConvertWithoutFileArgumentFails(): void
{
['status' => $status, 'stderr' => $stderr] = $this->runTpc([Command::CONVERT_SOURCE]);
self::assertSame(1, $status);
self::assertStringContainsString('Usage:', $stderr);
self::assertStringContainsString(Command::CONVERT_SOURCE, $stderr);
}
public function testConvertWithExtraArgumentFails(): void
{
['status' => $status, 'stderr' => $stderr] = $this->runTpc([Command::CONVERT_SOURCE, 'a.py', 'b.py']);
self::assertSame(1, $status);
self::assertStringContainsString('Usage:', $stderr);
}
public function testConvertUnreadableFileFails(): void
{
$file = sys_get_temp_dir() . '/typephp-no-such-' . bin2hex(random_bytes(4)) . '.py';
['status' => $status, 'stderr' => $stderr] = $this->runTpc([Command::CONVERT_SOURCE, $file]);
self::assertSame(1, $status);
self::assertStringContainsString('Unable to read Python source file', $stderr);
}
public function testConvertReportsPythonSyntaxError(): void
{
$file = $this->writeTempPython("def broken(:\n");
try {
['status' => $status, 'stderr' => $stderr] = $this->runTpc([Command::CONVERT_SOURCE, $file]);
} finally {
@unlink($file);
}
self::assertSame(1, $status);
self::assertStringContainsString('Unable to parse Python source', $stderr);
}
public function testSubcommandsCannotBeCombined(): void
{
['status' => $status, 'stderr' => $stderr] = $this->runTpc([
Command::CONVERT_SOURCE,
'a.py',
Command::GENERATE_HELPER,
'sys',
]);
self::assertSame(1, $status);
self::assertStringContainsString('cannot be combined', $stderr);
}
/**
* 通过真实子进程运行 tpc,分别捕获 stdout/stderr 与退出码。
*
* @return array{status: int, stdout: string, stderr: string}
*/
private function runTpc(array $arguments): array
{
$command = array_merge([PHP_BINARY, ROOT_PATH . '/bin/tpc.php'], $arguments);
$process = proc_open($command, [
0 => ['pipe', 'r'],
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
], $pipes, null, null, ['suppress_errors' => true]);
self::assertIsResource($process);
fclose($pipes[0]);
$stdout = stream_get_contents($pipes[1]);
fclose($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[2]);
$status = proc_close($process);
return ['status' => $status, 'stdout' => (string) $stdout, 'stderr' => (string) $stderr];
}
private function writeTempPython(string $source): string
{
$file = sys_get_temp_dir() . '/typephp-py2php-' . bin2hex(random_bytes(6)) . '.py';
self::assertNotFalse(file_put_contents($file, $source));
return $file;
}
}

@ -4,7 +4,11 @@ namespace TypePhp\PythonTools\Converter;
use RuntimeException;
final class PythonAstLoader
/**
* 非 final:测试可子类化注入预制 AST 或模拟解析失败,
* 见 phpunit/src/PythonTools/PythonAstLoaderTest.php。
*/
class PythonAstLoader
{
private const DUMPER = <<<'PYTHON'
import ast

Loading…
Cancel
Save