- Add Command class to handle --gen-python-helper and --convert-python-to-php operations - Integrate PythonToolsCommand execution into main compiler flow - Remove toPlainValue from CompilerBase keyword method map - Update tests to use new toValue() and toArray() methods instead of toPlainValue() - Rename plain-value tests to object-conversion-methods with updated expectations - Replace toPlainValue documentation with toValue() and toArray() explanations - Add HelperRenderer to generate PHP declarations for Python modules - Implement PhpyModuleScanner to inspect Python modules via reflection - Add PyObjectHelperRenderer to generate base PyObject IDE helper - Create example pi.php demonstrating new Python integration features - Update gitignore to exclude ide-helper directorypull/48/head
parent
f61a542255
commit
6c97b4f301
27 changed files with 1736 additions and 121 deletions
@ -0,0 +1,86 @@ |
||||
# Python 工具子模块 |
||||
|
||||
TypePHP 将 Python IDE helper 生成器和 Python 源码转换器集成到了 `tpc`。两者位于独立的 |
||||
`src/PythonTools` 目录,只复用 `tpc` 命令入口,不进入正常的 PHP 预处理、C++ 生成和编译流水线。 |
||||
|
||||
## Python namespace IDE helper |
||||
|
||||
```shell |
||||
./tpc --gen-python-helper math |
||||
./tpc --gen-python-helper numpy.linalg |
||||
./tpc --gen-python-helper numpy --output-dir .ide-helper |
||||
``` |
||||
|
||||
命令通过 PHPy 导入指定 Python module,并使用 Python `inspect` API 采集函数、参数、类、方法和 |
||||
module attribute。PHPy 扩展以及目标 Python module 必须安装在执行 `tpc` 的主机环境中。 |
||||
|
||||
默认生成文件位于当前目录的 `ide-helper` 中。`--output-dir` 可以替换这个输出根目录,既支持 |
||||
相对当前目录的路径,也支持绝对路径: |
||||
|
||||
```text |
||||
ide-helper/python/math.php |
||||
ide-helper/python/numpy/linalg.php |
||||
ide-helper/PyObject.php |
||||
``` |
||||
|
||||
首次生成 module helper 时,会同时生成公共的 `PyObject.php`。它包含 `PyObject` 的动态访问、调用、 |
||||
数组访问、迭代以及 `toArray()`、`toValue()` 等方法提示,供所有 Python module helper 共享。若该文件 |
||||
已经存在,生成器会保留原文件,不进行覆盖。 |
||||
|
||||
生成内容使用 TypePHP 的 module-as-namespace 形式,例如 `python\math\sqrt()`,并兼容普通 |
||||
`use`、`use function` 和 `use const` 的 IDE 名称解析。文件末尾包含 `die`,用于在误执行时明确 |
||||
终止程序。helper 只能交给 IDE 索引,不能被 include,也不能加入 TypePHP 项目的 sources 或编译输入。 |
||||
|
||||
`PyObject::IDE_HELPER_ONLY` 是所有 helper 共用的提示常量。非 `void` stub 的方法体使用 |
||||
`die(\PyObject::IDE_HELPER_ONLY)`,以满足 IDE 对返回类型控制流的检查,不会再产生“缺少 return |
||||
语句”的诊断。module attribute 使用命名空间 `const` 声明,支持 IDE 的常量补全和 `use const`。 |
||||
PHP 8.1 及以上允许在常量初始化表达式中使用 `new`。module attribute 因此直接使用仅供 IDE |
||||
分析的 `PyObject` 实例作为占位值: |
||||
|
||||
```php |
||||
const pi = new \PyObject(); |
||||
``` |
||||
|
||||
这样 IDE 会将常量精确识别为 `PyObject`,而不是从 `null` 推断出错误类型。 |
||||
|
||||
公共 `PyObject` helper 还声明了 TypePHP 的虚拟关键词方法,包括 `toInt()`、`toFloat()`、 |
||||
`toString()`、`toBool()`、`toStream()`、高精度类型转换、`toObject()`、`toAny()` 和 `toRef()`。 |
||||
这些声明仅用于 IDE 补全;调用会在编译期展开,并不是 PHPy `PyObject` 运行时类的实体方法。 |
||||
`toArray()` 和 `toValue()` 则仍是 PHPy 提供的真实方法。 |
||||
|
||||
Python class 的构造函数会显式调用 `parent::__construct()`。Python 对象若定义了 `count()`,helper |
||||
不会重复声明它,因为 `PyObject::count(): int` 已用于 PHP `Countable`。需要调用 Python 自身的 |
||||
`count()` 时,应显式写为 `$object->__call('count', $arguments)`。 |
||||
|
||||
PHP function/class 名称大小写不敏感,而 Python 名称大小写敏感;PHP 保留字也不能声明为普通 |
||||
stub symbol。生成器会以注释报告无法用合法 PHP 声明表达的符号,不会擅自重命名 Python API。 |
||||
|
||||
## Python 转 TypePHP |
||||
|
||||
```shell |
||||
./tpc --convert-python-to-php script.py > script.php |
||||
``` |
||||
|
||||
转换器调用 PATH 中的 `python3` 解析 Python AST,然后输出使用 TypePHP Python namespace |
||||
语法的 PHP 源码。普通 module import 会转换为 namespace import: |
||||
|
||||
```python |
||||
import math |
||||
print(math.sqrt(16)) |
||||
``` |
||||
|
||||
```php |
||||
use python\math; |
||||
|
||||
function main(): void |
||||
{ |
||||
python\print(math\sqrt(16)); |
||||
} |
||||
``` |
||||
|
||||
当前支持普通 import、函数、赋值、调用、容器字面量、基础运算、单项比较、if/while/for、 |
||||
lambda 和基础 f-string。module 顶层变量会转换为 PHP global,以保持函数读取 module 变量的能力。 |
||||
|
||||
转换器遵循“不能可靠保持语义就拒绝”的原则。class、async、generator、try/with、decorator、 |
||||
destructuring assignment、chained comparison、嵌套函数以及 loop-else 等尚未完成的语法会抛出带 |
||||
源文件和行号的错误,不会生成看似可用但语义错误的 PHP 代码。 |
||||
@ -0,0 +1,13 @@ |
||||
<?php |
||||
|
||||
use const python\math\pi; |
||||
use function python\platform\python_version; |
||||
|
||||
function main() |
||||
{ |
||||
echo pi, "\n"; |
||||
var_dump(get_class(pi)); |
||||
var_dump(pi->toValue()->toFloat()); |
||||
var_dump(pi->toValue()->toInt()); |
||||
echo python_version(), "\n"; |
||||
} |
||||
@ -0,0 +1,15 @@ |
||||
<?php |
||||
|
||||
function main(): void |
||||
{ |
||||
$list = python\list([1, 2, 3]); |
||||
$array = $list->toArray(); |
||||
$value = convertPythonValue($list); |
||||
$integer = python\int(42)->toValue()->toInt(); |
||||
var_dump($array, $value, $integer); |
||||
} |
||||
|
||||
function convertPythonValue(PyObject $value): mixed |
||||
{ |
||||
return $value->toValue(); |
||||
} |
||||
@ -1,10 +0,0 @@ |
||||
<?php |
||||
|
||||
function invalidPlainValueCall(PyObject $value): void |
||||
{ |
||||
$value->toPlainValue(1); |
||||
} |
||||
|
||||
function main(): void |
||||
{ |
||||
} |
||||
@ -1,15 +0,0 @@ |
||||
<?php |
||||
|
||||
function main(): void |
||||
{ |
||||
$list = python\list([1, 2, 3]); |
||||
$plain = $list->toPlainValue(); |
||||
$dynamic = convertPlainValue($list); |
||||
$array = $dynamic->toArray(); |
||||
var_dump($plain, $array); |
||||
} |
||||
|
||||
function convertPlainValue(mixed $value): mixed |
||||
{ |
||||
return $value->toPlainValue(); |
||||
} |
||||
@ -0,0 +1,156 @@ |
||||
<?php |
||||
|
||||
namespace TypePhpTest\PythonTools; |
||||
|
||||
use PHPUnit\Framework\Attributes\RequiresPhpExtension; |
||||
use PHPUnit\Framework\TestCase; |
||||
use TypePhp\PythonTools\IdeHelper\HelperRenderer; |
||||
use TypePhp\PythonTools\IdeHelper\PhpyModuleScanner; |
||||
use TypePhp\PythonTools\IdeHelper\PyObjectHelperRenderer; |
||||
|
||||
final class PythonIdeHelperTest extends TestCase |
||||
{ |
||||
public function testRendererProducesInertNamespaceHelper(): void |
||||
{ |
||||
$metadata = [ |
||||
'module' => 'demo.widgets', |
||||
'doc' => 'Demo module.', |
||||
'attributes' => [ |
||||
['name' => 'VERSION'], |
||||
['name' => 'class'], |
||||
], |
||||
'functions' => [ |
||||
[ |
||||
'name' => 'create', |
||||
'parameters' => [ |
||||
['name' => 'value', 'optional' => false, 'variadic' => false], |
||||
['name' => 'mode', 'optional' => true, 'variadic' => false], |
||||
], |
||||
], |
||||
], |
||||
'classes' => [ |
||||
[ |
||||
'name' => 'Widget', |
||||
'parameters' => [], |
||||
'methods' => [ |
||||
['name' => 'render', 'parameters' => []], |
||||
], |
||||
'properties' => ['name'], |
||||
], |
||||
], |
||||
]; |
||||
|
||||
$helper = (new HelperRenderer())->render($metadata); |
||||
|
||||
self::assertStringContainsString('@generated TypePHP Python IDE helper', $helper); |
||||
self::assertStringContainsString('namespace python\\demo\\widgets;', $helper); |
||||
self::assertStringNotContainsString('if (false)', $helper); |
||||
self::assertStringEndsWith("die(\\PyObject::IDE_HELPER_ONLY);\n", $helper); |
||||
self::assertStringContainsString('const VERSION = new \\PyObject();', $helper); |
||||
self::assertStringContainsString("\nconst VERSION = new \\PyObject();", $helper); |
||||
self::assertStringNotContainsString('/** @var \\PyObject */', $helper); |
||||
self::assertStringContainsString("\nclass Widget extends \\PyObject", $helper); |
||||
self::assertStringContainsString("\n public function render()", $helper); |
||||
self::assertStringNotContainsString('const class =', $helper); |
||||
self::assertStringNotContainsString('define(', $helper); |
||||
self::assertStringContainsString( |
||||
'function create(mixed $value, mixed $mode = null): \\PyObject { die(\\PyObject::IDE_HELPER_ONLY); }', |
||||
$helper, |
||||
); |
||||
self::assertStringContainsString('class Widget extends \\PyObject', $helper); |
||||
self::assertStringContainsString('function Widget(): Widget { die(\\PyObject::IDE_HELPER_ONLY); }', $helper); |
||||
self::assertStringContainsString('public function __construct() { parent::__construct(); }', $helper); |
||||
self::assertStringContainsString('public function render(): \\PyObject { die(\\PyObject::IDE_HELPER_ONLY); }', $helper); |
||||
self::assertStringNotContainsString('PyCore::import', $helper); |
||||
} |
||||
|
||||
public function testBuiltinsAreRenderedInPythonRootNamespace(): void |
||||
{ |
||||
$helper = (new HelperRenderer())->render([ |
||||
'module' => 'builtins', |
||||
'doc' => '', |
||||
'attributes' => [], |
||||
'functions' => [['name' => 'len', 'parameters' => []]], |
||||
'classes' => [], |
||||
]); |
||||
|
||||
self::assertStringContainsString('namespace python;', $helper); |
||||
self::assertStringContainsString('function len(): \\PyObject', $helper); |
||||
} |
||||
|
||||
public function testRendererProducesAnInertPyObjectHelper(): void |
||||
{ |
||||
$helper = (new PyObjectHelperRenderer())->render(); |
||||
|
||||
self::assertStringNotContainsString('if (false)', $helper); |
||||
self::assertStringEndsWith("die(PyObject::IDE_HELPER_ONLY);\n", $helper); |
||||
self::assertStringContainsString( |
||||
'class PyObject implements \\ArrayAccess, \\Iterator, \\Countable', |
||||
$helper, |
||||
); |
||||
self::assertStringContainsString("\nclass PyObject implements", $helper); |
||||
self::assertStringContainsString("\n public const IDE_HELPER_ONLY", $helper); |
||||
self::assertStringContainsString("public const IDE_HELPER_ONLY = 'IDE helper only';", $helper); |
||||
self::assertStringNotContainsString('enum PyObjectConstant', $helper); |
||||
self::assertStringContainsString('public function toArray(): array { die(self::IDE_HELPER_ONLY); }', $helper); |
||||
self::assertStringContainsString('public function toValue(): mixed { die(self::IDE_HELPER_ONLY); }', $helper); |
||||
self::assertStringContainsString('public function __toString(): string { die(self::IDE_HELPER_ONLY); }', $helper); |
||||
self::assertStringContainsString('public function next(): void {}', $helper); |
||||
self::assertStringContainsString('TypePHP keyword methods are compiler intrinsics', $helper); |
||||
self::assertStringContainsString('public function toInt(): int { die(self::IDE_HELPER_ONLY); }', $helper); |
||||
self::assertStringContainsString('public function toFloat(): float { die(self::IDE_HELPER_ONLY); }', $helper); |
||||
self::assertStringContainsString('public function toString(): string { die(self::IDE_HELPER_ONLY); }', $helper); |
||||
self::assertStringContainsString('public function toBool(): bool { die(self::IDE_HELPER_ONLY); }', $helper); |
||||
self::assertStringContainsString('public function toStream(): mixed { die(self::IDE_HELPER_ONLY); }', $helper); |
||||
self::assertStringContainsString('public function toBigInt(): mixed { die(self::IDE_HELPER_ONLY); }', $helper); |
||||
self::assertStringContainsString('public function toBigFloat(): mixed { die(self::IDE_HELPER_ONLY); }', $helper); |
||||
self::assertStringContainsString('public function toDecimal(): mixed { die(self::IDE_HELPER_ONLY); }', $helper); |
||||
self::assertStringContainsString('public function toObject(?string $class = null): object', $helper); |
||||
self::assertStringContainsString('public function toAny(): mixed { die(self::IDE_HELPER_ONLY); }', $helper); |
||||
self::assertStringContainsString('public function toRef(): mixed { die(self::IDE_HELPER_ONLY); }', $helper); |
||||
self::assertSame(1, substr_count($helper, 'public function toArray(): array')); |
||||
} |
||||
|
||||
public function testRendererOmitsPythonCountMethodInheritedFromPyObject(): void |
||||
{ |
||||
$helper = (new HelperRenderer())->render([ |
||||
'module' => 'demo', |
||||
'attributes' => [], |
||||
'functions' => [], |
||||
'classes' => [[ |
||||
'name' => 'Container', |
||||
'parameters' => [], |
||||
'properties' => [], |
||||
'methods' => [ |
||||
['name' => 'count', 'parameters' => [['name' => 'value']]], |
||||
['name' => 'append', 'parameters' => [['name' => 'value']]], |
||||
], |
||||
]], |
||||
]); |
||||
|
||||
self::assertStringNotContainsString('public function count(', $helper); |
||||
self::assertStringContainsString('public function append(mixed $value): \\PyObject', $helper); |
||||
} |
||||
|
||||
#[RequiresPhpExtension('phpy')] |
||||
public function testPhpyScannerReadsRealPythonModule(): void |
||||
{ |
||||
$metadata = (new PhpyModuleScanner())->scan('math'); |
||||
$functions = array_column($metadata['functions'], null, 'name'); |
||||
$attributes = array_column($metadata['attributes'], null, 'name'); |
||||
|
||||
self::assertArrayHasKey('sqrt', $functions); |
||||
self::assertSame('x', $functions['sqrt']['parameters'][0]['name']); |
||||
self::assertArrayHasKey('pi', $attributes); |
||||
} |
||||
|
||||
#[RequiresPhpExtension('phpy')] |
||||
public function testPhpyScannerReadsClassesWithoutRetainingDynamicCallTrampolines(): void |
||||
{ |
||||
$metadata = (new PhpyModuleScanner())->scan('json'); |
||||
$classes = array_column($metadata['classes'], null, 'name'); |
||||
|
||||
self::assertArrayHasKey('JSONDecoder', $classes); |
||||
self::assertNotEmpty($classes['JSONDecoder']['methods']); |
||||
} |
||||
} |
||||
@ -0,0 +1,76 @@ |
||||
<?php |
||||
|
||||
namespace TypePhpTest\PythonTools; |
||||
|
||||
use PHPUnit\Framework\TestCase; |
||||
use RuntimeException; |
||||
use TypePhp\PythonTools\Converter\PythonToTypePhpConverter; |
||||
|
||||
final class PythonToTypePhpConverterTest extends TestCase |
||||
{ |
||||
public function testConvertsImportsFunctionsAndTopLevelCode(): void |
||||
{ |
||||
$source = <<<'PYTHON' |
||||
import math |
||||
import os.path as path |
||||
from json import dumps as encode |
||||
|
||||
def hypotenuse(x, y=4): |
||||
return math.sqrt(x * x + y * y) |
||||
|
||||
value = hypotenuse(3) |
||||
print(encode({"value": value})) |
||||
PYTHON; |
||||
|
||||
$php = (new PythonToTypePhpConverter())->convertSource($source, 'example.py'); |
||||
|
||||
self::assertStringContainsString('use python\\math;', $php); |
||||
self::assertStringContainsString('use python\\os\\path;', $php); |
||||
self::assertStringContainsString('function hypotenuse($x, $y = 4)', $php); |
||||
self::assertStringContainsString('return math\\sqrt($x * $x + $y * $y);', $php); |
||||
self::assertStringContainsString('function main(): void', $php); |
||||
self::assertStringContainsString('python\\json\\dumps(python\\dict([\'value\' => $value]))', $php); |
||||
self::assertStringContainsString('python\\print(', $php); |
||||
} |
||||
|
||||
public function testPassDoesNotBecomeReturnAndPythonComparisonsStayExplicit(): void |
||||
{ |
||||
$source = <<<'PYTHON' |
||||
def inspect_value(value, values): |
||||
if value is None: |
||||
pass |
||||
return value in values |
||||
PYTHON; |
||||
|
||||
$php = (new PythonToTypePhpConverter())->convertSource($source, 'comparison.py'); |
||||
|
||||
self::assertStringContainsString('if ($value === null)', $php); |
||||
self::assertStringContainsString('// pass', $php); |
||||
self::assertStringContainsString('python\\operator\\contains($values, $value)', $php); |
||||
self::assertStringNotContainsString('if ($value === null) {' . "\n return;", $php); |
||||
} |
||||
|
||||
public function testUnsupportedSyntaxReportsSourceLocation(): void |
||||
{ |
||||
$this->expectException(RuntimeException::class); |
||||
$this->expectExceptionMessage('sample.py:1'); |
||||
$this->expectExceptionMessage('ClassDef'); |
||||
|
||||
(new PythonToTypePhpConverter())->convertSource("class Demo:\n pass\n", 'sample.py'); |
||||
} |
||||
|
||||
public function testModuleVariablesRemainVisibleInsideFunctions(): void |
||||
{ |
||||
$php = (new PythonToTypePhpConverter())->convertSource(<<<'PYTHON' |
||||
factor = 4 |
||||
|
||||
def scale(value): |
||||
return value * factor |
||||
|
||||
print(scale(3)) |
||||
PYTHON, 'globals.py'); |
||||
|
||||
self::assertStringContainsString('function scale($value)' . "\n{\n" . ' global $factor;', $php); |
||||
self::assertStringContainsString("function main(): void\n{\n" . ' global $factor;', $php); |
||||
} |
||||
} |
||||
@ -0,0 +1,36 @@ |
||||
<?php |
||||
|
||||
namespace TypePhpTest\PythonTools; |
||||
|
||||
use PHPUnit\Framework\Attributes\RequiresPhpExtension; |
||||
use PHPUnit\Framework\TestCase; |
||||
use TypePhp\PythonTools\Command; |
||||
|
||||
final class PythonToolsCommandTest extends TestCase |
||||
{ |
||||
#[RequiresPhpExtension('phpy')] |
||||
public function testCustomOutputDirectoryPreservesExistingPyObjectHelper(): void |
||||
{ |
||||
$root = sys_get_temp_dir() . '/typephp-python-tools-' . bin2hex(random_bytes(6)); |
||||
$output = $root . '/stubs'; |
||||
self::assertTrue(mkdir($output, 0777, true)); |
||||
self::assertNotFalse(file_put_contents($output . '/PyObject.php', 'keep-me')); |
||||
|
||||
try { |
||||
$status = Command::execute( |
||||
['tpc', Command::GENERATE_HELPER, 'math', '--output-dir', 'stubs'], |
||||
$root, |
||||
); |
||||
|
||||
self::assertSame(0, $status); |
||||
self::assertFileExists($output . '/python/math.php'); |
||||
self::assertSame('keep-me', file_get_contents($output . '/PyObject.php')); |
||||
} finally { |
||||
@unlink($output . '/python/math.php'); |
||||
@rmdir($output . '/python'); |
||||
@unlink($output . '/PyObject.php'); |
||||
@rmdir($output); |
||||
@rmdir($root); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,145 @@ |
||||
<?php |
||||
|
||||
namespace TypePhp\PythonTools; |
||||
|
||||
use RuntimeException; |
||||
use Throwable; |
||||
use TypePhp\PythonTools\Converter\PythonToTypePhpConverter; |
||||
use TypePhp\PythonTools\IdeHelper\HelperRenderer; |
||||
use TypePhp\PythonTools\IdeHelper\PhpyModuleScanner; |
||||
use TypePhp\PythonTools\IdeHelper\PyObjectHelperRenderer; |
||||
|
||||
final class Command |
||||
{ |
||||
public const GENERATE_HELPER = '--gen-python-helper'; |
||||
public const CONVERT_SOURCE = '--convert-python-to-php'; |
||||
|
||||
/** Return null for a normal compiler invocation, otherwise an exit status. */ |
||||
public static function execute(array $argv, ?string $workingDirectory = null): ?int |
||||
{ |
||||
$helperIndex = array_search(self::GENERATE_HELPER, $argv, true); |
||||
$converterIndex = array_search(self::CONVERT_SOURCE, $argv, true); |
||||
if ($helperIndex === false && $converterIndex === false) { |
||||
return null; |
||||
} |
||||
if ($helperIndex !== false && $converterIndex !== false) { |
||||
return self::error('Python tool subcommands cannot be combined'); |
||||
} |
||||
|
||||
try { |
||||
if ($helperIndex !== false) { |
||||
$root = $workingDirectory ?? getcwd(); |
||||
if (!is_string($root) || $root === '') { |
||||
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); |
||||
$relative = str_replace('.', DIRECTORY_SEPARATOR, $module) . '.php'; |
||||
$file = $outputDirectory . DIRECTORY_SEPARATOR . 'python' . DIRECTORY_SEPARATOR . $relative; |
||||
$pyObjectFile = $outputDirectory . DIRECTORY_SEPARATOR . 'PyObject.php'; |
||||
if (!is_file($pyObjectFile)) { |
||||
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); |
||||
return 0; |
||||
} |
||||
|
||||
$file = self::singleArgument($argv, $converterIndex, self::CONVERT_SOURCE, '[your_file.py]'); |
||||
fwrite(STDOUT, (new PythonToTypePhpConverter())->convertFile($file)); |
||||
return 0; |
||||
} catch (Throwable $exception) { |
||||
return self::error($exception->getMessage()); |
||||
} |
||||
} |
||||
|
||||
/** @return array{string, string} */ |
||||
private static function helperArguments(array $argv, int $optionIndex, string $root): array |
||||
{ |
||||
$module = $argv[$optionIndex + 1] ?? ''; |
||||
if (!is_string($module) || $module === '' || str_starts_with($module, '-')) { |
||||
throw new RuntimeException("Usage: {$argv[0]} " . self::GENERATE_HELPER |
||||
. ' [Python Module] [--output-dir <directory>]'); |
||||
} |
||||
|
||||
$output = null; |
||||
for ($index = 1, $count = count($argv); $index < $count; $index++) { |
||||
$argument = $argv[$index]; |
||||
if ($index === $optionIndex || $index === $optionIndex + 1) { |
||||
continue; |
||||
} |
||||
if ($argument === '--output-dir') { |
||||
if ($output !== null || !isset($argv[$index + 1]) || $argv[$index + 1] === '') { |
||||
throw new RuntimeException('--output-dir requires exactly one directory'); |
||||
} |
||||
$output = $argv[++$index]; |
||||
continue; |
||||
} |
||||
if (str_starts_with($argument, '--output-dir=')) { |
||||
if ($output !== null) { |
||||
throw new RuntimeException('--output-dir may only be specified once'); |
||||
} |
||||
$output = substr($argument, strlen('--output-dir=')); |
||||
if ($output === '') { |
||||
throw new RuntimeException('--output-dir requires exactly one directory'); |
||||
} |
||||
continue; |
||||
} |
||||
throw new RuntimeException("Unknown argument for " . self::GENERATE_HELPER . ": {$argument}"); |
||||
} |
||||
|
||||
if ($output === null) { |
||||
return [$module, $root . DIRECTORY_SEPARATOR . 'ide-helper']; |
||||
} |
||||
if (self::isAbsolutePath($output)) { |
||||
$absoluteOutput = rtrim($output, '/\\'); |
||||
return [$module, $absoluteOutput === '' ? DIRECTORY_SEPARATOR : $absoluteOutput]; |
||||
} |
||||
return [$module, $root . DIRECTORY_SEPARATOR . rtrim($output, '/\\')]; |
||||
} |
||||
|
||||
private static function isAbsolutePath(string $path): bool |
||||
{ |
||||
return str_starts_with($path, '/') |
||||
|| str_starts_with($path, '\\') |
||||
|| preg_match('/^[A-Za-z]:[\\\\\/]/D', $path) === 1; |
||||
} |
||||
|
||||
private static function singleArgument(array $argv, int $optionIndex, string $option, string $placeholder): string |
||||
{ |
||||
$arguments = []; |
||||
foreach (array_slice($argv, 1) as $argument) { |
||||
if ($argument !== $option) { |
||||
$arguments[] = $argument; |
||||
} |
||||
} |
||||
if (count($arguments) !== 1 || $arguments[0] === '' || str_starts_with($arguments[0], '-')) { |
||||
throw new RuntimeException("Usage: {$argv[0]} {$option} {$placeholder}"); |
||||
} |
||||
if (!isset($argv[$optionIndex + 1]) || $argv[$optionIndex + 1] !== $arguments[0]) { |
||||
throw new RuntimeException("{$option} must be followed by {$placeholder}"); |
||||
} |
||||
return $arguments[0]; |
||||
} |
||||
|
||||
private static function writeFile(string $file, string $contents): void |
||||
{ |
||||
$directory = dirname($file); |
||||
if (!is_dir($directory) && !mkdir($directory, 0777, true) && !is_dir($directory)) { |
||||
throw new RuntimeException("Unable to create IDE helper directory: {$directory}"); |
||||
} |
||||
$temporary = $file . '.tmp-' . bin2hex(random_bytes(4)); |
||||
if (file_put_contents($temporary, $contents) === false || !rename($temporary, $file)) { |
||||
@unlink($temporary); |
||||
throw new RuntimeException("Unable to write IDE helper: {$file}"); |
||||
} |
||||
} |
||||
|
||||
private static function error(string $message): int |
||||
{ |
||||
fwrite(STDERR, "\033[31mError: {$message}\033[0m" . PHP_EOL); |
||||
return 1; |
||||
} |
||||
} |
||||
@ -0,0 +1,91 @@ |
||||
<?php |
||||
|
||||
namespace TypePhp\PythonTools\Converter; |
||||
|
||||
use RuntimeException; |
||||
|
||||
final class PythonAstLoader |
||||
{ |
||||
private const DUMPER = <<<'PYTHON' |
||||
import ast |
||||
import json |
||||
import sys |
||||
|
||||
def convert(value): |
||||
if isinstance(value, ast.AST): |
||||
result = {name: convert(item) for name, item in ast.iter_fields(value)} |
||||
result['_type'] = value.__class__.__name__ |
||||
for name in ('lineno', 'col_offset', 'end_lineno', 'end_col_offset'): |
||||
if hasattr(value, name): |
||||
result[name] = getattr(value, name) |
||||
return result |
||||
if isinstance(value, list): |
||||
return [convert(item) for item in value] |
||||
if isinstance(value, bytes): |
||||
return {'_python_constant': 'bytes', 'hex': value.hex()} |
||||
if isinstance(value, complex): |
||||
return {'_python_constant': 'complex', 'real': value.real, 'imag': value.imag} |
||||
return value |
||||
|
||||
filename = sys.argv[1] |
||||
source = sys.stdin.read() |
||||
try: |
||||
tree = ast.parse(source, filename=filename, type_comments=True) |
||||
except SyntaxError as error: |
||||
print(json.dumps({ |
||||
'error': error.msg, |
||||
'line': error.lineno, |
||||
'column': error.offset, |
||||
}), file=sys.stderr) |
||||
raise SystemExit(2) |
||||
print(json.dumps(convert(tree), ensure_ascii=False)) |
||||
PYTHON; |
||||
|
||||
public function __construct(private readonly string $python = 'python3') |
||||
{ |
||||
} |
||||
|
||||
/** @return array<string, mixed> */ |
||||
public function parse(string $source, string $filename): array |
||||
{ |
||||
$command = [$this->python, '-c', self::DUMPER, $filename]; |
||||
$descriptors = [ |
||||
0 => ['pipe', 'r'], |
||||
1 => ['pipe', 'w'], |
||||
2 => ['pipe', 'w'], |
||||
]; |
||||
// Do not inherit the project directory as Python's import root: a user |
||||
// file such as ast.py must not shadow the standard-library ast module. |
||||
$process = proc_open($command, $descriptors, $pipes, sys_get_temp_dir()); |
||||
if (!is_resource($process)) { |
||||
throw new RuntimeException("Unable to start Python executable `{$this->python}`"); |
||||
} |
||||
fwrite($pipes[0], $source); |
||||
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); |
||||
if ($status !== 0) { |
||||
$detail = trim($stderr); |
||||
try { |
||||
$error = json_decode($detail, true, flags: JSON_THROW_ON_ERROR); |
||||
if (is_array($error) && isset($error['error'])) { |
||||
$detail = $filename . ':' . ($error['line'] ?? 0) . ': ' . $error['error']; |
||||
} |
||||
} catch (\JsonException) { |
||||
} |
||||
throw new RuntimeException('Unable to parse Python source: ' . ($detail !== '' ? $detail : "exit status {$status}")); |
||||
} |
||||
try { |
||||
$tree = json_decode($stdout, true, flags: JSON_THROW_ON_ERROR); |
||||
} catch (\JsonException $exception) { |
||||
throw new RuntimeException('Python AST dumper returned invalid JSON', 0, $exception); |
||||
} |
||||
if (!is_array($tree) || ($tree['_type'] ?? null) !== 'Module') { |
||||
throw new RuntimeException('Python AST dumper did not return a module'); |
||||
} |
||||
return $tree; |
||||
} |
||||
} |
||||
@ -0,0 +1,648 @@ |
||||
<?php |
||||
|
||||
namespace TypePhp\PythonTools\Converter; |
||||
|
||||
use RuntimeException; |
||||
|
||||
final class PythonToTypePhpConverter |
||||
{ |
||||
/** @var array<string, string> */ |
||||
private array $moduleAliases = []; |
||||
|
||||
/** @var array<string, array{module: string, member: string}> */ |
||||
private array $importedSymbols = []; |
||||
|
||||
/** @var array<string, true> */ |
||||
private array $definedFunctions = []; |
||||
|
||||
/** @var array<string, true> */ |
||||
private array $moduleGlobals = []; |
||||
|
||||
private string $filename = '<python>'; |
||||
|
||||
private int $indent = 0; |
||||
|
||||
public function __construct(private readonly PythonAstLoader $loader = new PythonAstLoader()) |
||||
{ |
||||
} |
||||
|
||||
public function convertFile(string $file): string |
||||
{ |
||||
$source = @file_get_contents($file); |
||||
if ($source === false) { |
||||
throw new RuntimeException("Unable to read Python source file: {$file}"); |
||||
} |
||||
return $this->convertSource($source, $file); |
||||
} |
||||
|
||||
public function convertSource(string $source, string $filename = '<python>'): string |
||||
{ |
||||
$this->filename = $filename; |
||||
$this->moduleAliases = []; |
||||
$this->importedSymbols = []; |
||||
$this->definedFunctions = []; |
||||
$this->moduleGlobals = []; |
||||
$this->indent = 0; |
||||
$tree = $this->loader->parse($source, $filename); |
||||
$functions = []; |
||||
$main = []; |
||||
|
||||
foreach ($tree['body'] ?? [] as $node) { |
||||
if (in_array($node['_type'] ?? '', ['Assign', 'AnnAssign', 'AugAssign'], true)) { |
||||
$targets = ($node['_type'] ?? '') === 'Assign' ? ($node['targets'] ?? []) : [$node['target'] ?? []]; |
||||
foreach ($targets as $target) { |
||||
if (($target['_type'] ?? '') === 'Name') { |
||||
$this->moduleGlobals[(string) $target['id']] = true; |
||||
} |
||||
} |
||||
} |
||||
$type = $node['_type'] ?? ''; |
||||
if ($type === 'Import' || $type === 'ImportFrom') { |
||||
$this->collectImport($node); |
||||
} elseif ($type === 'FunctionDef') { |
||||
$name = (string) $node['name']; |
||||
if ($name === 'main') { |
||||
$this->unsupported($node, 'a Python function named main conflicts with the TypePHP entry point'); |
||||
} |
||||
$this->definedFunctions[$name] = true; |
||||
$functions[] = $node; |
||||
} else { |
||||
$main[] = $node; |
||||
} |
||||
} |
||||
|
||||
$lines = ['<?php', '', '/** @generated from ' . $this->safeComment($filename) . ' */']; |
||||
foreach ($this->moduleAliases as $alias => $module) { |
||||
$namespace = 'python\\' . str_replace('.', '\\', $module); |
||||
$defaultAlias = str_replace('.', '\\', $module); |
||||
$lines[] = $alias === basename(str_replace('.', '/', $module)) |
||||
? 'use ' . $namespace . ';' |
||||
: 'use ' . $namespace . ' as ' . $alias . ';'; |
||||
} |
||||
if ($this->moduleAliases !== []) { |
||||
$lines[] = ''; |
||||
} |
||||
foreach ($functions as $function) { |
||||
array_push($lines, ...$this->statement($function)); |
||||
$lines[] = ''; |
||||
} |
||||
$lines[] = 'function main(): void'; |
||||
$lines[] = '{'; |
||||
$this->indent = 1; |
||||
if ($this->moduleGlobals !== []) { |
||||
$lines[] = $this->line('global ' . implode(', ', $this->variables(array_keys($this->moduleGlobals))) . ';'); |
||||
} |
||||
foreach ($main as $node) { |
||||
array_push($lines, ...$this->statement($node)); |
||||
} |
||||
$this->indent = 0; |
||||
$lines[] = '}'; |
||||
$lines[] = ''; |
||||
return implode(PHP_EOL, $lines); |
||||
} |
||||
|
||||
/** @param array<string, mixed> $node */ |
||||
private function collectImport(array $node): void |
||||
{ |
||||
if ($node['_type'] === 'Import') { |
||||
foreach ($node['names'] ?? [] as $name) { |
||||
$module = (string) $name['name']; |
||||
$alias = (string) ($name['asname'] ?? ''); |
||||
if ($alias === '') { |
||||
$alias = explode('.', $module)[0]; |
||||
$module = $alias; |
||||
} |
||||
$this->moduleAliases[$alias] = $module; |
||||
} |
||||
return; |
||||
} |
||||
if (($node['level'] ?? 0) !== 0 || ($node['module'] ?? null) === null) { |
||||
$this->unsupported($node, 'relative imports are not supported yet'); |
||||
} |
||||
foreach ($node['names'] ?? [] as $name) { |
||||
if (($name['name'] ?? '') === '*') { |
||||
$this->unsupported($node, 'star imports are not supported'); |
||||
} |
||||
$alias = (string) (($name['asname'] ?? null) ?: $name['name']); |
||||
$this->importedSymbols[$alias] = [ |
||||
'module' => (string) $node['module'], |
||||
'member' => (string) $name['name'], |
||||
]; |
||||
} |
||||
} |
||||
|
||||
/** @param array<string, mixed> $node @return list<string> */ |
||||
private function statement(array $node): array |
||||
{ |
||||
$type = $node['_type'] ?? ''; |
||||
return match ($type) { |
||||
'FunctionDef' => $this->functionDefinition($node), |
||||
'Assign' => $this->assignment($node), |
||||
'AnnAssign' => ($node['value'] ?? null) === null |
||||
? $this->unsupported($node, 'annotation-only assignments have no TypePHP runtime value') |
||||
: [$this->line($this->target($node['target']) . ' = ' . $this->expression($node['value']) . ';')], |
||||
'AugAssign' => [$this->line($this->target($node['target']) . ' ' . $this->binaryOperator($node['op'], $node) |
||||
. '= ' . $this->expression($node['value']) . ';')], |
||||
'Expr' => $this->expressionStatement($node), |
||||
'Return' => [$this->line('return' . (($node['value'] ?? null) === null ? '' : ' ' . $this->expression($node['value'])) . ';')], |
||||
'If' => $this->ifStatement($node), |
||||
'While' => $this->whileStatement($node), |
||||
'For' => $this->forStatement($node), |
||||
'Break' => [$this->line('break;')], |
||||
'Continue' => [$this->line('continue;')], |
||||
'Pass' => [$this->line('// pass')], |
||||
'Global' => [$this->line('global ' . implode(', ', $this->variables($node['names'] ?? [])) . ';')], |
||||
'Delete' => $this->deleteStatement($node), |
||||
'Import', 'ImportFrom' => [], |
||||
default => $this->unsupported($node), |
||||
}; |
||||
} |
||||
|
||||
/** @param array<string, mixed> $node @return list<string> */ |
||||
private function functionDefinition(array $node): array |
||||
{ |
||||
if ($this->indent !== 0) { |
||||
$this->unsupported($node, 'nested functions require Python closure scope analysis'); |
||||
} |
||||
if (($node['decorator_list'] ?? []) !== []) { |
||||
$this->unsupported($node, 'function decorators are not supported yet'); |
||||
} |
||||
$parameters = $this->parameters($node['args'], $node); |
||||
$lines = [$this->line('function ' . $node['name'] . '(' . $parameters . ')'), $this->line('{')]; |
||||
$this->indent++; |
||||
$locals = $this->functionLocalNames($node); |
||||
$globals = array_values(array_diff(array_keys($this->moduleGlobals), array_keys($locals))); |
||||
if ($globals !== []) { |
||||
$lines[] = $this->line('global ' . implode(', ', $this->variables($globals)) . ';'); |
||||
} |
||||
foreach ($node['body'] ?? [] as $body) { |
||||
array_push($lines, ...$this->statement($body)); |
||||
} |
||||
$this->indent--; |
||||
$lines[] = $this->line('}'); |
||||
return $lines; |
||||
} |
||||
|
||||
/** @param array<string, mixed> $arguments @param array<string, mixed> $owner */ |
||||
private function parameters(array $arguments, array $owner): string |
||||
{ |
||||
$positional = array_merge($arguments['posonlyargs'] ?? [], $arguments['args'] ?? []); |
||||
$defaults = $arguments['defaults'] ?? []; |
||||
$defaultStart = count($positional) - count($defaults); |
||||
$result = []; |
||||
foreach ($positional as $index => $argument) { |
||||
$value = $this->variable((string) $argument['arg']); |
||||
if ($index >= $defaultStart) { |
||||
$value .= ' = ' . $this->expression($defaults[$index - $defaultStart]); |
||||
} |
||||
$result[] = $value; |
||||
} |
||||
foreach ($arguments['kwonlyargs'] ?? [] as $index => $argument) { |
||||
$default = $arguments['kw_defaults'][$index] ?? null; |
||||
$result[] = $this->variable((string) $argument['arg']) . ' = ' |
||||
. ($default === null ? 'null' : $this->expression($default)); |
||||
} |
||||
$variadic = $arguments['vararg'] ?? $arguments['kwarg'] ?? null; |
||||
if ($variadic !== null) { |
||||
$result[] = '...' . $this->variable((string) $variadic['arg']); |
||||
} |
||||
if (($arguments['vararg'] ?? null) !== null && ($arguments['kwarg'] ?? null) !== null) { |
||||
$this->unsupported($owner, 'simultaneous *args and **kwargs cannot be represented by one PHP signature'); |
||||
} |
||||
return implode(', ', $result); |
||||
} |
||||
|
||||
/** @param array<string, mixed> $node @return list<string> */ |
||||
private function assignment(array $node): array |
||||
{ |
||||
if (count($node['targets'] ?? []) !== 1) { |
||||
$this->unsupported($node, 'chained assignments are not supported yet'); |
||||
} |
||||
$target = $node['targets'][0]; |
||||
if (in_array($target['_type'] ?? '', ['Tuple', 'List'], true)) { |
||||
$this->unsupported($node, 'destructuring assignments are not supported yet'); |
||||
} |
||||
return [$this->line($this->target($target) . ' = ' . $this->expression($node['value']) . ';')]; |
||||
} |
||||
|
||||
/** @param array<string, mixed> $node @return list<string> */ |
||||
private function expressionStatement(array $node): array |
||||
{ |
||||
$value = $node['value']; |
||||
if (($value['_type'] ?? '') === 'Constant' && is_string($value['value'] ?? null)) { |
||||
return [$this->line('/** ' . $this->safeComment($value['value']) . ' */')]; |
||||
} |
||||
return [$this->line($this->expression($value) . ';')]; |
||||
} |
||||
|
||||
/** @param array<string, mixed> $node @return list<string> */ |
||||
private function ifStatement(array $node, bool $elseif = false): array |
||||
{ |
||||
$lines = [$this->line(($elseif ? 'elseif' : 'if') . ' (' . $this->expression($node['test']) . ')'), $this->line('{')]; |
||||
$this->indent++; |
||||
foreach ($node['body'] ?? [] as $body) { |
||||
array_push($lines, ...$this->statement($body)); |
||||
} |
||||
$this->indent--; |
||||
$lines[] = $this->line('}'); |
||||
$otherwise = $node['orelse'] ?? []; |
||||
if (count($otherwise) === 1 && ($otherwise[0]['_type'] ?? '') === 'If') { |
||||
$nested = $this->ifStatement($otherwise[0], true); |
||||
$nested[0] = $this->line('elseif (' . $this->expression($otherwise[0]['test']) . ')'); |
||||
array_push($lines, ...$nested); |
||||
} elseif ($otherwise !== []) { |
||||
$lines[] = $this->line('else'); |
||||
$lines[] = $this->line('{'); |
||||
$this->indent++; |
||||
foreach ($otherwise as $body) { |
||||
array_push($lines, ...$this->statement($body)); |
||||
} |
||||
$this->indent--; |
||||
$lines[] = $this->line('}'); |
||||
} |
||||
return $lines; |
||||
} |
||||
|
||||
/** @param array<string, mixed> $node @return list<string> */ |
||||
private function whileStatement(array $node): array |
||||
{ |
||||
if (($node['orelse'] ?? []) !== []) { |
||||
$this->unsupported($node, 'while/else is not supported yet'); |
||||
} |
||||
$lines = [$this->line('while (' . $this->expression($node['test']) . ')'), $this->line('{')]; |
||||
$this->indent++; |
||||
foreach ($node['body'] ?? [] as $body) { |
||||
array_push($lines, ...$this->statement($body)); |
||||
} |
||||
$this->indent--; |
||||
$lines[] = $this->line('}'); |
||||
return $lines; |
||||
} |
||||
|
||||
/** @param array<string, mixed> $node @return list<string> */ |
||||
private function forStatement(array $node): array |
||||
{ |
||||
if (($node['orelse'] ?? []) !== []) { |
||||
$this->unsupported($node, 'for/else is not supported yet'); |
||||
} |
||||
if (($node['target']['_type'] ?? '') !== 'Name') { |
||||
$this->unsupported($node, 'only a simple for-loop target is supported'); |
||||
} |
||||
$lines = [$this->line('foreach (' . $this->expression($node['iter']) . ' as ' |
||||
. $this->variable($node['target']['id']) . ')'), $this->line('{')]; |
||||
$this->indent++; |
||||
foreach ($node['body'] ?? [] as $body) { |
||||
array_push($lines, ...$this->statement($body)); |
||||
} |
||||
$this->indent--; |
||||
$lines[] = $this->line('}'); |
||||
return $lines; |
||||
} |
||||
|
||||
/** @param array<string, mixed> $node @return list<string> */ |
||||
private function deleteStatement(array $node): array |
||||
{ |
||||
$lines = []; |
||||
foreach ($node['targets'] ?? [] as $target) { |
||||
if (!in_array($target['_type'] ?? '', ['Name', 'Attribute', 'Subscript'], true)) { |
||||
$this->unsupported($node, 'unsupported del target'); |
||||
} |
||||
$lines[] = $this->line('unset(' . $this->target($target) . ');'); |
||||
} |
||||
return $lines; |
||||
} |
||||
|
||||
/** @param array<string, mixed> $node */ |
||||
private function expression(array $node): string |
||||
{ |
||||
return match ($node['_type'] ?? '') { |
||||
'Constant' => $this->constant($node['value'] ?? null), |
||||
'Name' => $this->nameExpression((string) $node['id']), |
||||
'Attribute' => $this->attribute($node), |
||||
'Call' => $this->call($node), |
||||
'List' => 'python\\list([' . $this->expressionList($node['elts'] ?? []) . '])', |
||||
'Tuple' => 'python\\tuple([' . $this->expressionList($node['elts'] ?? []) . '])', |
||||
'Set' => 'python\\set([' . $this->expressionList($node['elts'] ?? []) . '])', |
||||
'Dict' => 'python\\dict([' . $this->dictionaryItems($node) . '])', |
||||
'BinOp' => $this->binaryExpression($node), |
||||
'UnaryOp' => $this->unaryExpression($node), |
||||
'Compare' => $this->comparison($node), |
||||
'IfExp' => '(' . $this->expression($node['test']) . ' ? ' . $this->expression($node['body']) |
||||
. ' : ' . $this->expression($node['orelse']) . ')', |
||||
'Subscript' => $this->expression($node['value']) . '[' . $this->slice($node['slice']) . ']', |
||||
'Lambda' => 'fn (' . $this->parameters($node['args'], $node) . ') => ' . $this->expression($node['body']), |
||||
'JoinedStr' => $this->joinedString($node), |
||||
'Starred' => '...' . $this->expression($node['value']), |
||||
default => $this->unsupported($node), |
||||
}; |
||||
} |
||||
|
||||
/** @param array<string, mixed> $node */ |
||||
private function call(array $node): string |
||||
{ |
||||
$function = $node['func']; |
||||
if (($function['_type'] ?? '') === 'Name') { |
||||
$name = (string) $function['id']; |
||||
if (isset($this->importedSymbols[$name])) { |
||||
$symbol = $this->importedSymbols[$name]; |
||||
$callable = 'python\\' . str_replace('.', '\\', $symbol['module']) . '\\' . $symbol['member']; |
||||
} elseif (isset($this->definedFunctions[$name])) { |
||||
$callable = $name; |
||||
} elseif ($this->isPythonBuiltin($name)) { |
||||
$callable = 'python\\' . $name; |
||||
} else { |
||||
$callable = $this->variable($name); |
||||
} |
||||
} elseif (($function['_type'] ?? '') === 'Attribute') { |
||||
$callable = $this->attribute($function); |
||||
} else { |
||||
$callable = '(' . $this->expression($function) . ')'; |
||||
} |
||||
$arguments = []; |
||||
foreach ($node['args'] ?? [] as $argument) { |
||||
$arguments[] = $this->expression($argument); |
||||
} |
||||
foreach ($node['keywords'] ?? [] as $keyword) { |
||||
$arguments[] = ($keyword['arg'] === null ? '...' : $keyword['arg'] . ': ') |
||||
. $this->expression($keyword['value']); |
||||
} |
||||
return $callable . '(' . implode(', ', $arguments) . ')'; |
||||
} |
||||
|
||||
/** @param array<string, mixed> $node */ |
||||
private function attribute(array $node): string |
||||
{ |
||||
$parts = []; |
||||
$cursor = $node; |
||||
while (($cursor['_type'] ?? '') === 'Attribute') { |
||||
array_unshift($parts, (string) $cursor['attr']); |
||||
$cursor = $cursor['value']; |
||||
} |
||||
if (($cursor['_type'] ?? '') === 'Name' && isset($this->moduleAliases[$cursor['id']])) { |
||||
return $cursor['id'] . '\\' . implode('\\', $parts); |
||||
} |
||||
$result = $this->expression($cursor); |
||||
foreach ($parts as $part) { |
||||
$result .= '->' . $part; |
||||
} |
||||
return $result; |
||||
} |
||||
|
||||
/** @param array<string, mixed> $node */ |
||||
private function binaryExpression(array $node): string |
||||
{ |
||||
$operator = $node['op']['_type'] ?? ''; |
||||
if ($operator === 'FloorDiv') { |
||||
return 'python\\operator\\floordiv(' . $this->expression($node['left']) . ', ' |
||||
. $this->expression($node['right']) . ')'; |
||||
} |
||||
if ($operator === 'MatMult') { |
||||
return 'python\\operator\\matmul(' . $this->expression($node['left']) . ', ' |
||||
. $this->expression($node['right']) . ')'; |
||||
} |
||||
return $this->expression($node['left']) . ' ' . $this->binaryOperator($node['op'], $node) |
||||
. ' ' . $this->expression($node['right']); |
||||
} |
||||
|
||||
/** @param array<string, mixed> $operator @param array<string, mixed> $owner */ |
||||
private function binaryOperator(array $operator, array $owner): string |
||||
{ |
||||
return match ($operator['_type'] ?? '') { |
||||
'Add' => '+', 'Sub' => '-', 'Mult' => '*', 'Div' => '/', 'Mod' => '%', |
||||
'Pow' => '**', 'LShift' => '<<', 'RShift' => '>>', 'BitOr' => '|', |
||||
'BitXor' => '^', 'BitAnd' => '&', |
||||
default => $this->unsupported($owner, 'unsupported binary operator ' . ($operator['_type'] ?? 'unknown')), |
||||
}; |
||||
} |
||||
|
||||
/** @param array<string, mixed> $node */ |
||||
private function unaryExpression(array $node): string |
||||
{ |
||||
$operator = match ($node['op']['_type'] ?? '') { |
||||
'USub' => '-', 'UAdd' => '+', 'Not' => '!', 'Invert' => '~', |
||||
default => $this->unsupported($node, 'unsupported unary operator'), |
||||
}; |
||||
return $operator . $this->expression($node['operand']); |
||||
} |
||||
|
||||
/** @param array<string, mixed> $node */ |
||||
private function comparison(array $node): string |
||||
{ |
||||
if (count($node['ops'] ?? []) !== 1 || count($node['comparators'] ?? []) !== 1) { |
||||
$this->unsupported($node, 'chained comparisons require explicit temporary variables'); |
||||
} |
||||
$left = $this->expression($node['left']); |
||||
$right = $this->expression($node['comparators'][0]); |
||||
return match ($node['ops'][0]['_type'] ?? '') { |
||||
'Eq' => $left . ' == ' . $right, |
||||
'NotEq' => $left . ' != ' . $right, |
||||
'Is' => $left . ' === ' . $right, |
||||
'IsNot' => $left . ' !== ' . $right, |
||||
'Lt' => $left . ' < ' . $right, |
||||
'LtE' => $left . ' <= ' . $right, |
||||
'Gt' => $left . ' > ' . $right, |
||||
'GtE' => $left . ' >= ' . $right, |
||||
'In' => 'python\\operator\\contains(' . $right . ', ' . $left . ')', |
||||
'NotIn' => '!python\\operator\\contains(' . $right . ', ' . $left . ')', |
||||
default => $this->unsupported($node, 'unsupported comparison operator'), |
||||
}; |
||||
} |
||||
|
||||
/** @param array<string, mixed> $node */ |
||||
private function target(array $node): string |
||||
{ |
||||
if (($node['_type'] ?? '') === 'Attribute' && $this->attributeStartsWithModuleAlias($node)) { |
||||
$this->unsupported($node, 'Python module attributes cannot be assigned or deleted'); |
||||
} |
||||
return match ($node['_type'] ?? '') { |
||||
'Name' => $this->variable((string) $node['id']), |
||||
'Attribute' => $this->attribute($node), |
||||
'Subscript' => $this->expression($node['value']) . '[' . $this->slice($node['slice']) . ']', |
||||
default => $this->unsupported($node, 'unsupported assignment target'), |
||||
}; |
||||
} |
||||
|
||||
private function nameExpression(string $name): string |
||||
{ |
||||
if (isset($this->moduleAliases[$name])) { |
||||
throw new RuntimeException( |
||||
"{$this->filename}: a Python module cannot be used as a first-class value in TypePHP namespace syntax", |
||||
); |
||||
} |
||||
if (isset($this->importedSymbols[$name])) { |
||||
$symbol = $this->importedSymbols[$name]; |
||||
return 'python\\' . str_replace('.', '\\', $symbol['module']) . '\\' . $symbol['member']; |
||||
} |
||||
return $this->variable($name); |
||||
} |
||||
|
||||
private function variable(string $name): string |
||||
{ |
||||
return '$' . ($name === 'this' ? 'this_' : $name); |
||||
} |
||||
|
||||
private function constant(mixed $value): string |
||||
{ |
||||
if (is_array($value) && isset($value['_python_constant'])) { |
||||
throw new RuntimeException("{$this->filename}: Python {$value['_python_constant']} literals are not supported yet"); |
||||
} |
||||
if ($value === null) { |
||||
return 'null'; |
||||
} |
||||
if (is_bool($value)) { |
||||
return $value ? 'true' : 'false'; |
||||
} |
||||
return var_export($value, true); |
||||
} |
||||
|
||||
/** @param list<array<string, mixed>> $nodes */ |
||||
private function expressionList(array $nodes): string |
||||
{ |
||||
$expressions = []; |
||||
foreach ($nodes as $node) { |
||||
$expressions[] = $this->expression($node); |
||||
} |
||||
return implode(', ', $expressions); |
||||
} |
||||
|
||||
/** @param array<string, mixed> $node */ |
||||
private function dictionaryItems(array $node): string |
||||
{ |
||||
$items = []; |
||||
foreach ($node['keys'] ?? [] as $index => $key) { |
||||
if ($key === null) { |
||||
$items[] = '...' . $this->expression($node['values'][$index]); |
||||
} else { |
||||
$items[] = $this->expression($key) . ' => ' . $this->expression($node['values'][$index]); |
||||
} |
||||
} |
||||
return implode(', ', $items); |
||||
} |
||||
|
||||
/** @param array<string, mixed> $node */ |
||||
private function slice(array $node): string |
||||
{ |
||||
if (($node['_type'] ?? '') !== 'Slice') { |
||||
return $this->expression($node); |
||||
} |
||||
return 'python\\slice(' |
||||
. (($node['lower'] ?? null) === null ? 'null' : $this->expression($node['lower'])) . ', ' |
||||
. (($node['upper'] ?? null) === null ? 'null' : $this->expression($node['upper'])) . ', ' |
||||
. (($node['step'] ?? null) === null ? 'null' : $this->expression($node['step'])) . ')'; |
||||
} |
||||
|
||||
/** @param array<string, mixed> $node */ |
||||
private function joinedString(array $node): string |
||||
{ |
||||
$parts = []; |
||||
foreach ($node['values'] ?? [] as $value) { |
||||
if (($value['_type'] ?? '') === 'FormattedValue') { |
||||
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()'; |
||||
} else { |
||||
$parts[] = $this->expression($value); |
||||
} |
||||
} |
||||
return $parts === [] ? "''" : implode(' . ', $parts); |
||||
} |
||||
|
||||
private function isPythonBuiltin(string $name): bool |
||||
{ |
||||
static $builtins = [ |
||||
'abs', 'all', 'any', 'bool', 'bytes', 'callable', 'dict', 'dir', 'enumerate', |
||||
'filter', 'float', 'getattr', 'hasattr', 'int', 'isinstance', 'issubclass', 'iter', |
||||
'len', 'list', 'map', 'max', 'min', 'next', 'object', 'open', 'ord', 'pow', 'print', |
||||
'range', 'repr', 'reversed', 'round', 'set', 'setattr', 'slice', 'sorted', 'str', |
||||
'sum', 'tuple', 'type', 'vars', 'zip', |
||||
]; |
||||
return in_array($name, $builtins, true); |
||||
} |
||||
|
||||
/** @param array<string, mixed> $function @return array<string, true> */ |
||||
private function functionLocalNames(array $function): array |
||||
{ |
||||
$locals = []; |
||||
$globals = []; |
||||
$arguments = $function['args'] ?? []; |
||||
foreach (array_merge($arguments['posonlyargs'] ?? [], $arguments['args'] ?? [], $arguments['kwonlyargs'] ?? []) as $argument) { |
||||
$locals[(string) $argument['arg']] = true; |
||||
} |
||||
foreach (['vararg', 'kwarg'] as $kind) { |
||||
if (($arguments[$kind] ?? null) !== null) { |
||||
$locals[(string) $arguments[$kind]['arg']] = true; |
||||
} |
||||
} |
||||
$stack = array_reverse($function['body'] ?? []); |
||||
while ($stack !== []) { |
||||
$value = array_pop($stack); |
||||
if (!is_array($value)) { |
||||
continue; |
||||
} |
||||
if (($value['_type'] ?? '') === 'FunctionDef') { |
||||
continue; |
||||
} |
||||
if (($value['_type'] ?? '') === 'Global') { |
||||
foreach ($value['names'] ?? [] as $name) { |
||||
$globals[(string) $name] = true; |
||||
} |
||||
continue; |
||||
} |
||||
if (($value['_type'] ?? '') === 'Name' && ($value['ctx']['_type'] ?? '') === 'Store') { |
||||
$locals[(string) $value['id']] = true; |
||||
} |
||||
foreach ($value as $item) { |
||||
if (is_array($item)) { |
||||
$stack[] = $item; |
||||
} |
||||
} |
||||
} |
||||
foreach ($globals as $name => $_) { |
||||
unset($locals[$name]); |
||||
} |
||||
return $locals; |
||||
} |
||||
|
||||
/** @param list<string> $names @return list<string> */ |
||||
private function variables(array $names): array |
||||
{ |
||||
$variables = []; |
||||
foreach ($names as $name) { |
||||
$variables[] = $this->variable((string) $name); |
||||
} |
||||
return $variables; |
||||
} |
||||
|
||||
/** @param array<string, mixed> $node */ |
||||
private function attributeStartsWithModuleAlias(array $node): bool |
||||
{ |
||||
$cursor = $node; |
||||
while (($cursor['_type'] ?? '') === 'Attribute') { |
||||
$cursor = $cursor['value']; |
||||
} |
||||
return ($cursor['_type'] ?? '') === 'Name' && isset($this->moduleAliases[$cursor['id']]); |
||||
} |
||||
|
||||
private function line(string $code): string |
||||
{ |
||||
return str_repeat(' ', $this->indent) . $code; |
||||
} |
||||
|
||||
private function safeComment(string $value): string |
||||
{ |
||||
return str_replace(['*/', "\r", "\n"], ['* /', ' ', ' '], $value); |
||||
} |
||||
|
||||
/** @param array<string, mixed> $node */ |
||||
private function unsupported(array $node, ?string $detail = null): never |
||||
{ |
||||
$line = (int) ($node['lineno'] ?? 0); |
||||
$type = (string) ($node['_type'] ?? 'unknown'); |
||||
$message = "{$this->filename}:{$line}: unsupported Python syntax {$type}"; |
||||
if ($detail !== null) { |
||||
$message .= ": {$detail}"; |
||||
} |
||||
throw new RuntimeException($message); |
||||
} |
||||
} |
||||
@ -0,0 +1,176 @@ |
||||
<?php |
||||
|
||||
namespace TypePhp\PythonTools\IdeHelper; |
||||
|
||||
final class HelperRenderer |
||||
{ |
||||
/** @var array<string, true> */ |
||||
private const RESERVED = [ |
||||
'__halt_compiler' => true, 'abstract' => true, 'and' => true, 'array' => true, |
||||
'as' => true, 'bool' => true, 'break' => true, 'callable' => true, 'case' => true, |
||||
'catch' => true, 'class' => true, 'clone' => true, 'const' => true, 'continue' => true, |
||||
'declare' => true, 'default' => true, 'die' => true, 'do' => true, 'echo' => true, |
||||
'else' => true, 'elseif' => true, 'empty' => true, 'enddeclare' => true, |
||||
'endfor' => true, 'endforeach' => true, 'endif' => true, 'endswitch' => true, |
||||
'endwhile' => true, 'enum' => true, 'eval' => true, 'exit' => true, 'extends' => true, |
||||
'false' => true, 'final' => true, 'finally' => true, 'float' => true, 'fn' => true, |
||||
'for' => true, 'foreach' => true, 'from' => true, 'function' => true, 'global' => true, |
||||
'goto' => true, 'if' => true, 'implements' => true, 'include' => true, |
||||
'include_once' => true, 'instanceof' => true, 'insteadof' => true, 'int' => true, |
||||
'interface' => true, 'isset' => true, 'iterable' => true, 'list' => true, |
||||
'match' => true, 'mixed' => true, 'namespace' => true, 'never' => true, 'new' => true, |
||||
'null' => true, 'object' => true, 'or' => true, 'parent' => true, 'print' => true, |
||||
'private' => true, 'protected' => true, 'public' => true, 'readonly' => true, |
||||
'require' => true, 'require_once' => true, 'resource' => true, 'return' => true, |
||||
'self' => true, 'static' => true, 'string' => true, 'switch' => true, 'throw' => true, |
||||
'trait' => true, 'true' => true, 'try' => true, 'unset' => true, 'use' => true, |
||||
'var' => true, 'void' => true, 'while' => true, 'xor' => true, 'yield' => true, |
||||
]; |
||||
|
||||
/** |
||||
* Render declarations inside an unreachable branch. IDEs can index them, |
||||
* while accidentally including the helper has no runtime side effects. |
||||
* |
||||
* @param array<string, mixed> $metadata |
||||
*/ |
||||
public function render(array $metadata): string |
||||
{ |
||||
$module = (string) $metadata['module']; |
||||
$namespace = $module === 'builtins' |
||||
? 'python' |
||||
: 'python\\' . str_replace('.', '\\', $module); |
||||
$lines = [ |
||||
'<?php', |
||||
'', |
||||
'/**', |
||||
' * @generated TypePHP Python IDE helper.', |
||||
' * This file is for IDE indexing and must not be executed or compiled.', |
||||
' */', |
||||
'', |
||||
'namespace ' . $namespace . ';', |
||||
'', |
||||
]; |
||||
|
||||
$seenFunctions = []; |
||||
foreach ($metadata['attributes'] ?? [] as $attribute) { |
||||
$name = (string) ($attribute['name'] ?? ''); |
||||
if (!$this->isDeclarableName($name)) { |
||||
$lines[] = '// Omitted Python attribute with an invalid PHP identifier: ' . $this->comment($name); |
||||
continue; |
||||
} |
||||
$lines[] = 'const ' . $name . ' = new \\PyObject();'; |
||||
$lines[] = ''; |
||||
} |
||||
|
||||
foreach ($metadata['functions'] ?? [] as $function) { |
||||
$name = (string) ($function['name'] ?? ''); |
||||
$folded = strtolower($name); |
||||
if (!$this->isDeclarableName($name) || isset($seenFunctions[$folded])) { |
||||
$lines[] = '// Omitted Python callable not representable as a PHP function: ' . $this->comment($name); |
||||
continue; |
||||
} |
||||
$seenFunctions[$folded] = true; |
||||
$lines[] = 'function ' . $name . '(' . $this->renderParameters($function['parameters'] ?? []) |
||||
. '): \\PyObject ' . $this->unreachableBody(); |
||||
$lines[] = ''; |
||||
} |
||||
|
||||
$seenClasses = []; |
||||
foreach ($metadata['classes'] ?? [] as $class) { |
||||
$name = (string) ($class['name'] ?? ''); |
||||
$folded = strtolower($name); |
||||
if (!$this->isDeclarableName($name) || isset($seenClasses[$folded])) { |
||||
$lines[] = '// Omitted Python class not representable as a PHP class: ' . $this->comment($name); |
||||
continue; |
||||
} |
||||
$seenClasses[$folded] = true; |
||||
if (!isset($seenFunctions[$folded])) { |
||||
$seenFunctions[$folded] = true; |
||||
$lines[] = 'function ' . $name . '(' . $this->renderParameters($class['parameters'] ?? []) |
||||
. '): ' . $name . ' ' . $this->unreachableBody(); |
||||
$lines[] = ''; |
||||
} |
||||
$properties = $class['properties'] ?? []; |
||||
if ($properties !== []) { |
||||
$lines[] = '/**'; |
||||
foreach ($properties as $property) { |
||||
if ($this->isValidIdentifier((string) $property)) { |
||||
$lines[] = ' * @property \\PyObject $' . $property; |
||||
} |
||||
} |
||||
$lines[] = ' */'; |
||||
} |
||||
$lines[] = 'class ' . $name . ' extends \\PyObject'; |
||||
$lines[] = '{'; |
||||
$lines[] = ' public function __construct(' . $this->renderParameters($class['parameters'] ?? []) |
||||
. ') { parent::__construct(); }'; |
||||
$seenMethods = []; |
||||
foreach ($class['methods'] ?? [] as $method) { |
||||
$methodName = (string) ($method['name'] ?? ''); |
||||
$methodFolded = strtolower($methodName); |
||||
if ($methodFolded === 'count') { |
||||
$lines[] = " // Python count() conflicts with PyObject::count(); use __call('count', [...])."; |
||||
continue; |
||||
} |
||||
if (!$this->isValidIdentifier($methodName) || isset($seenMethods[$methodFolded])) { |
||||
continue; |
||||
} |
||||
$seenMethods[$methodFolded] = true; |
||||
$lines[] = ' public function ' . $methodName . '(' |
||||
. $this->renderParameters($method['parameters'] ?? []) . '): \\PyObject ' |
||||
. $this->unreachableBody(); |
||||
} |
||||
$lines[] = '}'; |
||||
$lines[] = ''; |
||||
} |
||||
|
||||
$lines[] = 'die(\\PyObject::IDE_HELPER_ONLY);'; |
||||
$lines[] = ''; |
||||
return implode(PHP_EOL, $lines); |
||||
} |
||||
|
||||
/** @param list<array{name: string, optional?: bool, variadic?: bool}> $parameters */ |
||||
private function renderParameters(array $parameters): string |
||||
{ |
||||
$regular = []; |
||||
$variadic = null; |
||||
$optionalSeen = false; |
||||
foreach ($parameters as $index => $parameter) { |
||||
$name = (string) ($parameter['name'] ?? ('arg' . $index)); |
||||
if (!$this->isValidIdentifier($name) || $name === 'this') { |
||||
$name = 'arg' . $index; |
||||
} |
||||
if (!empty($parameter['variadic'])) { |
||||
$variadic ??= 'mixed ...$' . $name; |
||||
continue; |
||||
} |
||||
$optional = $optionalSeen || !empty($parameter['optional']); |
||||
$optionalSeen = $optional; |
||||
$regular[] = 'mixed $' . $name . ($optional ? ' = null' : ''); |
||||
} |
||||
if ($variadic !== null) { |
||||
$regular[] = $variadic; |
||||
} |
||||
return implode(', ', $regular); |
||||
} |
||||
|
||||
private function isDeclarableName(string $name): bool |
||||
{ |
||||
return $this->isValidIdentifier($name) && !isset(self::RESERVED[strtolower($name)]); |
||||
} |
||||
|
||||
private function isValidIdentifier(string $name): bool |
||||
{ |
||||
return preg_match('/^[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*$/D', $name) === 1; |
||||
} |
||||
|
||||
private function comment(string $value): string |
||||
{ |
||||
return str_replace(["\r", "\n", '*/'], [' ', ' ', '* /'], $value); |
||||
} |
||||
|
||||
private function unreachableBody(): string |
||||
{ |
||||
return '{ die(\\PyObject::IDE_HELPER_ONLY); }'; |
||||
} |
||||
} |
||||
@ -0,0 +1,120 @@ |
||||
<?php |
||||
|
||||
namespace TypePhp\PythonTools\IdeHelper; |
||||
|
||||
use JsonException; |
||||
use RuntimeException; |
||||
use Throwable; |
||||
|
||||
final class PhpyModuleScanner |
||||
{ |
||||
private const INSPECTOR = <<<'PYTHON' |
||||
import importlib |
||||
import inspect |
||||
import json |
||||
|
||||
def typephp_parameters(value): |
||||
try: |
||||
result = [] |
||||
for parameter in inspect.signature(value).parameters.values(): |
||||
kind = str(parameter.kind) |
||||
result.append({ |
||||
'name': parameter.name, |
||||
'optional': parameter.default is not inspect._empty or kind == 'KEYWORD_ONLY', |
||||
'variadic': kind in ('VAR_POSITIONAL', 'VAR_KEYWORD'), |
||||
}) |
||||
return result |
||||
except Exception: |
||||
return [{'name': 'args', 'optional': True, 'variadic': True}] |
||||
|
||||
def typephp_class(name, value): |
||||
methods = [] |
||||
properties = [] |
||||
for member in dir(value): |
||||
if not member or member.startswith('_'): |
||||
continue |
||||
try: |
||||
item = getattr(value, member) |
||||
if inspect.isroutine(item): |
||||
parameters = typephp_parameters(item) |
||||
if parameters and parameters[0]['name'] in ('self', 'cls'): |
||||
parameters.pop(0) |
||||
methods.append({'name': member, 'parameters': parameters}) |
||||
else: |
||||
properties.append(member) |
||||
except Exception: |
||||
pass |
||||
return { |
||||
'name': name, |
||||
'parameters': typephp_parameters(value), |
||||
'methods': methods, |
||||
'properties': properties, |
||||
} |
||||
|
||||
module = importlib.import_module(module_name) |
||||
metadata = { |
||||
'module': module_name, |
||||
'doc': getattr(module, '__doc__', '') or '', |
||||
'attributes': [], |
||||
'functions': [], |
||||
'classes': [], |
||||
} |
||||
for name in dir(module): |
||||
if not name or name.startswith('_'): |
||||
continue |
||||
try: |
||||
value = getattr(module, name) |
||||
if inspect.isclass(value): |
||||
metadata['classes'].append(typephp_class(name, value)) |
||||
elif inspect.isroutine(value): |
||||
metadata['functions'].append({ |
||||
'name': name, |
||||
'parameters': typephp_parameters(value), |
||||
}) |
||||
else: |
||||
metadata['attributes'].append({'name': name}) |
||||
except Exception: |
||||
pass |
||||
|
||||
metadata_json = json.dumps(metadata, ensure_ascii=False) |
||||
PYTHON; |
||||
|
||||
/** @return array<string, mixed> */ |
||||
public function scan(string $moduleName): array |
||||
{ |
||||
if (!extension_loaded('phpy')) { |
||||
throw new RuntimeException('The phpy extension is required to generate a Python IDE helper'); |
||||
} |
||||
if (preg_match('/^[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*$/D', $moduleName) !== 1) { |
||||
throw new RuntimeException("Invalid Python module name: {$moduleName}"); |
||||
} |
||||
|
||||
try { |
||||
// Perform reflection entirely in Python. Besides reducing boundary |
||||
// crossings, this avoids retaining PHPy's short-lived Zend method |
||||
// trampolines at AOT call sites. |
||||
$result = \PyCore::eval(self::INSPECTOR, ['module_name' => $moduleName]); |
||||
$json = \PyCore::scalar($result->metadata_json); |
||||
if (!is_string($json)) { |
||||
throw new RuntimeException('Python inspector returned a non-string result'); |
||||
} |
||||
$metadata = json_decode($json, true, 512, JSON_THROW_ON_ERROR); |
||||
if (!is_array($metadata)) { |
||||
throw new RuntimeException('Python inspector returned invalid metadata'); |
||||
} |
||||
return $metadata; |
||||
} catch (JsonException $exception) { |
||||
throw new RuntimeException( |
||||
"Unable to decode metadata for Python module `{$moduleName}`: {$exception->getMessage()}", |
||||
0, |
||||
$exception, |
||||
); |
||||
} catch (Throwable $exception) { |
||||
throw new RuntimeException( |
||||
"Unable to inspect Python module `{$moduleName}`: {$exception->getMessage()}", |
||||
0, |
||||
$exception, |
||||
); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,65 @@ |
||||
<?php |
||||
|
||||
namespace TypePhp\PythonTools\IdeHelper; |
||||
|
||||
final class PyObjectHelperRenderer |
||||
{ |
||||
public function render(): string |
||||
{ |
||||
$die = '{ die(self::IDE_HELPER_ONLY); }'; |
||||
$lines = [ |
||||
'<?php', |
||||
'', |
||||
'/**', |
||||
' * @generated TypePHP Python IDE helper.', |
||||
' * This file is for IDE indexing and must not be executed or compiled.', |
||||
' */', |
||||
'', |
||||
'class PyObject implements \\ArrayAccess, \\Iterator, \\Countable', |
||||
'{', |
||||
" public const IDE_HELPER_ONLY = 'IDE helper only';", |
||||
'', |
||||
' public function __construct(mixed $value = null) {}', |
||||
' public function __call(string $name, array $arguments): mixed ' . $die, |
||||
' public function __get(string $name): mixed ' . $die, |
||||
' public function __set(string $name, mixed $value): void {}', |
||||
' public function __unset(string $name): void {}', |
||||
' public function __toString(): string ' . $die, |
||||
' public function toArray(): array ' . $die, |
||||
' public function toValue(): mixed ' . $die, |
||||
'', |
||||
' /*', |
||||
' * TypePHP keyword methods are compiler intrinsics.', |
||||
' * They do not exist on the runtime PyObject class.', |
||||
' */', |
||||
' public function toInt(): int ' . $die, |
||||
' public function toFloat(): float ' . $die, |
||||
' public function toString(): string ' . $die, |
||||
' public function toBool(): bool ' . $die, |
||||
' public function toStream(): mixed ' . $die, |
||||
' public function toBigInt(): mixed ' . $die, |
||||
' public function toBigFloat(): mixed ' . $die, |
||||
' public function toDecimal(): mixed ' . $die, |
||||
' public function toObject(?string $class = null): object ' . $die, |
||||
' public function toAny(): mixed ' . $die, |
||||
' public function toRef(): mixed ' . $die, |
||||
'', |
||||
' public function __invoke(mixed ...$arguments): mixed ' . $die, |
||||
' public function offsetGet(mixed $offset): mixed ' . $die, |
||||
' public function offsetSet(mixed $offset, mixed $value): void {}', |
||||
' public function offsetUnset(mixed $offset): void {}', |
||||
' public function offsetExists(mixed $offset): bool ' . $die, |
||||
' public function key(): mixed ' . $die, |
||||
' public function next(): void {}', |
||||
' public function rewind(): void {}', |
||||
' public function valid(): bool ' . $die, |
||||
' public function current(): mixed ' . $die, |
||||
' public function count(): int ' . $die, |
||||
'}', |
||||
'die(PyObject::IDE_HELPER_ONLY);', |
||||
'', |
||||
]; |
||||
|
||||
return implode(PHP_EOL, $lines); |
||||
} |
||||
} |
||||
@ -0,0 +1,41 @@ |
||||
--TEST-- |
||||
PyObject toArray() and toValue() explicitly return PHP values |
||||
--SKIPIF-- |
||||
<?php |
||||
if (!extension_loaded('phpy')) { |
||||
die('skip phpy extension is not loaded'); |
||||
} |
||||
?> |
||||
--FILE-- |
||||
<?php |
||||
|
||||
function main(): void |
||||
{ |
||||
$list = python\list([1, 2, 3]); |
||||
$integer = python\int(42); |
||||
|
||||
var_dump($list->toArray()); |
||||
var_dump($list->toValue()); |
||||
var_dump($integer->toValue()); |
||||
var_dump($integer->toValue()->toInt()); |
||||
} |
||||
?> |
||||
--EXPECT-- |
||||
array(3) { |
||||
[0]=> |
||||
int(1) |
||||
[1]=> |
||||
int(2) |
||||
[2]=> |
||||
int(3) |
||||
} |
||||
array(3) { |
||||
[0]=> |
||||
int(1) |
||||
[1]=> |
||||
int(2) |
||||
[2]=> |
||||
int(3) |
||||
} |
||||
int(42) |
||||
int(42) |
||||
@ -1,53 +0,0 @@ |
||||
--TEST-- |
||||
PyObject toPlainValue() explicitly returns a PHP value |
||||
--SKIPIF-- |
||||
<?php |
||||
if (!extension_loaded('phpy')) { |
||||
die('skip phpy extension is not loaded'); |
||||
} |
||||
?> |
||||
--FILE-- |
||||
<?php |
||||
|
||||
function main(): void |
||||
{ |
||||
$list = python\list([1, 2, 3]); |
||||
$integer = python\int(42); |
||||
|
||||
var_dump(toPlainValue($list)); |
||||
var_dump(toPlainValue($list)->toArray()); |
||||
var_dump($integer->toPlainValue()); |
||||
var_dump($integer->toPlainValue()->toInt()); |
||||
|
||||
try { |
||||
toPlainValue(new stdClass()); |
||||
} catch (Error $error) { |
||||
var_dump(str_contains($error->getMessage(), 'supports PyObject only')); |
||||
} |
||||
} |
||||
|
||||
function toPlainValue(mixed $value): mixed |
||||
{ |
||||
return $value->toPlainValue(); |
||||
} |
||||
?> |
||||
--EXPECT-- |
||||
array(3) { |
||||
[0]=> |
||||
int(1) |
||||
[1]=> |
||||
int(2) |
||||
[2]=> |
||||
int(3) |
||||
} |
||||
array(3) { |
||||
[0]=> |
||||
int(1) |
||||
[1]=> |
||||
int(2) |
||||
[2]=> |
||||
int(3) |
||||
} |
||||
int(42) |
||||
int(42) |
||||
bool(true) |
||||
Loading…
Reference in new issue