diff --git a/docs/PY2PHP.md b/docs/PY2PHP.md new file mode 100644 index 00000000..db24dbbe --- /dev/null +++ b/docs/PY2PHP.md @@ -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,环境缺失时自动跳过。 diff --git a/phpunit/src/PythonTools/PythonAstLoaderTest.php b/phpunit/src/PythonTools/PythonAstLoaderTest.php new file mode 100644 index 00000000..bbe2bc29 --- /dev/null +++ b/phpunit/src/PythonTools/PythonAstLoaderTest.php @@ -0,0 +1,109 @@ +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'); + } +} diff --git a/phpunit/src/PythonTools/PythonToTypePhpConverterTest.php b/phpunit/src/PythonTools/PythonToTypePhpConverterTest.php index 3fddb5ec..4af0fdf8 100644 --- a/phpunit/src/PythonTools/PythonToTypePhpConverterTest.php +++ b/phpunit/src/PythonTools/PythonToTypePhpConverterTest.php @@ -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"); + } } diff --git a/phpunit/src/PythonTools/PythonToolsCommandTest.php b/phpunit/src/PythonTools/PythonToolsCommandTest.php index 56519d9c..a10d4850 100644 --- a/phpunit/src/PythonTools/PythonToolsCommandTest.php +++ b/phpunit/src/PythonTools/PythonToolsCommandTest.php @@ -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; + } } diff --git a/src/PythonTools/Converter/PythonAstLoader.php b/src/PythonTools/Converter/PythonAstLoader.php index 7a1cfaee..6d10a2f7 100644 --- a/src/PythonTools/Converter/PythonAstLoader.php +++ b/src/PythonTools/Converter/PythonAstLoader.php @@ -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