- 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 propagationpull/48/head
parent
b77fee697e
commit
c47ef90593
5 changed files with 742 additions and 23 deletions
@ -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'); |
||||
} |
||||
} |
||||
Loading…
Reference in new issue