From 069d3e61bb5a26fb8b4a043fd69eaa49c09ad9c6 Mon Sep 17 00:00:00 2001 From: tianfenghan Date: Sun, 9 Aug 2026 19:20:07 +0800 Subject: [PATCH] =?UTF-8?q?Python=20=E4=BA=92=E8=B0=83=E7=94=A8=E6=94=AF?= =?UTF-8?q?=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- phpunit/code/python/alias-conflict.php | 8 + phpunit/code/python/builtins.php | 21 + phpunit/code/python/constructor-only.php | 7 + phpunit/code/python/duplicate-module.php | 8 + phpunit/code/python/invalid-builtin-path.php | 6 + phpunit/code/python/module-access.php | 18 + phpunit/code/python/module-constant.php | 12 + phpunit/code/python/nested-module.php | 12 + phpunit/code/python/object-protocol.php | 24 + phpunit/code/python/operators.php | 15 + phpunit/code/python/plain-value-arguments.php | 10 + phpunit/code/python/plain-value.php | 15 + phpunit/code/python/unused-module.php | 8 + .../code/typed-object-unset-assign-null.php | 3 + phpunit/src/AssignTest.php | 7 +- phpunit/src/Python/PythonModuleTest.php | 238 +++++ python/design.md | 936 ++++++++++++++++++ python/implementation-plan.md | 91 ++ src/CompilerBase.php | 68 +- src/Generator/ClosureGenerator.php | 17 +- src/Parser/AssignOpTrait.php | 31 +- src/Parser/BinaryOpTrait.php | 76 +- src/Parser/ClassConstantFetchTrait.php | 21 +- src/Parser/FunctionCallTrait.php | 5 + src/Parser/MethodCallTrait.php | 15 +- src/Parser/PropertyAccessTrait.php | 5 + src/Parser/TypeConversionTrait.php | 4 + src/Parser/UnaryExpressionTrait.php | 16 + src/Parser/UniversalMethodCall.php | 1 + src/Python/PythonModuleTrait.php | 652 ++++++++++++ src/Resolver/DeclarationSymbolTrait.php | 8 +- src/Translator.php | 11 + .../basic/unset-typed-object-reassign.phpt | 8 + .../basic/unset-typed-object-state.phpt | 18 +- .../compiler/python/argument-conversion.phpt | 40 + .../python/argument-order-callable.phpt | 41 + tests/compiler/python/builtin-errors.phpt | 29 + tests/compiler/python/builtins.phpt | 54 + .../python/case-sensitive-builtin.phpt | 22 + .../python/constructor-method-return.phpt | 22 + tests/compiler/python/conversion-errors.phpt | 31 + tests/compiler/python/empty.ini | 1 + tests/compiler/python/lib/typephp_protocol.py | 17 + tests/compiler/python/missing-phpy.phpt | 19 + tests/compiler/python/module-access.phpt | 19 + .../compiler/python/module-return-object.phpt | 23 + tests/compiler/python/object-protocol.phpt | 99 ++ tests/compiler/python/operators.phpt | 149 +++ tests/compiler/python/plain-value.phpt | 53 + tests/compiler/python/unused-module.phpt | 13 + .../trait/trait-constant-array-spread.phpt | 36 + 51 files changed, 2988 insertions(+), 75 deletions(-) create mode 100644 phpunit/code/python/alias-conflict.php create mode 100644 phpunit/code/python/builtins.php create mode 100644 phpunit/code/python/constructor-only.php create mode 100644 phpunit/code/python/duplicate-module.php create mode 100644 phpunit/code/python/invalid-builtin-path.php create mode 100644 phpunit/code/python/module-access.php create mode 100644 phpunit/code/python/module-constant.php create mode 100644 phpunit/code/python/nested-module.php create mode 100644 phpunit/code/python/object-protocol.php create mode 100644 phpunit/code/python/operators.php create mode 100644 phpunit/code/python/plain-value-arguments.php create mode 100644 phpunit/code/python/plain-value.php create mode 100644 phpunit/code/python/unused-module.php create mode 100644 phpunit/src/Python/PythonModuleTest.php create mode 100644 python/design.md create mode 100644 python/implementation-plan.md create mode 100644 src/Python/PythonModuleTrait.php create mode 100644 tests/compiler/python/argument-conversion.phpt create mode 100644 tests/compiler/python/argument-order-callable.phpt create mode 100644 tests/compiler/python/builtin-errors.phpt create mode 100644 tests/compiler/python/builtins.phpt create mode 100644 tests/compiler/python/case-sensitive-builtin.phpt create mode 100644 tests/compiler/python/constructor-method-return.phpt create mode 100644 tests/compiler/python/conversion-errors.phpt create mode 100644 tests/compiler/python/empty.ini create mode 100644 tests/compiler/python/lib/typephp_protocol.py create mode 100644 tests/compiler/python/missing-phpy.phpt create mode 100644 tests/compiler/python/module-access.phpt create mode 100644 tests/compiler/python/module-return-object.phpt create mode 100644 tests/compiler/python/object-protocol.phpt create mode 100644 tests/compiler/python/operators.phpt create mode 100644 tests/compiler/python/plain-value.phpt create mode 100644 tests/compiler/python/unused-module.phpt create mode 100644 tests/compiler/trait/trait-constant-array-spread.phpt diff --git a/phpunit/code/python/alias-conflict.php b/phpunit/code/python/alias-conflict.php new file mode 100644 index 00000000..a57b318c --- /dev/null +++ b/phpunit/code/python/alias-conflict.php @@ -0,0 +1,8 @@ + 42]); + $tuple = PYTHON\tuple([1, 2]); + $set = python\set([1, 2]); + $str = python\str(123); + $int = python\int('42'); + $object = python\object('value'); + $bytes = python\bytes('value'); + $value = python\len($list); + $scalar = python\scalar($int)->toInt(); + + python\print($dict, $tuple, $set, $str, $int, $object, $bytes, $value, $scalar); +} + +function main(): void +{ +} diff --git a/phpunit/code/python/constructor-only.php b/phpunit/code/python/constructor-only.php new file mode 100644 index 00000000..0343180d --- /dev/null +++ b/phpunit/code/python/constructor-only.php @@ -0,0 +1,7 @@ +append('/tmp'); + $object = protocol::make(); + $object->name = 'value'; + $value = $object->child->method(suffix: '!'); + $integer = $object->toInt(); + $item = $object[0]; + $object[1] = $value; + $object[0] += python\int(1); + $object->counter += python\int(1); + unset($object[2]); + $exists = isset($object[3]); + foreach ($object as $key => $entry) { + echo $key, $entry; + } + $result = $object(1, right: 2); + var_dump($integer); +} diff --git a/phpunit/code/python/operators.php b/phpunit/code/python/operators.php new file mode 100644 index 00000000..b1b74c46 --- /dev/null +++ b/phpunit/code/python/operators.php @@ -0,0 +1,15 @@ +toPlainValue(1); +} + +function main(): void +{ +} diff --git a/phpunit/code/python/plain-value.php b/phpunit/code/python/plain-value.php new file mode 100644 index 00000000..d2e1f2a8 --- /dev/null +++ b/phpunit/code/python/plain-value.php @@ -0,0 +1,15 @@ +toPlainValue(); + $dynamic = convertPlainValue($list); + $array = $dynamic->toArray(); + var_dump($plain, $array); +} + +function convertPlainValue(mixed $value): mixed +{ + return $value->toPlainValue(); +} diff --git a/phpunit/code/python/unused-module.php b/phpunit/code/python/unused-module.php new file mode 100644 index 00000000..d96a5ef1 --- /dev/null +++ b/phpunit/code/python/unused-module.php @@ -0,0 +1,8 @@ +exec( - 'Cannot assign null to typed object `$value` of type `TypedObjectUnsetNullValue`; use unset() to clear it', - 'typed-object-unset-assign-null.php' - ); + $this->compile('typed-object-unset-assign-null.php'); } public function testCannotAssignUnrelatedObjectToInterfaceDeclaredObject() diff --git a/phpunit/src/Python/PythonModuleTest.php b/phpunit/src/Python/PythonModuleTest.php new file mode 100644 index 00000000..ecceeb5b --- /dev/null +++ b/phpunit/src/Python/PythonModuleTest.php @@ -0,0 +1,238 @@ +addFiles([$source]); + $compiler->prepareFile($source); + $cppFile = $compiler->convertFile($source); + $cpp = file_get_contents($cppFile); + $extensionFile = $compiler->genExtension(); + $extension = file_get_contents($extensionFile); + + $this->assertStringContainsString('php_get_python_module(', $cpp); + $this->assertStringContainsString('.attr(', $cpp); + $this->assertStringContainsString('.call(', $cpp); + $this->assertStringContainsString('THREAD_LOCAL zval php_python_module_map[1]', $extension); + $this->assertStringContainsString('php::Object php_get_python_module(', $extension); + $this->assertStringContainsString('zval_ptr_dtor(', $extension); + $this->assertStringContainsString('PyCore', $extension); + $this->assertStringContainsString('import', $extension); + $this->assertStringNotContainsString('#include assertStringNotContainsString('phpy::', $extension); + $this->assertStringNotContainsString('module_that_does_not_exist', $extension); + } + + public function testUnusedPythonUseDoesNotGenerateModuleRuntimeState(): void + { + global $translator; + $compiler = CompilerTest::create(ROOT_PATH); + $translator = $compiler; + $source = ROOT_PATH . '/phpunit/code/python/unused-module.php'; + $compiler->addFiles([$source]); + $compiler->prepareFile($source); + $compiler->convertFile($source); + $extensionFile = $compiler->genExtension(); + $extension = file_get_contents($extensionFile); + + $this->assertStringNotContainsString('php_python_module_map', $extension); + $this->assertStringNotContainsString('php_get_python_module', $extension); + $this->assertStringNotContainsString('module_that_does_not_exist', $extension); + } + + public function testModuleValueCannotUsePhpClassConstantSyntax(): void + { + $this->expectException(TestError::class); + $this->expectExceptionMessage('Python module value `math::pi` must use `math::$pi`'); + + $this->compileFixture('module-constant.php'); + } + + public function testPythonModuleAliasConflictsCaseInsensitivelyWithClassAlias(): void + { + $this->expectException(Error::class); + $this->expectExceptionMessage('the name is already in use'); + + $this->compileFixture('alias-conflict.php'); + } + + public function testNestedModuleUsesPythonDottedImportName(): void + { + global $translator; + $compiler = CompilerTest::create(ROOT_PATH); + $translator = $compiler; + $source = ROOT_PATH . '/phpunit/code/python/nested-module.php'; + $compiler->addFiles([$source]); + $compiler->prepareFile($source); + $compiler->convertFile($source); + $extension = file_get_contents($compiler->genExtension()); + + $this->assertStringContainsString('numpy.linalg', $extension); + $this->assertStringNotContainsString('numpy\\\\linalg', $extension); + } + + public function testSameModuleAcrossFilesUsesOneRuntimeSlot(): void + { + global $translator; + $compiler = CompilerTest::create(ROOT_PATH); + $translator = $compiler; + $sources = [ + ROOT_PATH . '/phpunit/code/python/module-access.php', + ROOT_PATH . '/phpunit/code/python/duplicate-module.php', + ]; + $compiler->addFiles($sources); + foreach ($sources as $source) { + $compiler->prepareFile($source); + $compiler->convertFile($source); + } + $extension = file_get_contents($compiler->genExtension()); + + $this->assertStringContainsString('THREAD_LOCAL zval php_python_module_map[1]', $extension); + } + + public function testPythonBuiltinsUseBuiltinsModuleAndPreserveKnownObjectTypes(): void + { + global $translator; + $compiler = CompilerTest::create(ROOT_PATH); + $translator = $compiler; + $source = ROOT_PATH . '/phpunit/code/python/builtins.php'; + $compiler->addFiles([$source]); + $compiler->prepareFile($source); + $cpp = file_get_contents($compiler->convertFile($source)); + $extension = file_get_contents($compiler->genExtension()); + + $this->assertStringContainsString('php_get_python_module(', $cpp); + $this->assertStringContainsString('.call(', $cpp); + $this->assertStringContainsString('php::Object list;', $cpp); + $this->assertStringContainsString('php::Object dict;', $cpp); + $this->assertStringContainsString('php::Object tuple;', $cpp); + $this->assertStringContainsString('php::Object set;', $cpp); + $this->assertStringContainsString('php::Object str;', $cpp); + $this->assertStringContainsString('php::Object _php__var__int;', $cpp); + $this->assertStringContainsString('php::Object object;', $cpp); + $this->assertStringContainsString('php::Object bytes;', $cpp); + $this->assertStringContainsString('scalar = php::toInt(', $cpp); + $this->assertStringContainsString('php::newObject(', $cpp); + $this->assertStringContainsString('PyList', $extension); + $this->assertStringContainsString('PyDict', $extension); + $this->assertStringContainsString('THREAD_LOCAL zval php_python_module_map[1]', $extension); + $this->assertStringContainsString('builtins', $extension); + $this->assertStringContainsString('PyCore::setOptions', $extension); + $this->assertStringContainsString('return_as_object', $extension); + $this->assertStringContainsString('php_python_runtime_configured = false;', $extension); + $this->assertStringNotContainsString('python\\\\list', $extension); + } + + public function testPythonBuiltinRejectsNestedModuleSyntax(): void + { + $this->expectException(TestError::class); + $this->expectExceptionMessage('Python builtins must use the form `python\\name()`'); + + $this->compileFixture('invalid-builtin-path.php'); + } + + public function testConstructorOnlyProgramConfiguresObjectPreservingRuntimeLazily(): void + { + global $translator; + $compiler = CompilerTest::create(ROOT_PATH); + $translator = $compiler; + $source = ROOT_PATH . '/phpunit/code/python/constructor-only.php'; + $compiler->addFiles([$source]); + $compiler->prepareFile($source); + $cpp = file_get_contents($compiler->convertFile($source)); + $extension = file_get_contents($compiler->genExtension()); + + $this->assertStringContainsString('php_configure_python_runtime()', $cpp); + $this->assertStringContainsString('void php_configure_python_runtime()', $extension); + $this->assertStringContainsString('return_as_object', $extension); + } + + public function testPythonOperatorsLowerToTheOperatorModule(): void + { + global $translator; + $compiler = CompilerTest::create(ROOT_PATH); + $translator = $compiler; + $source = ROOT_PATH . '/phpunit/code/python/operators.php'; + $compiler->addFiles([$source]); + $compiler->prepareFile($source); + $cpp = file_get_contents($compiler->convertFile($source)); + $extension = file_get_contents($compiler->genExtension()); + + $this->assertStringContainsString('operator', $extension); + $this->assertStringContainsString('.call(', $cpp); + $this->assertStringContainsString('add', $extension); + $this->assertStringContainsString('iadd', $extension); + $this->assertStringContainsString('is_', $extension); + $this->assertStringContainsString('is_not', $extension); + } + + public function testPythonObjectProtocolStaysOnZendDynamicDispatch(): void + { + global $translator; + $compiler = CompilerTest::create(ROOT_PATH); + $translator = $compiler; + $source = ROOT_PATH . '/phpunit/code/python/object-protocol.php'; + $compiler->addFiles([$source]); + $compiler->prepareFile($source); + $cpp = file_get_contents($compiler->convertFile($source)); + $extension = file_get_contents($compiler->genExtension()); + + $this->assertStringContainsString('.call(', $cpp); + $this->assertStringContainsString('.attr(', $cpp); + $this->assertStringContainsString('.item(', $cpp); + $this->assertStringContainsString('php::ForeachIterator', $cpp); + $this->assertStringContainsString('integer = php::toInt(object);', $cpp); + $this->assertStringContainsString('iadd', $extension); + $this->assertStringNotContainsString('phpy::', $cpp); + } + + public function testToPlainValueUsesTheZendScalarFacade(): void + { + global $translator; + $compiler = CompilerTest::create(ROOT_PATH); + $translator = $compiler; + $source = ROOT_PATH . '/phpunit/code/python/plain-value.php'; + $compiler->addFiles([$source]); + $compiler->prepareFile($source); + $cpp = file_get_contents($compiler->convertFile($source)); + $extension = file_get_contents($compiler->genExtension()); + + $this->assertStringContainsString('php::Var plain;', $cpp); + $this->assertStringContainsString('php::Var dynamic;', $cpp); + $this->assertStringContainsString('php::toPlainValue(', $cpp); + $this->assertStringContainsString('php::Var value', $cpp); + $this->assertStringNotContainsString('.call("toPlainValue"', $cpp); + $this->assertStringNotContainsString('phpy::', $cpp); + } + + public function testToPlainValueRejectsArguments(): void + { + $this->expectException(TestError::class); + $this->expectExceptionMessage('The toPlainValue method does not accept parameters'); + + $this->compileFixture('plain-value-arguments.php'); + } + + private function compileFixture(string $file): void + { + global $translator; + $compiler = CompilerTest::create(ROOT_PATH); + $translator = $compiler; + $source = ROOT_PATH . '/phpunit/code/python/' . $file; + $compiler->addFiles([$source]); + $compiler->prepareFile($source); + $compiler->convertFile($source); + } +} diff --git a/python/design.md b/python/design.md new file mode 100644 index 00000000..8e85e957 --- /dev/null +++ b/python/design.md @@ -0,0 +1,936 @@ +# TypePHP 与 Python 语言级互调用设计 + +> 状态:核心设计已确认,按 `python/implementation-plan.md` 分阶段实施。 +> +> 本文是语法、类型语义、运行时边界和兼容性目标的设计规范;尚未确认的细节继续在文末维护。 + +## 1. 目标 + +TypePHP 应在语言层面提供从 TypePHP 调用 Python 包的能力: + +1. TypePHP 导入 Python 模块,访问模块成员,调用 Python 函数和类。 +2. TypePHP 操作 Python 对象,包括属性、方法、下标、迭代、运算符和调用协议。 +3. TypePHP 函数、闭包和对象可以作为 Python 调用的参数,并允许 Python 在该次动态调用关系中同步回调。 +4. 两个 VM 在同一进程内直接互调用,不通过 JSON、RPC 或子进程。 +5. 默认保留 Python 对象身份和类型信息,避免不必要的深拷贝。 +6. 语法面向普通 TypePHP/PHP 开发者,常规调用不要求理解 CPython C API、GIL 或引用计数。 +7. 本功能是可选的扩展级能力;不使用 Python 语法的项目不依赖 phpy。 + +其中最主要的语言变化是 `use python\...`:它把 phpy 原本需要手写的 `PyCore::import('module')` 和返回变量提升为编译期可识别的模块别名。Python 对象的属性、方法、下标、迭代、参数转换、返回包装和异常等能力原则上复用 phpy 已有实现,不在 TypePHP 中重新建立一套运行时。 + +非目标: + +- 不编译 Python 源码,也不试图替代 CPython。 +- 不承诺将动态 Python API 静态类型化。 +- 永久不支持 Python 线程、`asyncio` 或 CPython subinterpreter。 +- 不生成 Python extension,不向 Python 注册 TypePHP 函数、类或模块。 +- 不提供 `#[PythonExport]` 或其他 TypePHP 符号导出机制。 +- 不追求兼容 Python 语法;目标是让 TypePHP 程序方便、可靠地调用 Python 包。 +- 不将任意 Python 容器自动、递归地复制为 TypePHP 数组。 + +## 2. 参考设计 + +### 2.1 Mojo + +Mojo 使用未经修改的 CPython 运行时保证 Python 生态兼容性,并用统一的 `PythonObject` 包装动态 Python 值。TypePHP 只借鉴其嵌入和对象包装设计,不采用其导出机制。 + +可借鉴的部分: + +- Python 值默认保持为包装对象。 +- TypePHP 基础值传入 Python 时可自动转换。 +- Python 值转回 TypePHP 原生类型时显式转换。 +- 动态 Python 值使用统一代理类型承载。 + +参考:[Mojo Python interoperability](https://docs.modular.com/stable/mojo/manual/python/)、[Mojo Python types](https://docs.modular.com/mojo/manual/python/types)。 + +### 2.2 pybind11 + +pybind11 明确区分对象所有权、返回值策略、解释器生命周期、GIL guard、位置参数和关键字参数。其经验说明:跨语言调用最危险的部分不是调用语法,而是对象生命周期和异常路径。 + +TypePHP 不应把 pybind11 的所有权策略暴露给普通用户,但运行时必须建立同等严格的内部契约。 + +参考:[pybind11 embedding](https://pybind11.readthedocs.io/en/stable/advanced/embedding.html)、[pybind11 functions](https://pybind11.readthedocs.io/en/stable/advanced/functions.html)。 + +### 2.3 PyO3 + +PyO3 使用 GIL token 和带生命周期的 Python 对象指针,从类型系统上区分持有对象、借用对象和 GIL 绑定对象。 + +TypePHP 无需向用户暴露生命周期参数,但 phpy 的 C++ 层应借鉴这一点:所有 CPython API 调用必须能证明当前持有 GIL,所有 `PyObject*` 必须明确是 owned、borrowed 还是 stolen reference。 + +参考:[PyO3 object model](https://pyo3.rs/main/doc/pyo3/)、[PyO3 Python object types](https://pyo3.rs/main/types)。 + +## 3. phpy 的定位 + +phpy 是本功能的运行时基础候选,而不是已经验证完成的稳定依赖。 + +可复用能力包括: + +- 在 ZendVM 进程内初始化 CPython。 +- `zval` 与 `PyObject*` 的边界转换。 +- Python 模块、对象、字符串、序列、字典、集合、迭代器和 callable 的代理对象。 +- TypePHP/PHP 闭包传入 Python后的 callable 代理。 +- Python 异常到 Zend 异常的基础映射。 +- Python 同步调用由 ZendVM 主动传入的函数、对象和 callable 代理的基础设施。 +- GIL RAII guard 的雏形。 + +但是不能直接假定现有实现完全正确。后续实施必须同时 review phpy、重构边界、增加测试、修复 BUG 和优化性能。 + +设计阶段已经识别出的重点审计项: + +- CPython 初始化、重复初始化、关闭顺序和仍存活对象的析构。 +- 每个 CPython API 的 owned/borrowed/stolen reference 规则。 +- 所有成功路径和异常路径的 `Py_INCREF/Py_DECREF` 对称性。 +- GIL 获取、重入调用和 TypePHP 回调 Python 再回调 TypePHP 的行为。 +- 转换过程已改为每次顶层转换创建独立的 C++ 转换器对象;转换策略、递归栈和深度限制均为对象内状态,并由 RAII 恢复,不再使用进程级或线程级临时函数指针。仍需继续审计跨 VM 回调和生命周期边界。 +- Python 异常转 Zend 异常后,CPython error indicator 是否始终被正确清理。 +- Zend 异常转 Python 异常时,原始异常类型、消息和 traceback 的保存。 +- 运算符协议是否正确。例如 PHP `/` 不能映射为 Python floor division。 +- Python 大整数、无效 UTF-8、包含 NUL 的 bytes、递归容器和循环引用。 +- Python 代理持有 Zend 对象时,Zend GC 与 CPython GC 之间可能形成的跨 VM 引用环。 +- Python 线程、`asyncio`、subinterpreter 必须被永久、显式拒绝,而不是产生未定义行为。 + +TypePHP 通过 ZendVM 动态调用 phpy 扩展公开的 `PyCore`、`PyObject`、`PyDict` 等 Facade,不直接链接 `libphpy.so`,也不生成任何 phpy C++ 符号引用。现有公开名称必须保留,TypePHP 不建立第二套用户可见命名体系。 + +职责边界: + +- phpy 负责所有运行时问题:CPython 初始化、GIL、引用计数、对象代理、类型转换、异常和双 VM 生命周期。 +- phpy 负责提供稳定、可测试的 Zend internal class/function/object-handler API。 +- TypePHP 只负责识别语言语法、静态类型和求值顺序,并生成基于 `zend_function*` 与 PHPX/Zend 通用对象 API 的动态调用。 +- TypePHP 不直接操作裸 `PyObject*`,不复制 phpy 的 GIL、引用计数或异常实现。 +- 修复运行时 BUG 时优先修复 phpy,不能只在 TypePHP 生成代码中增加补丁绕过。 + +最小适配原则: + +- TypePHP 的核心新增能力是 Python `use` 解析、模块别名符号和对应代码生成。 +- `python\name()`、`module::$name`、`module::name()` 和运算符 lowering 都应落到 phpy 的 Zend Facade;Python 运算符通过标准库 `operator` module 调用完整的 CPython 运算协议。 +- phpy 已正确解决的行为只补测试并复用;只有 review 或测试证明存在 BUG、隐式转换不符合 TypePHP 规则,或者缺少 Zend 动态入口时,才修改 phpy。 +- TypePHP 不实现 CPython 协议细节,不在生成代码中复制 `PyCore`、`PyObject` 或 `PyModule` 的逻辑。 + +## 4. 可选扩展与运行时检测 + +Python 互调用是扩展级特性,不是 TypePHP 核心程序的强制依赖。 + +- TypePHP 生成代码只依赖 ZendVM/PHPX,不 include phpy 头文件,也不链接 `libphpy.so`。 +- 编译器识别 Python 语法并保留逻辑上的 `PyObject` 类型信息,但不检查 phpy SDK、动态库、ABI 或 Python module 是否存在。 +- phpy 必须像普通 PHP 扩展一样由运行环境加载并注册 `PyCore`、`PyObject` 等 Zend internal classes。 +- 首次实际使用 Python 符号时,TypePHP 通过 class map/func map 解析 `PyCore` 和对应的 `zend_function*`。 +- phpy 未加载时,Zend class lookup 抛出可捕获的 PHP `Error`;若未捕获,则按普通 PHP 规则成为 fatal error。 +- phpy 已加载但 Python module 不存在时,`PyCore::import()` 通过 phpy 抛出 `PyError`。 +- 只有 `use python\sys` 而没有实际访问任何 Python 符号时,不发生运行时解析,因此即使没有安装 phpy 也不会报错。 + +这种模型使同一个 TypePHP 二进制可以在未安装 phpy 的环境中运行不涉及 Python 的路径,也避免 TypePHP 与 phpy 建立原生 C++ ABI 依赖。 + +### 4.1 TypePHP 代码隔离 + +TypePHP 中所有 Python 专用实现必须集中到独立子目录,暂定为: + +```text +src/Python/ +``` + +该目录负责: + +- `python` 特殊根命名空间识别。 +- import/module symbol 表。 +- Python Zend class/method 名称和逻辑返回类型映射。 +- Python 语法糖和静态返回类型映射。 +- Python 调用、属性、下标、迭代和运算符的 C++ lowering。 +- Python 专用诊断。 + +通用 Parser、TypeSystem、Optimizer 和 Generator 只允许保留最小、稳定的扩展入口,不应散落 `if ($isPython...)` 特判。Python 功能未启用时,不加载 Python 专用分析器,也不改变现有代码生成路径。 + +测试同样独立组织,建议使用: + +```text +phpunit/src/Python/ +phpunit/code/python/ +tests/compiler/python/ +``` + +具体目录名在 coding 计划阶段确认,但“实现与测试隔离”是设计约束。 + +## 5. 总体运行时模型 + +采用以下模型: + +- 一个进程内同时存在一个 ZendVM 和一个 CPython 主解释器。 +- CPython 完全通过 phpy 已有的扩展生命周期初始化和关闭;TypePHP 不建立第二套初始化路径。 +- 所有 Python API 边界自动获取 GIL,普通用户不操作 GIL。 +- `PyObject` 及其 `PyDict`、`PyList`、`PyStr` 等子类持有 CPython strong reference。 +- Python 代理对象复制时增加引用计数,析构时在合法的解释器/GIL 上下文中减少引用计数。 +- borrowed reference 只允许存在于 phpy 内部的短生命周期作用域,不暴露给 TypePHP。 +- TypePHP 调用 Python、Python 同步回调由 TypePHP 作为参数传入的 callable、该 callable 再调用 Python,必须支持同步重入。 +- Python 不能独立导入 TypePHP 应用,也不能通过全局注册表查找 TypePHP 函数或类型。 + +解释器关闭前必须先释放所有由 TypePHP 持有的 Python 对象。不能依赖 `Py_Finalize()` 自动修复错误的生命周期。 + +## 6. 导入语法 + +`python` 是编译器识别的保留根命名空间: + +```php +use python\sys; +use Python\numpy as np; +use python\numpy\linalg as linalg; +``` + +分别等价于: + +```python +import sys +import numpy as np +import numpy.linalg as linalg +``` + +在现有 phpy PHP API 中,语义上对应: + +```php +$sys = PyCore::import('sys'); +$np = PyCore::import('numpy'); +$linalg = PyCore::import('numpy.linalg'); +``` + +`PyCore::import()` 返回一个 `PyModule`/`PyObject` 变量,后续属性和方法均通过该变量访问。TypePHP 的 `use python\module` 本身只建立“别名 → Python module 完整名称”的 namespace 标记,不立即执行导入,也不生成 ZendVM class、namespace 或用户可见变量。 + +当编译器在函数代码中发现 `module::$attr` 或 `module::func()` 时,采用与现有 `funcMap` 相同的编译器结构:为实际使用的完整 module 名称分配整数 ID,生成统一的 `THREAD_LOCAL` zval array,并通过 lazy getter 动态调用 `PyCore::import()`。下列名称只是设计示意: + +```cpp +THREAD_LOCAL zval php_python_module_map[module_count]; + +php::Object php_get_python_module(int module_id, const php::Str &module_name) +{ + zval *module = &php_python_module_map[module_id]; + if (UNEXPECTED(Z_ISUNDEF_P(module))) { + // Resolve PyCore::import through classMap/funcMap and invoke zend_function*. + php::Variant value = php::call(/* cached zend_function* */, php::ArgList{module_name}); + ZVAL_COPY(module, value.ptr()); + } + return php::Object(module); +} +``` + +对应 lowering: + +```text +use Python\numpy as np + -> compile-time namespace marker: np => "numpy" + -> module id allocated only when np is actually referenced + +np::$version + -> php::Object(php_get_python_module(module_id, "numpy")).attr("version") + +np::array($value) + -> php::Object(php_get_python_module(module_id, "numpy")).call("array", converted($value)) +``` + +同一完整 module 名称在整个 TypePHP 构建中只分配一个 ID。如果当前 `.php` 文件只有 `use python\sys`,但没有出现任何 `sys::$attr`、`sys::func()` 或其他 `sys` 符号访问,则编译器不为它分配 module ID,运行时不调用 `import('sys')`,也不会因为 Python 环境缺少该 module 而报错。 + +未使用 module 不触发任何 phpy 运行时解析。`tpc` 只检查 `use python\sys` 本身的语法和别名冲突,不检查 phpy SDK/ABI,也不增加 phpy 链接依赖。 + +### 6.1 与 `funcMap` 的关系 + +`pythonModuleMap` 复用 `funcMap` 已验证的整体模式: + +- 编译期使用 `完整 module 名称 → integer ID` 的 map 去重。 +- 数据声明集中生成,普通 `.cc` 只引用 extern array 和 getter。 +- getter 首次访问时初始化,后续通过数组直接命中。 +- 只为真正出现成员访问或调用的 module 分配 ID。 +- 在应用/request clean 阶段集中清理。 + +但是两者不能机械地使用完全相同的清理代码: + +- `funcMap` 保存由 Zend function table 拥有的 non-owning `zend_function*`,清理时可以直接 `memset`。 +- `pythonModuleMap` 保存 phpy 返回的 Zend `PyModule` object zval,不能直接 `memset` 覆盖有效对象。 +- request clean 必须逐项执行 `zval_ptr_dtor()` 并恢复为 `UNDEF`,让 phpy 自己的 Zend object destructor 处理 Python reference 和 GIL。 +- import 失败时 slot 保持 `UNDEF`,不能缓存异常值或半初始化对象。 + +清理由 TypePHP 使用普通 Zend zval API 完成,不调用 phpy C++ 符号: + +```cpp +for (zval &module : php_python_module_map) { + if (!Z_ISUNDEF(module)) { + zval_ptr_dtor(&module); + ZVAL_UNDEF(&module); + } +} +``` + +TypePHP 只释放 Zend object;其内部 Python 引用计数、GIL 和 error state 仍由 phpy object handler 负责。 + +### 6.2 `sys.modules` 仍是全局事实来源 + +Python import 本身就是全局的。getter 首次调用底层 import 时,CPython 从 `sys.modules` 返回已加载 module 或执行首次加载。`pythonModuleMap` 不是第二套 import 系统,只相当于 Python 文件执行 `import numpy as np` 后保存在该文件 namespace 中的绑定: + +```text +php_get_python_module(id, "numpy") + -> TypePHP request 内的 PyModule zval binding + -> CPython sys.modules(全局 module identity 与加载状态) +``` + +它避免每次函数调用都重复进入 Python import API,同时不承担包查找、加载或 reload 逻辑。即使同一个 module 被多个 TypePHP 文件以不同别名引用,只要完整 module 名称相同,就使用同一个 ID 和 `PyModule` Zend object zval。 + +该绑定与 Python 普通 import 一致:Python 代码之后删除或替换 `sys.modules['numpy']`,不会自动改变已经完成的 `np` 绑定;显式执行 `PyCore::import('numpy')` 则按调用当时的 `sys.modules` 状态处理。 + +规则: + +- `python` 根命名空间的大小写不敏感,`python`、`Python`、`PYTHON` 均识别为同一个语言符号。 +- 只有根命名空间不区分大小写。后续模块路径、成员、方法和关键字参数名称严格区分大小写。 +- `use python\...` 只能导入 Python 模块。 +- 是否存在该模块只能在运行时由 CPython 判断。 +- 不支持 `from package import *`。 +- 初版不设计单独的 `from package import name` 语法,成员统一通过模块别名访问。 +- `python` 根命名空间本身不可作为普通 TypePHP/PHP 命名空间声明。 +- 模块别名不能与当前文件中的 TypePHP 类、命名空间导入或其他 Python 模块别名冲突。 +- 用户仍可直接调用 `PyCore::import()` 并把返回的 `PyModule` 保存到普通变量;`use python\...` 是使用 `pythonModuleMap` lazy binding 的语言级 namespace 标记。 + +示例: + +```php +python\len($value); // 正确 +Python\len($value); // 正确,根命名空间大小写不同 +python\Len($value); // 错误,Python builtin 名称大小写错误 +Python\Len($value); // 错误 +``` + +`python` 不是普通运行时命名空间。它由 TypePHP 编译器识别并转换为 Python 语言符号,因此不会进行普通 PHP 命名空间函数或类查找。 + +## 7. 模块成员 + +Python 不区分“类常量”“静态属性”和“模块变量”。模块中的所有名称本质上都是属性。 + +### 7.1 包变量 + +读取 Python 包变量使用 PHP 静态属性形式 `module::$name`: + +```php +use python\math; +use python\os; +use python\numpy as np; + +$pi = math::$pi; +$environ = os::$environ; +$arrayType = np::$ndarray; +``` + +这里的 `$pi`、`$environ` 和 `$ndarray` 是静态成员语法中的成员名,不是读取同名 TypePHP 局部变量。编译器将其 lowering 为 Python module attribute lookup。 + +不允许使用 `math::pi` 读取包变量。PHP 语法会把它理解为常量访问,而 Python module 没有与 PHP class constant 对应的常量概念。编译器发现 Python module alias 后使用 `module::name` 时,应给出有针对性的 FatalError,并提示改用 `module::$name`。 + +### 7.2 包函数和类构造 + +调用 Python 包中的 callable 使用 `module::name(...)`: + +```php +$a = np::array([1, 2, 3]); +$b = np::array([4, 5, 6]); +$c = np::add($a, $b); +``` + +编译器读取 module 的 `name` 属性,并调用得到的 Python 对象。该对象可以是: + +- Python 函数。 +- Python class,此时调用执行该类的构造过程并返回实例。 +- 实现 `__call__` 的其他 Python 对象。 + +TypePHP 不需要也不能仅根据 `np::array()` 的语法判断它是函数还是类构造;可调用性由 Python 在运行时判断。成员不存在时产生 Python `AttributeError`,成员不可调用时产生 Python `TypeError`,并统一映射为 `PyError`。 + +待确认:初版是否允许对模块属性赋值,例如 `module::$name = $value`。建议初版只支持读取;需要写入时使用: + +```php +python\setattr(os, 'name', $value); // 伪代码,具体模块值语法仍需确定 +``` + +## 8. Python 内置函数与 phpy 语法糖 + +`python\name()` 表示调用 Python builtins: + +```php +python\print('hello'); // 等价于 PyCore::print('hello') +$length = python\len($value)->toPlainValue()->toInt(); +$range = python\range(0, 10); +$type = python\type($value); +``` + +它不是普通 TypePHP 命名空间函数。编译器使用 class/func map 解析 `PyCore` 对应的 `zend_function*` 并动态调用,运行时语义与直接编写对应 `PyCore` 调用一致。 + +名称严格区分大小写。对于编译器内建映射中已知的错误名称,可以在编译期报错;其他动态 builtin lookup 失败时产生 Python `AttributeError`。 + +一部分名称是现有 phpy 类型构造器的语法糖,而不是直接调用同名 Python builtin: + +| TypePHP 语法 | 等价 phpy API | +|---|---| +| `python\dict($array)` | `new PyDict($array)` | +| `python\list($array)` | `new PyList($array)` | +| `python\tuple($array)` | `new PyTuple($array)` | +| `python\set($array)` | `new PySet($array)` | +| `python\str($value)` | `new PyStr($value)` | +| `python\object($value)` | `new PyObject($value)` | +| `python\print(...)` | `PyCore::print(...)` | +| `python\scalar($value)` | `PyCore::scalar($value)` | + +例如: + +```php +$dict1 = new PyDict([1, 2, 3, 4]); +$dict2 = python\dict([1, 2, 3, 4]); +``` + +二者必须具有完全相同的运行时语义。这里不能简单转发 CPython `dict([1, 2, 3, 4])`,因为原生 Python builtin 会把参数解释为 key/value pair iterable,与 `PyDict` 的 PHP array 构造规则不同。 + +所有语法糖的映射必须形成封闭、经过测试的表,不能仅凭函数名猜测。 + +该映射同时决定编译期静态类型: + +```php +$list1 = new PyList(); +$list2 = python\list(); + +$dict1 = new PyDict(); +$dict2 = python\dict(); +``` + +- `$list1` 与 `$list2` 都是 `PyList` typed object。 +- `$dict1` 与 `$dict2` 都是 `PyDict` typed object。 +- 两种写法必须使用相同的类型检查、方法解析和 Native Call 优化。 +- 语法糖不能退化成 `mixed`、`var` 或只有基础类型 `PyObject`。 +- Python builtin 调用同样遵守对象保持规则,例如 `python\len()` 返回包装 Python int 的 `PyObject`;需要先以 `toPlainValue()`(或兼容入口 `python\scalar()`)离开 Python 对象规则,再使用普通 TypePHP 转换得到确定类型。`python\print()` 的 Python `None` 结果也保持为 `PyObject`,作为独立语句使用时可直接丢弃。 +- `toPlainValue()` 和 `python\scalar()` 都不是普通 Python builtin 调用,而是明确要求退出 Python 类型规则的转换边界,因此返回 TypePHP `var`。 +- 动态 Python module 成员调用统一返回 `PyObject`。 + +## 9. Python 对象类型 + +所有无法在编译期确定静态类型的 Python 值统一表示为: + +```php +PyObject +``` + +`PyObject` 是现有 phpy 的公开类型,也是 TypePHP 的正式运行时类型。不会再引入 `python\Object` 或 `python\Any`。 + +Python 内建类型继续使用 phpy 已有的具体代理类,例如 `PyDict`、`PyList`、`PyTuple`、`PySet`、`PyStr`、`PyType`、`PyFn` 和 `PyIter`。这样普通 PHP 与 TypePHP 用户看到的是同一套类型体系。 + +Python 的 `None` 也是一个合法 Python 对象。它与 TypePHP `null` 的自动转换规则需要单独定义,不能通过空指针表示 Python `None`。 + +## 10. 对象操作 + +### 10.1 属性和方法 + +```php +$env = os::$environ; +$items = $env->items(); +$name = $object->name; +$object->name = 'new value'; +unset($object->name); +``` + +分别映射为 Python 的 `getattr`、call、`setattr` 和 `delattr` 协议。 + +`PyObject` 与普通 Object 遵循相同的方法解析规则。TypePHP 不为它保留或注入 `toInt()`、`toFloat()`、`toBool()`、`toString()`、`toArray()` 等特殊转换方法;同名 Python 成员仍按正常的动态成员规则调用。 + +`toPlainValue()` 是与 `toArray()`、`toString()` 同级的 TypePHP 全局关键词方法,用于把扩展对象转换为 PHP 内置值;当前第一个受支持的扩展对象是 `PyObject`。从 Python 对象进入 TypePHP 原生值时,推荐使用这个可保持链式调用的入口。`python\scalar()` 保留为等价的函数式入口。其返回值再使用普通 TypePHP 转换方法确定类型: + +```php +$pyValue = np::int64(42); // PyObject +$value = $pyValue->toPlainValue()->toInt(); // TypePHP int +``` + +这里的 `toInt()` 作用于 `toPlainValue()` 已返回的 TypePHP 值,并非作用于 `PyObject`。 + +### 10.2 下标 + +```php +$value = $object[$key]; +$object[$key] = $value; +unset($object[$key]); +isset($object[$key]); +``` + +分别映射到 Python mapping/sequence protocol。 + +`isset()` 保持 PHP 的空值语义:键或索引不存在时返回 `false`,对应值为 Python `None` 时也返回 `false`。运行时只把 `KeyError` / `IndexError` 识别为“缺失”;Python protocol 抛出的其他异常必须继续映射为 `PyError`,不得被 `isset()` 吞掉。list 和 tuple 的整数下标遵循 Python 负索引规则。 + +### 10.3 调用对象 + +```php +$result = $callable($arg1, $arg2); +``` + +运行时使用 `PyObject_Call`。不可调用对象产生 Python `TypeError`,并映射为 TypePHP 可捕获的 Python 异常。 + +### 10.4 迭代 + +```php +foreach ($pythonIterable as $value) { + // Python __iter__ / __next__ +} +``` + +带 key 的形式: + +```php +foreach ($pythonIterable as $index => $value) { +} +``` + +通用 Python iterator 使用从 `0` 开始的 TypePHP 迭代序号作为 `$index`,`$value` 是 `__next__()` 产出的对象。`PyDict` 是 phpy 的专用 mapping wrapper,带 key 的 `foreach` 使用 PHP mapping 习惯:`$index` 是 dict key,`$value` 是对应 dict value。`__iter__()` / `__next__()` 的 Python 异常必须传播为 `PyError`,不能当作正常迭代结束。 + +## 11. 参数与关键字参数 + +普通参数按从左到右顺序求值,然后构造 Python positional args: + +```php +$model = AutoModel::from_pretrained( + 'model-name', + trust_remote_code: true, + device_map: 'auto', +); +``` + +TypePHP 命名参数映射为 Python keyword arguments。参数名严格区分大小写。 + +PHP/TypePHP 数组展开规则可用于构造位置参数和关键字参数,但必须满足: + +- 整数 key 生成 positional argument。 +- 字符串 key 生成 keyword argument。 +- positional argument 不能出现在 keyword argument 之后。 +- 重复 keyword 产生 Python `TypeError`。 + +是否增加显式的 `python\args()` / `python\kwargs()` 类型,留待后续讨论;初版尽量复用现有调用和数组展开语法。 + +## 12. 显式转换原则 + +TypePHP 不继承 phpy 在 ZendVM Facade/opcode 层面的返回值隐式转换行为。语言层采用“参数进入 Python 边界时自动转换、Python 返回值保持对象、返回 TypePHP 时显式转换”的原则。 + +允许自动转换的场景必须由语法明确指出正在进入 Python: + +- `python\name(...)`。 +- Python module 调用,例如 `np::array(...)`。 +- `PyObject` 的方法或 callable 调用。 +- 显式 Python 容器构造,例如 `new PyList(...)` 或 `python\list(...)`。 +- 参数声明要求 `PyObject`、`PyDict` 等 phpy 类型。 +- `PyObject` 与 TypePHP 值组成的混合运算表达式。 + +在这些调用边界内,所有参数表达式先严格按照 TypePHP 从左到右的顺序求值,再转换为 Python 能接受的对象。TypePHP 标量转换为对应 Python scalar;TypePHP 数组递归转换为 Python list/dict,这一过程会产生深拷贝。这不应扩散为不含 Python 对象的普通 TypePHP 表达式中的全局隐式转换。 + +“所有参数自动转换”只适用于转换表明确支持的 TypePHP 类型;resource 或其他没有 Python 表示形式的值必须抛出清晰的类型错误,不能静默转换或传递无效指针。 + +以下场景不允许隐式转换: + +- 将 `PyObject` 直接赋给 `int`、`float`、`bool`、`string` 或 `array`。 +- 将 Python 容器隐式深拷贝成 TypePHP array。 +- 因算术、比较或字符串上下文而擅自把 Python 对象变成 TypePHP 标量。 +- 根据运行时 Python 类型改变 TypePHP 变量的静态类型。 + +`echo $pyObject` 可继续兼容现有 `PyObject::__toString()`,但这只属于输出协议,不能被编译器当作一般的字符串隐式转换。 + +## 13. TypePHP 到 Python 的转换 + +Python 调用边界允许以下自动转换: + +| TypePHP | Python | 语义 | +|---|---|---| +| `null` | `None` | 单例,不是空 `PyObject*` | +| `bool` | `bool` | 值转换 | +| `int` | `int` | Python 任意精度整数 | +| `float` | `float` | double | +| `string` | `str` | 要求合法 UTF-8 | +| list array | `list` | 递归复制 | +| map array | `dict` | 递归复制 | +| `PyObject` 及其子类 | 原对象 | 零拷贝,只传递引用 | +| TypePHP callable | Python callable proxy | Python 可同步回调 TypePHP | +| TypePHP object | Zend object proxy | 不自动复制对象属性 | + +PHP array 使用 `zend_array_is_list()` 一类规则决定转换为 Python `list` 还是 `dict`。空数组默认转换为 Python `list`;如需空 dict,必须提供显式构造 API。 + +数组和普通 TypePHP 字符串每次进入 Python 边界都可能产生分配与复制。文档和性能诊断应建议高频调用、循环调用或大数据场景尽早构造并复用 `PyDict`、`PyList`、`PyStr` 等原生 Python 代理类型,避免重复深拷贝。`PyObject` 及其子类进入 Python 边界时只传递原对象引用,不做内容复制。 + +推荐写法: + +```php +// 只转换一次,后续调用传递同一个 Python 对象。 +use python\processor; + +$pyItems = python\list($items); +for ($i = 0; $i < 1000; $i++) { + processor::consume($pyItems); +} +``` + +应避免在循环中反复把同一个 TypePHP 容器作为参数传入,因为每次跨越 Python 调用边界都会重新深拷贝: + +```php +for ($i = 0; $i < 1000; $i++) { + processor::consume($items); +} +``` + +字符串与 bytes 必须区分。TypePHP `string` 默认映射到 Python `str`;二进制内容使用显式 `python\bytes()`。 + +递归数组、循环引用和超深嵌套必须检测并抛出异常,不能无限递归。 + +## 14. Python 到 TypePHP 的转换 + +### 14.1 默认规则 + +TypePHP 的 Python 专用调用路径必须关闭 phpy 的返回值隐式转换,所有 Python 函数、方法、构造调用和运算结果均保持为 phpy 对象。动态调用的静态返回类型统一为 `PyObject`,不能因为运行时结果恰好是 Python `bool`、`int`、`float`、`str`、`list` 或 `dict` 就隐式转换为 TypePHP 值。 + +当前实现由生成代码在首次实际执行 Python 表达式时,动态调用 `PyCore::setOptions(['return_as_object' => true])`。该初始化是请求级 lazy guard:只写 `use python\module` 而不访问 Python 符号不会触发 phpy;constructor-only 程序也会在构造前完成配置;request clean 会重置 TypePHP 自身的 guard。后续若 phpy 提供无全局模式的对象保持型独立入口,可在不改变语言语义的前提下替换这一运行时实现。 + +编译器已知的 phpy 构造语法糖仍保留精确子类,例如 `python\list()` 返回 `PyList`、`python\dict()` 返回 `PyDict`;这些类型本身都是 `PyObject` 子类,不构成返回值隐式转换。 + +phpy Zend Facade 应提供相互独立的“保持 Python 对象”和“显式转换为 TypePHP”入口。不能通过修改进程级全局函数指针或全局转换模式来临时切换,否则嵌套调用、同步重入和异常路径可能把错误策略泄漏给后续调用。TypePHP 生成的普通 Python 调用只动态调用对象保持入口;`toPlainValue()` 与 `python\scalar()` 最终都调用明确的标量转换入口。 + +phpy 内部已使用 `PythonToPhpConverter` 与 `PhpToPythonConverter` 实现这一约束。每次顶层转换拥有独立实例,递归子值复用同一实例;容器进入与退出由 RAII guard 管理,循环容器和超过深度限制的输入会抛出 `PyError`,不会污染后续转换或导致进程崩溃。 + +原因: + +- 保留 Python 对象身份和精确类型。 +- 避免容器返回时立即深拷贝。 +- Python `int` 可能超过 TypePHP `int` 范围。 +- Python 类型的子类可能重载协议,不能按基础容器强制展开。 +- 避免 phpy 当前“部分标量自动转换、部分对象保留包装”的行为进入 TypePHP 静态类型系统。 + +### 14.2 显式转换 + +Python 对象只有通过 `toPlainValue()`、`python\scalar()`(或手写等价的 `PyCore::scalar()`)才能进入 TypePHP 类型规则: + +```php +$nativeValue1 = PyCore::scalar($value); +$nativeValue2 = python\scalar($value); // 完全等价的语法糖 +$nativeValue3 = $value->toPlainValue(); // 推荐的链式关键词方法 +$integer = $value->toPlainValue()->toInt(); +$float = $value->toPlainValue()->toFloat(); +$boolean = $value->toPlainValue()->toBool(); +$string = $value->toPlainValue()->toString(); +$array = $value->toPlainValue()->toArray(); +``` + +规则: + +- 编译器把 `toPlainValue()` 与 `python\scalar()` 识别为 Python/TypePHP 边界;其后的 `toInt()` 等调用是 TypePHP 原生值已有的普通转换能力。 +- `toPlainValue()` 注册在 TypePHP 全局 `KEYWORD_METHOD_MAP`,不属于 Python module 语法。它按 `toArray()` / `toString()` 的 PHPX 自由函数模式生成 `php::toPlainValue(value)`。参数可以是 `php::Object` 或 `php::Var`;当前 PHPX 在运行时确认对象是 `PyObject`,再通过 Zend API 调用 `PyCore::scalar()`,不链接 phpy C++ 符号。后续其他扩展类可继续在 PHPX 中增加适配。 +- 显式转换完成后,结果完全进入 TypePHP 的静态类型、运算符和参数传递规则,不再采用 Python protocol。 +- 容器转换属于显式深转换,并检测递归引用。 +- Python 大整数不能静默溢出;现有转换规则需要 review 后再确定与 TypePHP `BigInt` 的精确映射。 +- Python `str` 与 `bytes` 必须区分,不能都无条件转换为 TypePHP string。 +- phpy 只负责 `PyCore::scalar()` 的 Python 到 PHP 值转换;后续 `toInt/toFloat/toBool/toString/toArray` 不属于 phpy,也不应在 `PyObject` 上重复实现。 + +现有 phpy 的 PHP 用户仍可保留兼容行为;TypePHP 调用 phpy 的对象保持型 Zend API。为此可以重构或新增 phpy internal class method,但不增加 TypePHP 到 phpy 的 C++ 链接依赖。 + +## 15. 通过 Python `operator` module 实现运算符 + +对于 `PyObject` 及其子类: + +- `+ - * / % ** << >> & | ^` 映射为 Python 标准库 `operator` module 的对应函数。 +- `/` 映射 `operator.truediv()`,不能映射 `operator.floordiv()`。 +- Python floor division 暂用 `python\floordiv($a, $b)`,因为 TypePHP 没有 `//` 运算符。 +- `== != < <= > >=` 分别映射 `operator.eq/ne/lt/le/gt/ge()`。 +- `===` / `!==` 分别映射 `operator.is_()` / `operator.is_not()`。 +- `if ($object)`、`!$object` 使用 `operator.truth()`。 +- compound assignment 映射 `operator.iadd/isub/...()`,并用返回对象更新左值。 + +基础映射: + +| TypePHP | 生成的动态调用 | +|---|---| +| `$a + $b` | `operator::add($a, $b)` | +| `$a - $b` | `operator::sub($a, $b)` | +| `$a * $b` | `operator::mul($a, $b)` | +| `$a / $b` | `operator::truediv($a, $b)` | +| `$a % $b` | `operator::mod($a, $b)` | +| `$a ** $b` | `operator::pow($a, $b)` | +| `$a << $b` | `operator::lshift($a, $b)` | +| `$a >> $b` | `operator::rshift($a, $b)` | +| `$a & $b` | `operator::and_($a, $b)` | +| bitwise OR | `operator::or_($a, $b)` | +| `$a ^ $b` | `operator::xor($a, $b)` | +| `-$a` | `operator::neg($a)` | +| `+$a` | `operator::pos($a)` | +| `~$a` | `operator::invert($a)` | +| `$a += $b` | `$a = operator::iadd($a, $b)` | + +所有操作数必须严格从左到右求值。 + +即使源码没有显式写出 `use python\operator`,出现 Python 运算符时,编译器也将其视为一个仅供内部 lowering 使用的隐式 module binding,并通过同一 `pythonModuleMap` 取得 `operator` module。它不向用户文件注入可见别名,因此不会与用户自己定义的 `operator` class 或 use alias 冲突。用户显式 `use python\operator` 时,内部 lowering 和用户访问复用同一个 module ID。 + +identity 比较调用 `operator::is_()` / `operator::is_not()`。即使两个对象的 `operator::eq()` 结果为真,只要不是同一个 Python object,`===` 仍为假。 + +允许 Python 对象与 TypePHP 值直接混合运算。只要当前运算节点的一侧静态类型为 `PyObject` 或其子类,另一侧的 TypePHP 表达式先完整地按 TypePHP 规则求值,再把所得值转换为 Python 对象,最后由 CPython 执行当前运算节点对应的 protocol。 + +例如: + +```php +$result1 = $pyInt + 10; // 10 转为 Python int,由 Python 执行加法 +$result2 = $pyList * getCount(); // 先求值 getCount(),再转为 Python int +$native = $pyInt->toPlainValue()->toInt() + 10; // 已显式转为 TypePHP int,使用 TypePHP 加法 +``` + +`operator` 调用结果仍为 `PyObject`,以保留 Python 自定义运算符可能返回的任意对象。`===` / `!==` 和条件分支是例外:`operator.is_/is_not/truth()` 的 Python bool 结果随后通过显式 phpy 转换入口得到 TypePHP `bool`。两侧操作数必须严格从左到右各求值一次,转换过程不得导致表达式重复执行。 + +phpy 作为普通 PHP 扩展时,可以继续使用 Zend opcode handler 提供运算符重载兼容性;TypePHP 不依赖这些 handler。 + +TypePHP 编译器在识别到静态类型为 `PyObject`、`PyDict` 等 phpy 对象时,把运算符改写为普通 Python module callable 调用: + +```text +TypePHP operator + -> compile-time lowering + -> implicit python\operator module binding + -> operator::add/sub/... dynamic call + -> CPython complete operator protocol +``` + +该抽象不直接链接 phpy,也不经过 phpy 的 user opcode handler,但它不是无调用成本的 C++ inline 操作: + +- `zend_function*` 和 class entry 使用现有 func/class map lazy cache。 +- 参数仍需要构造为 Zend values,并由 phpy 转为 Python 对象。 +- Python module member lookup、GIL、CPython call 和引用计数成本仍然存在。 +- 优点是 TypePHP 二进制只依赖 ZendVM/PHPX,phpy 可以作为真正的可选运行时扩展。 + +使用标准库 `operator.add()` 而不是直接调用 `__add__()`,可以复用 CPython 对 `NotImplemented`、`__radd__()`、右操作数子类优先级等完整规则,TypePHP 不实现 reflected-operation fallback。 + +当前实现已经覆盖二元算术和位运算、比较、identity、一元运算、条件真假值、短路逻辑,以及 variable、属性和下标左值的复合赋值。Python module function/property、builtin、动态方法、属性、下标和 callable 的结果都会继续传播 `PyObject` 静态类型,因此可以直接链式访问或参与后续 Python 运算。 + +## 16. 异常 + +Python 调用失败时抛出统一的 TypePHP 异常类型,暂定: + +```php +PyError +``` + +异常至少保留: + +- Python exception type。 +- message。 +- Python traceback 对象。 +- 格式化后的 traceback 字符串。 +- 原始 Python exception instance。 + +示例: + +```php +try { + np::array('invalid')->reshape(2, 2); +} catch (PyError $error) { + echo $error->pythonType(); + echo $error->pythonTraceback(); +} +``` + +Python 同步调用 TypePHP callable 代理时,如果 TypePHP 抛出异常,应转换为普通 Python 异常,并保留原始 TypePHP 类名和消息。该异常只沿当前动态调用栈传播,不要求注册 `typephp` Python module 或专用的全局异常类型。 + +异常跨 VM 后必须清理源 VM 的 pending exception 状态。任何异常转换失败都不能导致 coredump、重复抛出或遗留错误状态。 + +## 17. TypePHP callable 传给 Python + +TypePHP 函数、闭包和可调用对象可以自动包装为 Python callable: + +```php +$values = python\list([1, 2, 3]); +$result = python\map(fn (int $value): int => $value * 2, $values); +``` + +Python 调用代理时: + +1. Python 参数按边界规则转换或包装为 TypePHP 值。 +2. 进入 ZendVM 调用 callable。 +3. 返回值转换为 Python 值。 +4. TypePHP 异常转换为 Python 异常。 + +闭包代理必须持有 Zend callable,防止 callable 在 Python 仍引用它时被释放。跨 VM 引用环必须由运行时显式检测或提供可预测的回收策略。 + +TypePHP callable 代理只是参数值,不是导出机制:只有 TypePHP 主动把代理传给 Python 后,Python 才能在该对象存活期间动态调用它。TypePHP 不生成可供 Python 独立导入的 module,也不注册全局函数或类。 + +## 18. phpy 生命周期与集成方式 + +TypePHP 复用 phpy 自己的 PHP 扩展入口和生命周期,不增加独立的 CPython bootstrap: + +1. phpy 的 `MINIT` 初始化共享运行时、CPython 以及 `PyObject`、`PyDict` 等 Zend 类。 +2. phpy 的 `RINIT` 建立本次请求需要的状态。 +3. TypePHP 程序在请求期间通过 phpy 注册到 ZendVM 的 internal classes、methods 和 object handlers 动态调用 Python。 +4. phpy 的 `RSHUTDOWN` 释放请求级资源和代理。 +5. phpy 的 `MSHUTDOWN` 在所有代理均已安全释放后关闭共享运行时和 CPython。 + +TypePHP 应通过与其他静态或动态链接 PHP 扩展相同的机制执行这些入口,不能重复初始化 CPython,也不能绕过 phpy 生命周期直接调用 `Py_Initialize()` 或 `Py_Finalize()`。 + +唯一产物是以 TypePHP 为入口的主程序或库。不会生成可被 CPython 导入的 `.so` / `.pyd`,不会向 Python 注册 TypePHP module、函数或类,也不存在 `#[PythonExport]`。 + +## 19. 性能原则 + +- `PyObject` 传参只增加必要的引用计数,不复制 Python 对象。 +- `pythonModuleMap` 只缓存已经绑定的 `PyModule` Zend object zval,真实加载和全局 identity 直接复用 CPython `sys.modules`;builtin/member lookup 初版保持简单,只有基准测试证明必要时才单独设计缓存。 +- 参数应直接构造 vectorcall 所需数组,优先使用 CPython vectorcall API。 +- 避免先构造 PHP 数组,再由 phpy 二次转换为 Python tuple/dict。 +- TypePHP 数组到 Python 容器属于显式 O(n) 转换,不宣称零成本。 +- 对进入热点 Python 调用的 TypePHP 数组和字符串,应提升为可复用的 `PyList`、`PyDict`、`PyStr`;编译器不擅自缓存转换结果,因为原 TypePHP 值可能已经改变。 +- GIL guard 应覆盖最小必要区域;单线程同步重入期间必须保持正确的解释器状态。 +- 异常路径与正常路径必须同等测试引用计数和内存泄漏。 + +## 20. 永久边界与不支持能力 + +- Python 线程,包括 `threading` 创建线程以及任何从非主线程进入 phpy/TypePHP bridge 的调用。 +- `asyncio`、Python coroutine、`async`/`await` 及跨语言事件循环调度。 +- CPython subinterpreter 和 per-interpreter GIL 模式。 +- Python 作为入口独立加载 TypePHP 程序。 +- 生成 Python extension 或将 TypePHP 函数、类、对象注册为可导入的 Python module。 +- 运行时反射生成 TypePHP 静态类型。 +- 自动导入 `from module import *`。 +- pickle/serialize Python 对象。 +- 跨进程传递 `PyObject`。 +- WASM target 中的 Python 互调用。 + +禁止能力必须有明确防线:编译器对能够静态识别的 `threading`、`_thread`、`asyncio` 和 subinterpreter API 给出 FatalError;phpy 记录创建运行时的 owner thread,并拒绝从其他线程进入 ZendVM bridge。动态导入、反射或第三方包不能被编译器完整识别,因此运行时检查不能省略。 + +第三方 native package 内部完全封闭、从不进入 CPython API 或 phpy/ZendVM bridge 的计算线程不属于这里的 Python 线程能力;它们对 TypePHP 不可见,也不得产生跨线程回调。 + +## 21. TDD 与测试门禁 + +本项目的实现和重构必须严格遵循 TDD,顺序不可颠倒: + +1. 根据已确认的设计语义编写测试。 +2. 运行测试,确认它因为目标能力尚未实现或现有 BUG 而失败。 +3. 编写使该测试通过的最小实现。 +4. 运行相关测试和完整回归。 +5. 在测试保护下重构、清理和优化。 +6. 再次运行完整回归、内存检查和覆盖率检查。 + +禁止先完成实现,再补写只能验证当前实现细节的测试。每个 BUG 必须先添加能够稳定复现问题的回归测试。 + +### 21.1 三层强制测试 + +#### PHPUnit + +TypePHP 仓库的 PHPUnit 用于验证编译器自身: + +- Python import 和特殊名称解析。 +- AST、符号表和类型推断。 +- C++ 代码生成。 +- 编译期错误和诊断位置。 +- 永久禁用能力的编译期诊断,以及无 phpy 环境仍能成功生成代码。 +- 不需要启动 CPython 的边界逻辑。 + +phpy 仓库现有 PHPUnit 用于验证 ZendVM/PHP Facade 与共享 Runtime: + +- `PyCore`、`PyObject`、`PyDict` 等公开 PHP API。 +- PHP 值与 Python 对象转换。 +- Python 异常映射为 `PyError`。 +- opcode handler 与 TypePHP 使用的 Zend dynamic-call API 具有一致语义。 +- TypePHP 需要的对象保持型调用路径。 +- GIL、引用计数、析构和异常路径。 + +#### PHPT + +用于从 TypePHP 用户视角验证语言和运行时的端到端行为: + +- 导入、`module::$name` 包变量读取、`module::name()` callable 调用和关键字参数。 +- `use python\module as alias` 与手写 `$alias = PyCore::import('module')` 的结果、异常和对象 identity 等价。 +- 多个别名、嵌套模块和跨 `.cc` 重复导入。 +- 只有 `use python\module` 而未访问任何别名符号时,不生成 helper、不调用 import,也不检查该 Python module 是否存在。 +- 同一完整 module 名称跨函数、跨 `.cc` 只分配一个 ID,并只在首次访问时调用 import API。 +- import 失败保持 map slot 为 `UNDEF`;异常被捕获后,下一次访问可以重新尝试。 +- request clean 对 module zval 逐项执行 `zval_ptr_dtor()` 并恢复为 `UNDEF`,不得直接 `memset` 有效 Zend object。 +- 删除或替换 `sys.modules` 条目不会改变已经完成的 TypePHP module alias binding。 +- `module::name` 常量式访问的编译期 FatalError,以及不存在成员和不可调用成员的运行时异常。 +- 属性、下标、迭代、运算符和 truthiness。 +- TypePHP 参数到 Python 的转换,以及 Python 返回值的显式转换。 +- 空 TypePHP 数组默认转换为 Python list,以及数组递归深拷贝、异常中止和重复转换行为。 +- Python builtin、模块函数、方法和运算结果不会隐式变成 TypePHP 标量。 +- `$obj->toPlainValue()->toInt()`、`python\scalar($obj)->toInt()` 等显式边界及其后的普通 TypePHP 转换恢复静态类型和运算规则。 +- Python 异常到 TypePHP 异常。 +- phpy 未加载时首次 Python 调用抛出 PHP `Error`,而仅声明未使用的 Python `use` 不报错。 +- TypePHP callable 被 Python 回调。 +- 引用计数、对象析构和重复调用。 +- 编译后的真实程序输出,而不是只检查生成代码字符串。 + +#### pytest + +pytest 用于 phpy 自身已有 Python-facing bridge 的回归测试;它不表示 TypePHP 会生成 Python extension。需要验证: + +- Python 调用 PHP 函数、对象和 callable。 +- 同步重入和 phpy module 生命周期。 +- Python 对 Zend callable/object proxy 的持有、释放和异常映射。 +- 永久禁止从 Python 线程进入 ZendVM 的防护。 + +三层测试不能互相替代。C++/GoogleTest 可以覆盖 phpy 内部的引用计数、RAII 和低层转换,但不能代替 PHPUnit、PHPT 或 pytest。 + +### 21.2 每项语义的测试矩阵 + +每个已支持能力至少考虑以下维度: + +- 正常路径。 +- 错误类型和错误消息。 +- 边界值及空值。 +- Python 子类和动态协议。 +- TypePHP → Python → TypePHP 重入。 +- 仅由 TypePHP 发起、经 callable 代理发生的 Python → TypePHP → Python 同步重入。 +- 正常析构和异常析构。 +- 重复执行、`sys.modules` identity 和重复 import 不重新执行 module 代码。 +- Debug、Release 以及支持的平台。 + +转换测试必须包含: + +- `PHP_INT_MIN/PHP_INT_MAX` 及超出范围的 Python int。 +- `NaN`、`INF`、`-INF` 和负零。 +- 空字符串、Unicode、无效 UTF-8、内含 NUL 的 bytes。 +- 空 list/dict、混合 key、深层容器、递归容器和循环引用。 +- 同一 Python 对象经多次包装后的 identity。 + +### 21.3 内存与稳定性测试 + +涉及 `PyObject*` 或 `zval` 所有权的修改,除功能测试外还必须执行: + +- PHP memory leak report。 +- Python debug build/refcount 检查(环境可用时)。 +- ASan/UBSan 构建。 +- 异常注入测试,覆盖每一个可能提前返回的分支。 +- 循环创建和销毁对象的压力测试。 +- 进程退出时仍存在跨 VM 代理对象的测试。 + +不允许把 coredump、泄漏或未清理的 pending exception 标记为“预期行为”来绕过测试。 + +### 21.4 覆盖率要求 + +- 设计文档中每一条规范性行为都必须能够对应到至少一个测试。 +- 新增和修改的桥接代码需要覆盖正常分支与错误分支。 +- 项目整体覆盖率不得因本功能下降。 +- 对 GIL、引用计数、异常和析构代码,不能只依赖行覆盖率,必须人工检查分支矩阵。 +- 最终 coding 计划必须先列出测试清单,再列实现任务。 + +## 22. 已确认与待确认问题 + +已确认: + +1. Python 互调用是可选的扩展级特性;TypePHP 不链接或在编译期检查 `libphpy.so`,首次实际调用时若 phpy 未加载则由 Zend 抛出 PHP `Error`。 +2. TypePHP 尽可能采用显式转换,不继承 phpy 的全部隐式转换行为。 +3. TypePHP 运算符在编译期改写为 `operator::add($left, $right)` 一类 Python 标准库调用,不使用 phpy opcode handler,也不生成 phpy C++ 符号调用。 +4. `python` 根命名空间大小写不敏感,其后的所有 Python 符号大小写敏感。 +5. `python` 是编译器处理的特殊语言命名空间。 +6. 运行时类继续使用 `PyObject`、`PyDict` 等 phpy 公开名称。 +7. `python\dict()` 等构造语法是现有 phpy 类构造器的语法糖;`python\print()` 等是 `PyCore` API 的语法糖。 +8. `new PyList()` 与 `python\list()` 具有相同的 `PyList` typed object 类型和优化能力。 +9. phpy 解决运行时问题,TypePHP 只通过缓存的 `zend_function*` 和 PHPX/Zend 通用对象 API 动态调用 phpy Facade。 +10. TypePHP 的 Python 专用实现与测试放入独立子目录,通过受控入口接入通用编译流程。 +11. Python 线程、`asyncio` 和 subinterpreter 永久禁止,且不作为后续兼容目标。 +12. `===` / `!==` 分别映射 Python identity 的 `is` / `is not`;`==` / `!=` 使用 Python 值比较。 +13. 仅支持 TypePHP 主动调用 Python;不生成 Python extension,不提供 `#[PythonExport]`,不向 Python 注册 TypePHP 符号。 +14. CPython 和 bridge 生命周期完全复用 phpy 的 `MINIT/RINIT/RSHUTDOWN/MSHUTDOWN` 入口。 +15. Python 包变量使用 `math::$pi` 形式读取;`math::pi` 保持 PHP 常量访问含义,不被重新解释。 +16. `np::array()` 表示读取并调用 Python 包成员;该成员可以是函数、class 或其他 callable,具体类型由 Python 运行时决定。 +17. `PyObject` 可以与 TypePHP 值混合运算;TypePHP 操作数转换为 Python 对象后,整个运算由 CPython protocol 执行,结果保持为 `PyObject`。 +18. Python 函数、方法、class 构造和 builtin 调用的结果一律保持为 `PyObject` 或已知的 phpy 子类;禁用 phpy 返回值隐式转换。 +19. `PyObject::toPlainValue()` 是推荐的链式显式转换关键词;`python\scalar()` 保留为等价入口。两者退出 Python 类型规则后均可继续使用普通 TypePHP 转换,例如 `$obj->toPlainValue()->toInt()`。 +20. TypePHP 调用 Python 时,所有参数自动转换为 Python 类型;TypePHP 数组递归深拷贝,空数组默认转换为 Python list。 +21. 性能敏感代码应复用 `PyDict`、`PyList`、`PyStr` 等代理对象,避免同一 TypePHP 值反复转换和深拷贝。 +22. TypePHP 的主要语言增量是 `use python\...` 和模块别名;使用别名时通过与 `funcMap` 同类的 lazy indexed map 调用 phpy import,其他运行时能力优先直接复用 phpy。 +23. `use python\module` 只登记 namespace 标记;当前 `.php` 文件没有使用该别名的任何符号时,不生成 helper,也不执行运行时 import。 +24. 发现 `module::$attr` 或 `module::func()` 时,才为完整 module 名称分配 ID;未使用的 `use` 不占 map slot,也不执行 import。 +25. `pythonModuleMap` 与 `funcMap` 一样集中声明、按 ID lazy lookup;区别是 module 保存为拥有引用的 Zend object zval,必须在 request clean 中逐项 `zval_ptr_dtor()` 并恢复为 `UNDEF`。 +26. `sys.modules` 负责全局加载状态和 identity,`pythonModuleMap` 只表示 TypePHP 已经完成的 module alias binding。 +27. TypePHP 生成代码只依赖 PHPX/ZendVM;`PyCore::import()`、builtin、对象方法和转换均解析为 `zend_function*` 动态调用。 +28. Python 运算符隐式使用 `python\operator` module;完整运算协议由 CPython `operator` 函数处理,不直接调用 dunder,也不由 TypePHP 实现 reflected fallback。 + +仍待确认: + +1. 初版是否需要模块属性赋值?如果需要,是否使用 `module::$name = $value`? + +该待确认项不阻塞只读 module binding 等已确认阶段的实施;属性写入必须在语义确认并先补测试后才能实现。 diff --git a/python/implementation-plan.md b/python/implementation-plan.md new file mode 100644 index 00000000..e17cd27a --- /dev/null +++ b/python/implementation-plan.md @@ -0,0 +1,91 @@ +# TypePHP Python 互调用分阶段实施计划 + +> 本计划以 `python/design.md` 为规范。每个阶段严格执行:先增加 PHPUnit/PHPT/pytest 测试并确认失败,再实现,再运行相关测试和完整回归。 + +## 阶段 1:Python use 与 module binding + +目标是完成最小可运行闭环,不实现运算符和通用转换: + +1. 识别 `use python\module`、根名称大小写不敏感和 Python 后续名称大小写敏感。 +2. 建立文件级 Python module alias 表,并与普通 class/function/constant use 检查冲突。 +3. 仅在出现 `module::$attr` 或 `module::func()` 时分配 module ID。 +4. 生成与 `funcMap` 同类的 `pythonModuleMap`、lazy getter 和 request-clean 代码。 +5. 使用 Zend class/function map 动态调用 `PyCore::import()`;不 include、link 或检测 phpy。 +6. 使用 Zend object API 读取 module 属性及调用 module callable。 +7. phpy 未加载时在首次实际使用处抛出 PHP `Error`;未使用的 Python use 不触发错误。 + +测试顺序:PHPUnit 代码生成与诊断测试 → PHPT 运行时测试 → 现有 compiler 回归。 + +## 阶段 2:builtins、构造语法糖与静态类型 + +1. `python\name()` 通过 phpy Zend Facade 动态调用:显式 `PyCore` 方法直接复用,其他名称经 Python `builtins` module lookup。 +2. `python\list/dict/tuple/set/str/object()` 映射既有 phpy Zend 类或方法。 +3. `new PyList()` 与 `python\list()` 等写法获得相同的逻辑静态类型。 +4. Python 调用结果保持 `PyObject` 或已知 phpy 子类,关闭 TypePHP 路径的隐式 scalar conversion。 +5. 缺少 phpy、builtin 不存在、参数错误和异常映射测试。 + +实现状态:已完成。TypePHP 在首次实际执行 Python 表达式时延迟启用 phpy 的 `return_as_object`,仅声明未使用的 Python 符号仍不触发运行时依赖。 + +## 阶段 3:参数转换与显式结果转换 + +1. TypePHP 参数从左到右求值后自动转换为 Python 值。 +2. 标量、数组、空数组、嵌套容器及 TypePHP callable 转换。 +3. 通过 `$py->toPlainValue()` 或兼容入口 `python\scalar($py)` 离开 Python 对象规则;需要确定原生类型时继续使用普通 TypePHP 转换,例如 `$py->toPlainValue()->toInt()`。 +4. 深拷贝、递归容器、溢出、Unicode/bytes 和异常路径测试。 +5. review 并重构 phpy 转换策略,移除影响同步重入的全局临时转换状态。 + +实现状态:核心边界已完成。TypePHP 参数严格从左到右求值,支持标量、空数组、嵌套 list/dict 与 callable;`toPlainValue()` 是推荐的链式显式转换入口,`python\scalar()` 保留为等价兼容入口。PHPX 在运行时确认 `php::Var` 确实持有 `PyObject`,再通过 Zend Facade 调用 `PyCore::scalar()`;不依赖 phpy C++ 符号。phpy 已移除进程级转换函数指针,改为局部有状态转换器、RAII 递归保护和 128 层深度限制,并覆盖无效 UTF-8、PHP 自引用数组及 Python 循环容器错误路径。Python 大整数与 bytes 的最终语言映射仍保留在本阶段后续工作中。 + +## 阶段 4:运算符 + +1. 将运算符改写为 Python 标准库 `operator` module 的动态调用。 +2. 混合操作数先转换为 `PyObject`。 +3. 严格保证从左到右、各求值一次。 +4. 使用 `operator.is_/is_not/truth` 实现 identity 和 truthiness,使用 `iadd/isub/...` 实现 compound assignment。 +5. 验证 `operator` 自动处理 `NotImplemented`、reflected dunder 和子类优先级。 +6. 对照 phpy opcode-handler 行为,修复 `/` 错误映射 floor division 等既有问题。 + +实现状态:已完成。二元算术、位运算、比较、`===`/`!==`、一元运算、条件真假值、短路逻辑和复合赋值均通过隐式 `operator` module binding 执行;`/` 使用 `truediv`。混合 TypePHP 操作数由 phpy 在调用边界转换,结果继续保持 `PyObject`,比较和真假值结果显式收敛为 TypePHP `bool`。属性和下标左值由阶段 5 的动态写入协议完成回写。 + +## 阶段 5:完整对象协议 + +1. Python 对象属性读写和删除。 +2. 下标读写、删除、`isset()`。 +3. iterator/foreach。 +4. Python callable 和 TypePHP callable proxy 的同步重入。 +5. keyword argument、argument unpacking 和错误语义。 + +实现状态:已完成。Python proxy 的动态属性、未知方法、下标、删除、`isset()`、`foreach` 和 callable 均复用 phpy 的 Zend object protocol,不生成 phpy C++ 符号。方法、属性、下标和 callable 结果会继续传播为 `PyObject`,支持链式访问和后续 Python 运算。named argument 与 unpacking 复用统一调用参数管线并保持从左到右求值;属性和下标复合赋值使用 `operator.i*()` 的返回对象回写原左值。 + +phpy 同步完成了对象协议加固:`__set()` 转换引用释放、`__unset()`、list/tuple 负索引、list 删除、缺失键与 Python `None` 的 `isset()` 语义、删除和 contains 状态检查,以及 iterator/count 异常传播。相关 BUG 均由 phpy PHPUnit 与 TypePHP PHPT 独立覆盖。 + +## 阶段 6:phpy 稳定性与性能收尾 + +1. CPython/ZendVM 生命周期、GIL、owned/borrowed/stolen reference 全量审计。 +2. Python/Zend 异常状态和 traceback 审计。 +3. 跨 VM 引用环、析构和异常注入测试。 +4. ASan/UBSan、PHP leak report、Python debug build 和压力测试。 +5. 基准测试动态 Zend call、module map、参数转换和 `operator` module 调用;只优化被数据证明的热点。 +6. 完整 PHPUnit、pytest、PHPT 和现有 TypePHP compiler 回归。 + +实现状态:进行中。第一轮 CPython 失败路径审计已覆盖通用对象、list、dict、tuple、set 的构造和下标写入,以及 sequence/set 的 `contains()`。PHP 到 Python 的 key/value 转换失败现在会立即映射为 `PyError`,所有已取得的新引用均由作用域守卫释放;构造失败不再留下未处理的 CPython error indicator,`contains()` 的 `-1` 错误结果也不再被误判为 `true`。无效 UTF-8、unhashable set member、失败后容器仍可继续使用等路径已有 phpy PHPUnit 回归测试。 + +第二轮审计覆盖 module import、异常转换、callable 检查和显式 iterator API。`PyImport_ImportModule()`、`PyErr_Fetch()` 和 `PyIter_Next()` 转移给调用方的新引用现在都会在 Zend wrapper 取得独立引用后统一释放;重复 import、Python 异常或显式 iterator next 不再持续增加引用计数。调用非 callable 的 Python 属性或 `PyObject` 会稳定抛出 `PyError(TypeError)`,不再因为 `PyCallable_Check()` 未设置 error indicator 而静默返回 `null`。`PyCore::next()` 也会区分正常迭代结束和 iterator 异常。上述路径均先建立失败的 phpy PHPUnit 回归测试,其中对象调用行为另有 TypePHP PHPT 集成覆盖。 + +第三轮审计覆盖 `PyCore` Facade 的转换失败与函数缓存。`PyCore::eval()` 会在 globals 转换失败后立即抛出 `PyError`,`PyCore::bytes()` 对非字符串标量使用转换后的 `zend_string`,两者不再解引用空指针或错误的 zval union 字段而导致进程崩溃。`PyCore::next()` 同时释放参数转换产生的 iterator 引用。builtin/operator 函数缓存改用 `std::string` 内容键,不再把请求级 `char*` 地址作为长期 key,也避免同名动态调用不断重复缓存并增加 Python function 引用计数;调用存在但不可调用的 builtin 会释放临时引用并抛出 `PyError(TypeError)`。所有问题均由先失败的独立 PHPUnit 覆盖,其中两个崩溃用禁用 core dump 的隔离进程确认退出码 139 后再修复。 + +第四轮审计覆盖 Python 到 PHP 的同步回调边界。phpy 会将 Python keyword arguments 转换为 Zend named parameters,并在任一位置参数或命名参数转换失败后立即停止,不会执行只接收到部分参数的 PHP callable。PHPX 为 AOT 原生闭包生成并管理 Zend `arg_info` 参数名元数据,因此 Python kwargs 可以按名称绑定到 TypePHP 闭包,而不是依赖参数位置或降级为字符串 callable。phpy PHPUnit、PHPX 单元测试和 TypePHP PHPT 分别覆盖了转换失败、Zend 命名绑定以及完整的 Python→TypePHP 回调链路。 + +第五轮审计覆盖 Python 字符串跨 Zend 边界时的异常和所有权。Python 孤立代理字符无法编码为 UTF-8 时,`phpy.String`、动态 PHP 类名、字典键、`PyObject::__toString()` 和 Python 异常消息格式化都不会再使用空指针或未初始化长度;修复前相关隔离测试会退出 139 或尝试分配异常大的内存。`StrObject` 现在具有显式有效状态,所有调用方必须在访问指针前检查转换结果;异常消息的字符串化仅作为 best-effort 辅助信息,失败时保留原始 Python error/type/value,并清理临时 CPython error indicator。`new_string()` 同时补齐 Zend carrier 析构注册,定长字符串直接取得唯一的 `zend_string` 引用,消除了成功路径的泄漏和未初始化 zval。 + +第六轮引用审计修复了 `PySequence::slice()` 的 new-reference 泄漏。切片在包装为 Zend `PyObject` 后会释放 CPython API 返回的原始所有权,同时保留 wrapper 自己持有的引用;由 `sys.getrefcount()` 压力测试验证重复创建并销毁切片不会继续增加元素引用计数。切片创建失败也会在接触空指针前转换为 `PyError`。 + +内存门禁使用 Valgrind Memcheck 执行。测试关闭 Zend allocator 与 PCRE JIT,在最小独立进程中分别循环 100 次 PHP Closure kwargs 回调、可调用 PHP 对象 kwargs 回调、sequence slice 创建销毁、无效 Unicode 的对象字符串化、字典键转换和异常格式化。结果为 0 invalid-access、0 definite leak、0 indirect leak;进程退出时由 PHP/CPython 保留的 493,106 bytes 均为 still-reachable,不计为泄漏。ASan 扩展无法安全 `dlopen` 到当前启用了 `RTLD_DEEPBIND` 的非 ASan PHP,因此本轮采用不要求 PHP 同步重编译的 Valgrind 作为内存检查工具。 + +## 阶段门禁 + +- 当前阶段的失败测试未先建立,不开始实现。 +- 当前阶段所有测试未通过,不进入下一阶段。 +- phpy 的行为变更必须先在 phpy 仓库增加 PHPUnit/pytest 测试。 +- 每个已修复 BUG 必须保留独立回归测试。 +- 不以修改第三方测试期望来掩盖实现差异。 diff --git a/src/CompilerBase.php b/src/CompilerBase.php index 3529a95d..f5852a39 100644 --- a/src/CompilerBase.php +++ b/src/CompilerBase.php @@ -69,6 +69,7 @@ use TypePhp\Platform\Macos; use TypePhp\Platform\PlatformBase; use TypePhp\Platform\PlatformFactory; use TypePhp\Platform\Windows; +use TypePhp\Python\PythonModuleTrait; use TypePhp\Resolver\DeclarationSymbolTrait; use TypePhp\Resolver\MagicMethodDetector; use TypePhp\Resolver\PropertyAccessContext; @@ -101,6 +102,7 @@ class CompilerBase implements PropertyAccessContext use CompilationStateTrait; use NativeTypeCompatibilityTrait; use NativeBuildConfigurationTrait; + use PythonModuleTrait; use DeclarationSymbolTrait; use NameResolutionTrait; use AstNodeType; @@ -154,18 +156,24 @@ class CompilerBase implements PropertyAccessContext * Use findKeywordMethod() for unified lookup including keyword extension methods. */ public const array KEYWORD_METHOD_MAP = [ - 'toInt' => Type::INT, - 'toFloat' => Type::FLOAT, - 'toString' => Type::STR, - 'toBool' => Type::BOOL, - 'toArray' => Type::ARRAY, - 'toStream' => Type::STREAM, - 'toBigInt' => Type::BIGINT, - 'toBigFloat' => Type::BIGFLOAT, - 'toDecimal' => Type::DECIMAL, - 'toObject' => Type::OBJECT, - 'toAny' => Type::VAR, - 'toRef' => Type::REF, + 'toInt' => Type::INT, + 'toFloat' => Type::FLOAT, + 'toString' => Type::STR, + 'toBool' => Type::BOOL, + 'toArray' => Type::ARRAY, + 'toStream' => Type::STREAM, + 'toBigInt' => Type::BIGINT, + 'toBigFloat' => Type::BIGFLOAT, + 'toDecimal' => Type::DECIMAL, + 'toObject' => Type::OBJECT, + 'toAny' => Type::VAR, + 'toPlainValue' => Type::VAR, + 'toRef' => Type::REF, + ]; + + /** Keyword methods not listed here accept no arguments. */ + public const array KEYWORD_METHOD_WITH_ARGUMENTS = [ + 'toObject' => true, ]; private const array STREAM_FUNCTIONS = [ @@ -1046,6 +1054,7 @@ class CompilerBase implements PropertyAccessContext $this->useAliases = []; $this->useFunctions = []; $this->useConstants = []; + $this->resetPythonModuleAliases(); $this->namespace = ''; } @@ -1774,6 +1783,21 @@ class CompilerBase implements PropertyAccessContext protected function detectClassOfExpr(NodeAbstract $expr): string { + if ($expr instanceof Expr\MethodCall && $this->isNamedMethod($expr->name)) { + $keywordType = $this->findKeywordMethod($this->parseIdentifier($expr->name)); + if ($keywordType !== null && $keywordType !== Type::OBJECT) { + return ''; + } + } + $pythonOperatorClass = $this->detectPythonOperatorReturnClass($expr); + if ($pythonOperatorClass !== null) { + return $pythonOperatorClass; + } + $pythonClass = $this->detectPythonExpressionReturnClass($expr); + if ($pythonClass !== null) { + return $pythonClass; + } + if ($this->isNewExpr($expr) and $this->isNameExpr($expr->class)) { $class = $this->parseIdentifier($expr->class); if ($class === 'self') { @@ -2553,6 +2577,21 @@ class CompilerBase implements PropertyAccessContext protected function detectTypeOfExpr($expr): string { + if ($expr instanceof Expr\MethodCall && $this->isNamedMethod($expr->name)) { + $keywordType = $this->findKeywordMethod($this->parseIdentifier($expr->name)); + if ($keywordType !== null) { + return $keywordType; + } + } + $pythonOperatorType = $this->detectPythonOperatorReturnType($expr); + if ($pythonOperatorType !== null) { + return $pythonOperatorType; + } + $pythonType = $this->detectPythonExpressionReturnType($expr); + if ($pythonType !== null) { + return $pythonType; + } + $exprType = $expr->getType(); switch ($exprType) { case 'Expr_UnaryMinus': @@ -2709,11 +2748,6 @@ class CompilerBase implements PropertyAccessContext case 'Expr_MethodCall': if ($this->isNamedMethod($expr->name)) { $method = $this->parseIdentifier($expr->name); - // keyword methods (to* builtins + __ extensions) — return type is known regardless of receiver - $kwType = $this->findKeywordMethod($method); - if ($kwType !== null) { - return $kwType; - } // Class definition resolution (handles this_, typed VarExpr) $classDef = $this->resolveObjectClassDef($expr->var); if ($classDef !== null && $classDef->hasMethod($method)) { diff --git a/src/Generator/ClosureGenerator.php b/src/Generator/ClosureGenerator.php index ff128c4d..728adc68 100644 --- a/src/Generator/ClosureGenerator.php +++ b/src/Generator/ClosureGenerator.php @@ -23,7 +23,7 @@ use PhpParser\Node\Expr\Variable; trait ClosureGenerator { - protected function genNewClosure(string $callback, string $uses, bool $hasThis): string + protected function genNewClosure(string $callback, string $uses, bool $hasThis, array $params = []): string { $thisArg = $hasThis ? 'this_' : '{}'; if ($this->classDef?->trait !== null) { @@ -36,7 +36,15 @@ trait ClosureGenerator ? $this->getClassEntryPtr($this->getFullClassName()) : 'nullptr'; } - return 'php::newClosure(' . $callback . ', ' . $uses . ', ' . $thisArg . ', ' . $scope . ')'; + $parameterNames = []; + foreach ($params as $param) { + $name = is_string($param->var->name) + ? $param->var->name + : $this->unescapeVarName($this->parseIdentifier($param->var)); + $parameterNames[] = $this->genCharPtr($name, true); + } + return 'php::newClosure(' . $callback . ', ' . $uses . ', ' . $thisArg . ', ' . $scope + . ', { ' . implode(', ', $parameterNames) . ' })'; } protected function parseArrowFunction(Expr\ArrowFunction $expr): string @@ -233,7 +241,8 @@ trait ClosureGenerator return $this->genNewClosure( $tmpVar, '{ ' . implode(', ', $useVars) . ' }', - $this->methodDef !== null + $this->methodDef !== null, + $params ); } @@ -347,7 +356,7 @@ trait ClosureGenerator $code .= $this->getIndent() . '};' . PHP_EOL; $args = $capturedArgs ? '{ ' . implode(', ', $capturedArgs) . ' }' : '{}'; - $callback = $this->genNewClosure($callbackVar, $args, $this->methodDef !== null); + $callback = $this->genNewClosure($callbackVar, $args, $this->methodDef !== null, $params); $code .= $this->getIndent() . 'return typephp_new_fiber_generator(' . $callback . ');' . PHP_EOL; return $code; } diff --git a/src/Parser/AssignOpTrait.php b/src/Parser/AssignOpTrait.php index 71405edc..593477ba 100644 --- a/src/Parser/AssignOpTrait.php +++ b/src/Parser/AssignOpTrait.php @@ -216,6 +216,7 @@ trait AssignOpTrait $type = $this->detectTypeOfExpr($right); $finalVarType = $this->getNormalAssignType($type); $runtimeObjectAssignClass = ''; + $assigningNullToTypedObject = false; if ($type === Type::VOID) { $type = Type::VAR; } @@ -236,12 +237,9 @@ trait AssignOpTrait if ($var === 'this_') { $this->fatalError($left, 'Cannot re-assign $this'); } - if ($this->hasVar($var) + $assigningNullToTypedObject = $this->hasVar($var) && $this->getVarType($var) === Type::OBJECT - && $this->isNull($right)) { - $class = $this->getDeclaredObjectType($var) ?: 'object'; - $this->fatalError($right, "Cannot assign null to typed object `\${$var}` of type `{$class}`; use unset() to clear it"); - } + && $this->isNull($right); if ($this->isStdContainer($var)) { $copyAssign = $this->parseStdContainerCopyAssign($var, $right); if ($copyAssign !== null) { @@ -343,7 +341,10 @@ trait AssignOpTrait $finalVarType = $this->getVarType($var); $this->checkVarAssignExpr($left, $finalVarType, $type); $declaredObjectClass = $this->getDeclaredObjectType($var); - if ($finalVarType === Type::OBJECT && $declaredObjectClass !== '' && ($type === Type::VAR || $type === Type::OBJECT)) { + if (!$assigningNullToTypedObject + && $finalVarType === Type::OBJECT + && $declaredObjectClass !== '' + && ($type === Type::VAR || $type === Type::OBJECT)) { $runtimeObjectAssignClass = $declaredObjectClass; } } @@ -372,8 +373,20 @@ trait AssignOpTrait if ($propertyWriteTarget !== null) { $rightExpr = $this->wrapPropertyWriteTypeCheck($propertyWriteTarget, $right, $rightExpr); } + if ($assigningNullToTypedObject) { + // php::Object intentionally keeps the inferred class as its C++ type, + // while an empty/null value represents the same state as unset($var). + $rightExpr = 'php::Object{' . $rightExpr . '}'; + } if ($runtimeObjectAssignClass !== '') { - $rightExpr = 'php::toObject(' . $rightExpr . ', ' . $this->getClassEntryPtr($runtimeObjectAssignClass) . ')'; + // A typed object has two valid runtime states: the declared class or + // null. Evaluate a dynamic RHS once, preserve null, and validate only + // actual objects against the inferred class constraint. + $checkedValue = 'typephp_nullable_object_value'; + $rightExpr = '([&](php::Var ' . $checkedValue . ') -> php::Object {' + . ' if (' . $checkedValue . '.isNull()) { return php::Object{' . $checkedValue . '}; }' + . ' return php::toObject(' . $checkedValue . ', ' . $this->getClassEntryPtr($runtimeObjectAssignClass) . ');' + . ' })(' . $rightExpr . ')'; } $leftExprType = $this->detectTypeOfExpr($left); $rightExprType = $this->detectTypeOfExpr($right); @@ -463,6 +476,10 @@ trait AssignOpTrait protected function parseAssignOp(Expr\AssignOp $node, string $op): string { $this->assertNotNullsafeWriteContext($node->var); + $pythonOperator = $this->parsePythonAssignOperator($node); + if ($pythonOperator !== null) { + return $pythonOperator; + } $propertyWriteTarget = $this->preparePropertyWriteTarget($node->var); $this->guardLiteralDivisionByZero($node->expr, $op); diff --git a/src/Parser/BinaryOpTrait.php b/src/Parser/BinaryOpTrait.php index d6e0ec47..9ee83534 100644 --- a/src/Parser/BinaryOpTrait.php +++ b/src/Parser/BinaryOpTrait.php @@ -594,12 +594,14 @@ trait BinaryOpTrait protected function parseBinaryOpPlus(Expr\BinaryOp\Plus $expr): string { - return $this->parseBinaryOp($expr->left, $expr->right, '+'); + return $this->parsePythonBinaryOperator($expr) + ?? $this->parseBinaryOp($expr->left, $expr->right, '+'); } protected function parseBinaryOpMul(Expr\BinaryOp\Mul $expr): string { - return $this->parseBinaryOp($expr->left, $expr->right, '*'); + return $this->parsePythonBinaryOperator($expr) + ?? $this->parseBinaryOp($expr->left, $expr->right, '*'); } protected function parseBinaryOpConcat(Expr\BinaryOp\Concat $expr): string @@ -657,31 +659,40 @@ trait BinaryOpTrait protected function parseBinaryOpSmaller(Expr\BinaryOp\Smaller $expr): string { - return $this->convertBoolExpr($this->parseBinaryOp($expr->left, $expr->right, '<')); + return $this->parsePythonBinaryOperator($expr) + ?? $this->convertBoolExpr($this->parseBinaryOp($expr->left, $expr->right, '<')); } protected function parseBinaryOpShiftLeft(Expr\BinaryOp\ShiftLeft $expr): string { - return $this->parseBinaryOp($expr->left, $expr->right, '<<'); + return $this->parsePythonBinaryOperator($expr) + ?? $this->parseBinaryOp($expr->left, $expr->right, '<<'); } protected function parseBinaryOpShiftRight(Expr\BinaryOp\ShiftRight $expr): string { - return $this->parseBinaryOp($expr->left, $expr->right, '>>'); + return $this->parsePythonBinaryOperator($expr) + ?? $this->parseBinaryOp($expr->left, $expr->right, '>>'); } protected function parseBinaryOpMod(Expr\BinaryOp\Mod $expr): string { - return $this->parseBinaryOp($expr->left, $expr->right, '%'); + return $this->parsePythonBinaryOperator($expr) + ?? $this->parseBinaryOp($expr->left, $expr->right, '%'); } protected function parseBinaryOpGreater(Expr\BinaryOp\Greater $expr): string { - return $this->convertBoolExpr($this->parseBinaryOp($expr->left, $expr->right, '>')); + return $this->parsePythonBinaryOperator($expr) + ?? $this->convertBoolExpr($this->parseBinaryOp($expr->left, $expr->right, '>')); } protected function parseBinaryOpPow(Expr\BinaryOp\Pow $expr): string { + $pythonOperator = $this->parsePythonBinaryOperator($expr); + if ($pythonOperator !== null) { + return $pythonOperator; + } $this->assertExprCanBeUsedAsValue($expr->left, 'binary operand'); $this->assertExprCanBeUsedAsValue($expr->right, 'binary operand'); $leftType = $this->detectTypeOfExpr($expr->left); @@ -708,17 +719,20 @@ trait BinaryOpTrait protected function parseBinaryOpBitwiseAnd(Expr\BinaryOp\BitwiseAnd $expr): string { - return $this->parseBinaryOp($expr->left, $expr->right, '&'); + return $this->parsePythonBinaryOperator($expr) + ?? $this->parseBinaryOp($expr->left, $expr->right, '&'); } protected function parseBinaryOpBitwiseOr(Expr\BinaryOp\BitwiseOr $expr): string { - return $this->parseBinaryOp($expr->left, $expr->right, '|'); + return $this->parsePythonBinaryOperator($expr) + ?? $this->parseBinaryOp($expr->left, $expr->right, '|'); } protected function parseBinaryOpBitwiseXor(Expr\BinaryOp\BitwiseXor $expr): string { - return $this->parseBinaryOp($expr->left, $expr->right, '^'); + return $this->parsePythonBinaryOperator($expr) + ?? $this->parseBinaryOp($expr->left, $expr->right, '^'); } protected function parseCompareExpr(NodeAbstract $expr): string @@ -733,18 +747,30 @@ trait BinaryOpTrait protected function parseBinaryOpEqual(Expr\BinaryOp\Equal $expr): string { + $pythonOperator = $this->parsePythonBinaryOperator($expr); + if ($pythonOperator !== null) { + return $pythonOperator; + } return $this->genBigNumericCmp($expr, ' == 0') ?? 'php::equals(' . $this->parseCompareExpr($expr->left) . ', ' . $this->parseCompareExpr($expr->right) . ')'; } protected function parseBinaryOpNotEqual(Expr\BinaryOp\NotEqual $expr): string { + $pythonOperator = $this->parsePythonBinaryOperator($expr); + if ($pythonOperator !== null) { + return $pythonOperator; + } return $this->genBigNumericCmp($expr, ' != 0') ?? '!php::equals(' . $this->parseCompareExpr($expr->left) . ', ' . $this->parseCompareExpr($expr->right) . ')'; } protected function parseBinaryOpIdentical(Expr\BinaryOp $expr): string { + $pythonOperator = $this->parsePythonBinaryOperator($expr); + if ($pythonOperator !== null) { + return $pythonOperator; + } $left = $this->parseCompareExpr($expr->left); $right = $this->parseCompareExpr($expr->right); if ($right === 'nullptr') { @@ -827,8 +853,10 @@ trait BinaryOpTrait $this->context->afterStmtLines = array_slice($this->context->afterStmtLines, 0, $rightAfterStmtCount); $this->checkVarMustExist($right, $rightExpr); - $leftBool = $this->convertBoolExpr((string) $leftExpr, $this->detectTypeOfExpr($left)); - $rightBool = $this->convertBoolExpr((string) $rightExpr, $this->detectTypeOfExpr($right)); + $leftBool = $this->convertPythonObjectToBool($left, (string) $leftExpr) + ?? $this->convertBoolExpr((string) $leftExpr, $this->detectTypeOfExpr($left)); + $rightBool = $this->convertPythonObjectToBool($right, (string) $rightExpr) + ?? $this->convertBoolExpr((string) $rightExpr, $this->detectTypeOfExpr($right)); if (!$rightBeforeStmts && !$rightAfterStmts) { return '(' . $leftBool . ' ' . $op . ' ' . $rightBool . ')'; } @@ -844,7 +872,8 @@ trait BinaryOpTrait $code .= $this->getIndent() . $rightTmpVar . ' = ' . $rightExpr . ';'; $code .= $this->formatCapturedStmtLines($rightAfterStmts); $rightExpr = $rightTmpVar; - $rightBool = $this->convertBoolExpr($rightExpr, $this->detectTypeOfExpr($right)); + $rightBool = $this->convertPythonObjectToBool($right, $rightExpr) + ?? $this->convertBoolExpr($rightExpr, $this->detectTypeOfExpr($right)); } $code .= $this->getIndent() . 'return ' . $rightBool . ';'; $code .= $this->getIndent() . '}'; @@ -860,19 +889,23 @@ trait BinaryOpTrait $this->assertExprCanBeUsedAsCondition($expr->right, 'logical operand'); $left = $this->parseOrderedBinaryOperand($expr->left); $right = $this->parseOrderedBinaryOperand($expr->right); - $leftBool = $this->convertBoolExpr($left, $this->detectTypeOfExpr($expr->left)); - $rightBool = $this->convertBoolExpr($right, $this->detectTypeOfExpr($expr->right)); + $leftBool = $this->convertPythonObjectToBool($expr->left, $left) + ?? $this->convertBoolExpr($left, $this->detectTypeOfExpr($expr->left)); + $rightBool = $this->convertPythonObjectToBool($expr->right, $right) + ?? $this->convertBoolExpr($right, $this->detectTypeOfExpr($expr->right)); return '(' . $leftBool . ' != ' . $rightBool . ')'; } protected function parseBinaryOpSmallerOrEqual(Expr\BinaryOp\SmallerOrEqual $expr): string { - return $this->convertBoolExpr($this->parseBinaryOp($expr->left, $expr->right, '<=')); + return $this->parsePythonBinaryOperator($expr) + ?? $this->convertBoolExpr($this->parseBinaryOp($expr->left, $expr->right, '<=')); } protected function parseBinaryOpGreaterOrEqual(Expr\BinaryOp\GreaterOrEqual $expr): string { - return $this->convertBoolExpr($this->parseBinaryOp($expr->left, $expr->right, '>=')); + return $this->parsePythonBinaryOperator($expr) + ?? $this->convertBoolExpr($this->parseBinaryOp($expr->left, $expr->right, '>=')); } protected function parseBinaryOpSpaceship(Expr\BinaryOp\Spaceship $expr): string @@ -939,12 +972,14 @@ trait BinaryOpTrait protected function parseBinaryOpNotIdentical(Expr\BinaryOp $expr): string { - return '!(' . $this->parseBinaryOpIdentical($expr) . ')'; + return $this->parsePythonBinaryOperator($expr) + ?? '!(' . $this->parseBinaryOpIdentical($expr) . ')'; } protected function parseBinaryOpDiv(Expr\BinaryOp\Div $expr): string { - return $this->parseBinaryOp($expr->left, $expr->right, '/'); + return $this->parsePythonBinaryOperator($expr) + ?? $this->parseBinaryOp($expr->left, $expr->right, '/'); } protected function guardLiteralDivisionByZero(NodeAbstract $right, string $op): void @@ -956,7 +991,8 @@ trait BinaryOpTrait protected function parseBinaryOpMinus(Expr\BinaryOp\Minus $expr): string { - return $this->parseBinaryOp($expr->left, $expr->right, '-'); + return $this->parsePythonBinaryOperator($expr) + ?? $this->parseBinaryOp($expr->left, $expr->right, '-'); } } diff --git a/src/Parser/ClassConstantFetchTrait.php b/src/Parser/ClassConstantFetchTrait.php index 26c67685..850cff42 100644 --- a/src/Parser/ClassConstantFetchTrait.php +++ b/src/Parser/ClassConstantFetchTrait.php @@ -17,6 +17,8 @@ trait ClassConstantFetchTrait { protected function parseClassConstFetch(Expr\ClassConstFetch $expr): string { + $this->rejectPythonModuleClassConstantFetch($expr); + if (!$this->isNameExpr($expr->class)) { return $this->parseDynamicClassConstFetch($expr); } @@ -24,17 +26,14 @@ trait ClassConstantFetchTrait $class = $this->parseIdentifier($expr->class); $self = false; if ($class === 'self' or $class === 'this_') { - // Trait 读取常量,必须动态获取类名 - if ($this->classDef->trait) { - $class = 'static'; - } else { - $self = true; - // Trait-composed methods are parsed under the trait's lexical - // namespace, while `self` still denotes the consuming class. - // Keep it fully qualified so the lexical namespace is not - // applied to the class identity below. - $class = '\\' . $this->getFullClassName(); - } + $self = true; + // Trait methods are compiled only after their AST is composed into + // the consuming class. During trait preprocessing, however, class + // constant initializers still belong to the trait itself. Resolve + // `self` lexically in both cases; rewriting it to `static` would + // incorrectly require a method scope for expressions such as + // `const B = [...self::A]`. + $class = '\\' . $this->getFullClassName(); } elseif ($class === 'parent') { if (!$this->classDef || !$this->classDef->extends) { $this->fatalError($expr, 'Cannot use "parent" outside a class or class does not extend any class'); diff --git a/src/Parser/FunctionCallTrait.php b/src/Parser/FunctionCallTrait.php index 48d6f298..eaf952fe 100644 --- a/src/Parser/FunctionCallTrait.php +++ b/src/Parser/FunctionCallTrait.php @@ -71,6 +71,11 @@ trait FunctionCallTrait protected function parseFuncCall(Expr\FuncCall $expr): string { + $pythonCall = $this->parsePythonBuiltinCall($expr); + if ($pythonCall !== null) { + return $pythonCall; + } + if ($this->isVarExpr($expr->name)) { $fn = $this->parseIdentifier($expr->name); $placeHolder = $fn; diff --git a/src/Parser/MethodCallTrait.php b/src/Parser/MethodCallTrait.php index 0fce5d18..b9dbb022 100644 --- a/src/Parser/MethodCallTrait.php +++ b/src/Parser/MethodCallTrait.php @@ -332,24 +332,24 @@ trait MethodCallTrait $magicMethod = false; $method = $this->identifierToStr($expr->name, literal: true); - // keyword methods (to* builtins + __ extensions) — dispatched before type-specific logic + // Keyword methods are dispatched before all receiver-specific logic. if ($this->isNamedMethod($expr->name)) { $methodName = $expr->name->toString(); $receiverType = $this->isVarExpr($expr->var) ? $this->getVarType($object) : $this->detectTypeOfExpr($expr->var); if ($receiverType === Type::VOID) { $receiverType = Type::VAR; } - // to* builtins - if (isset(self::KEYWORD_METHOD_MAP[$methodName])) { + $keywordType = $this->findKeywordMethod($methodName); + if ($keywordType !== null && isset(self::KEYWORD_METHOD_MAP[$methodName])) { + if (!isset(self::KEYWORD_METHOD_WITH_ARGUMENTS[$methodName]) && $expr->args !== []) { + $this->fatalError($expr, "The {$methodName} method does not accept parameters"); + } if ($methodName === 'toObject') { return $this->genToObjectCall($expr, $object); } if ($methodName === 'toRef') { return $this->genToRefCall($expr); } - if ($methodName === 'toAny' && !empty($expr->args)) { - $this->fatalError($expr, 'The toAny method does not accept parameters'); - } return $this->genToConvertCall($object, $methodName, $receiverType); } // MethodsFor('*') extensions apply to every receiver type. @@ -413,6 +413,9 @@ trait MethodCallTrait $magicMethod = true; } if (!$nativeFunc) { + if ($this->isPythonDynamicMethodCall($expr->var, $methodName)) { + $magicMethod = true; + } $extension = $this->findObjectExtensionMethod( $class, $methodName, diff --git a/src/Parser/PropertyAccessTrait.php b/src/Parser/PropertyAccessTrait.php index c2f5c1fc..4f5aac76 100644 --- a/src/Parser/PropertyAccessTrait.php +++ b/src/Parser/PropertyAccessTrait.php @@ -355,6 +355,11 @@ trait PropertyAccessTrait protected function parseStaticPropertyFetch(Expr\StaticPropertyFetch $expr): string { + $pythonProperty = $this->parsePythonModuleStaticPropertyFetch($expr); + if ($pythonProperty !== null) { + return $pythonProperty; + } + $native = $this->parseNativeStaticPropertyFetch($expr); if ($native !== null) { return $native; diff --git a/src/Parser/TypeConversionTrait.php b/src/Parser/TypeConversionTrait.php index f1778454..2985bd8b 100644 --- a/src/Parser/TypeConversionTrait.php +++ b/src/Parser/TypeConversionTrait.php @@ -180,6 +180,10 @@ trait TypeConversionTrait protected function convertConditionExpr(NodeAbstract $node, string $expr): string { + $pythonBool = $this->convertPythonObjectToBool($node, $expr); + if ($pythonBool !== null) { + return $pythonBool; + } $type = $this->detectTypeOfExpr($node); return $this->convertBoolExpr($expr, $type); } diff --git a/src/Parser/UnaryExpressionTrait.php b/src/Parser/UnaryExpressionTrait.php index a7f1c7d7..4b7f7ad7 100644 --- a/src/Parser/UnaryExpressionTrait.php +++ b/src/Parser/UnaryExpressionTrait.php @@ -16,6 +16,10 @@ trait UnaryExpressionTrait { protected function parseBitwiseNot(Expr\BitwiseNot $expr): string { + $pythonOperator = $this->parsePythonUnaryOperator($expr); + if ($pythonOperator !== null) { + return $pythonOperator; + } $type = $this->detectTypeOfExpr($expr->expr); $this->assertExprCanBeUsedAsValue($expr->expr, 'bitwise operand'); if ($type === Type::BIGINT) { @@ -27,6 +31,10 @@ trait UnaryExpressionTrait protected function parseBooleanNot(Expr\BooleanNot $expr): string { + $pythonOperator = $this->parsePythonUnaryOperator($expr); + if ($pythonOperator !== null) { + return $pythonOperator; + } $this->assertExprCanBeUsedAsCondition($expr->expr, 'boolean operand'); return '!(' . $this->convertBoolExpr( $this->parseExprAsValue($expr->expr), @@ -69,6 +77,10 @@ trait UnaryExpressionTrait protected function parseUnaryMinus(Expr\UnaryMinus $expr): string { + $pythonOperator = $this->parsePythonUnaryOperator($expr); + if ($pythonOperator !== null) { + return $pythonOperator; + } $type = $this->detectTypeOfExpr($expr->expr); $this->assertExprCanBeUsedAsValue($expr->expr, 'unary operand'); if ($type === Type::BIGFLOAT) { @@ -100,6 +112,10 @@ trait UnaryExpressionTrait protected function parseUnaryPlus(Expr\UnaryPlus $expr): string { + $pythonOperator = $this->parsePythonUnaryOperator($expr); + if ($pythonOperator !== null) { + return $pythonOperator; + } $this->assertExprCanBeUsedAsValue($expr->expr, 'unary operand'); return $this->parseExprAsValue($expr->expr); } diff --git a/src/Parser/UniversalMethodCall.php b/src/Parser/UniversalMethodCall.php index 967c5886..87590a03 100644 --- a/src/Parser/UniversalMethodCall.php +++ b/src/Parser/UniversalMethodCall.php @@ -452,6 +452,7 @@ trait UniversalMethodCall 'toBigInt' => 'php::BigInt::newInstance(' . $receiver . ')', 'toBigFloat' => 'php::BigFloat::newInstance(' . $receiver . ')', 'toDecimal' => 'php::Decimal::newInstance(' . $receiver . ')', + 'toPlainValue' => 'php::toPlainValue(' . $receiver . ')', 'toAny' => $receiver, default => $receiver, }; diff --git a/src/Python/PythonModuleTrait.php b/src/Python/PythonModuleTrait.php new file mode 100644 index 00000000..484e1843 --- /dev/null +++ b/src/Python/PythonModuleTrait.php @@ -0,0 +1,652 @@ + TypePHP constructor sugar to the existing phpy facade class. */ + private const PYTHON_CONSTRUCTOR_CLASSES = [ + 'list' => 'PyList', + 'dict' => 'PyDict', + 'tuple' => 'PyTuple', + 'set' => 'PySet', + 'str' => 'PyStr', + 'object' => 'PyObject', + ]; + + /** @var array Python calls whose precise phpy wrapper is known statically. */ + private const PYTHON_BUILTIN_RETURN_CLASSES = [ + ...self::PYTHON_CONSTRUCTOR_CLASSES, + 'int' => 'PyObject', + 'float' => 'PyObject', + 'bytes' => 'PyObject', + 'type' => 'PyType', + ]; + + /** @var array Builtins exposed as explicit PyCore methods. */ + private const PYTHON_CORE_FUNCTIONS = [ + 'int' => true, + 'float' => true, + 'bytes' => true, + 'scalar' => true, + ]; + + /** @var array Lowercase alias to case-sensitive Python module name. */ + protected array $pythonModuleAliases = []; + + /** @var array Case-sensitive Python module name to generated slot ID. */ + protected array $pythonModuleMap = []; + + protected int $pythonModuleIndex = 0; + + protected bool $pythonRuntimeUsed = false; + + /** Return true when the expression is statically known to hold a phpy proxy. */ + protected function isPythonObjectExpr(NodeAbstract $expr): bool + { + $class = $this->detectClassOfExpr($expr); + if ($class === '') { + return false; + } + return strcasecmp($class, 'PyObject') === 0 + || $this->isObjectClassStaticallyAssignableTo($class, 'PyObject'); + } + + /** + * Python members are resolved by the Python VM. Only methods explicitly + * registered on the phpy wrapper may use a cached Zend method pointer; + * every other name must reach PyObject::__call(). + */ + protected function isPythonDynamicMethodCall(NodeAbstract $receiver, string $method): bool + { + if (!$this->isPythonObjectExpr($receiver)) { + return false; + } + + $class = $this->detectClassOfExpr($receiver); + return $class === '' || !\TypePhp\Resolver\Reflection::hasMethod($class, $method); + } + + protected function getPythonBinaryOperator(Expr\BinaryOp $expr): ?string + { + return match (true) { + $expr instanceof Expr\BinaryOp\Plus => 'add', + $expr instanceof Expr\BinaryOp\Minus => 'sub', + $expr instanceof Expr\BinaryOp\Mul => 'mul', + $expr instanceof Expr\BinaryOp\Div => 'truediv', + $expr instanceof Expr\BinaryOp\Mod => 'mod', + $expr instanceof Expr\BinaryOp\Pow => 'pow', + $expr instanceof Expr\BinaryOp\ShiftLeft => 'lshift', + $expr instanceof Expr\BinaryOp\ShiftRight => 'rshift', + $expr instanceof Expr\BinaryOp\BitwiseAnd => 'and_', + $expr instanceof Expr\BinaryOp\BitwiseOr => 'or_', + $expr instanceof Expr\BinaryOp\BitwiseXor => 'xor', + $expr instanceof Expr\BinaryOp\Equal => 'eq', + $expr instanceof Expr\BinaryOp\NotEqual => 'ne', + $expr instanceof Expr\BinaryOp\Smaller => 'lt', + $expr instanceof Expr\BinaryOp\SmallerOrEqual => 'le', + $expr instanceof Expr\BinaryOp\Greater => 'gt', + $expr instanceof Expr\BinaryOp\GreaterOrEqual => 'ge', + $expr instanceof Expr\BinaryOp\Identical => 'is_', + $expr instanceof Expr\BinaryOp\NotIdentical => 'is_not', + default => null, + }; + } + + protected function isPythonBinaryOperatorExpr(NodeAbstract $expr): bool + { + return $expr instanceof Expr\BinaryOp + && $this->getPythonBinaryOperator($expr) !== null + && ($this->isPythonObjectExpr($expr->left) || $this->isPythonObjectExpr($expr->right)); + } + + protected function pythonOperatorReturnsBool(Expr\BinaryOp $expr): bool + { + return $expr instanceof Expr\BinaryOp\Equal + || $expr instanceof Expr\BinaryOp\NotEqual + || $expr instanceof Expr\BinaryOp\Smaller + || $expr instanceof Expr\BinaryOp\SmallerOrEqual + || $expr instanceof Expr\BinaryOp\Greater + || $expr instanceof Expr\BinaryOp\GreaterOrEqual + || $expr instanceof Expr\BinaryOp\Identical + || $expr instanceof Expr\BinaryOp\NotIdentical; + } + + /** + * Lower a Python operation through the standard operator module. + * Braced ArgList elements are sequenced by C++17; call operands that can + * emit statements are materialized first by parseOrderedOperand(). + */ + protected function parsePythonBinaryOperator(Expr\BinaryOp $expr): ?string + { + if (!$this->isPythonBinaryOperatorExpr($expr)) { + return null; + } + + $left = $this->parseOrderedOperand($expr->left, false); + $right = $this->parseOrderedOperand($expr->right, false); + $call = $this->getPythonModuleExpression('operator') + . '.call(' . $this->getLiteralString($this->getPythonBinaryOperator($expr)) + . ', php::ArgList{' . $left . ', ' . $right . '})'; + + return $this->pythonOperatorReturnsBool($expr) + ? $this->convertPythonResultToBool($call) + : $call; + } + + protected function convertPythonResultToBool(string $expression): string + { + $this->markPythonRuntimeUsed(); + $scalar = 'php::call(' . $this->getClassEntryPtr('PyCore') . ', ' + . $this->getFuncPtr('PyCore::scalar') . ', php::ArgList{' . $expression . '})'; + return 'php::toBool(' . $scalar . ')'; + } + + protected function detectPythonOperatorReturnType(NodeAbstract $expr): ?string + { + if ($this->isPythonBinaryOperatorExpr($expr)) { + return $this->pythonOperatorReturnsBool($expr) ? Type::BOOL : Type::OBJECT; + } + if ($this->isPythonUnaryOperatorExpr($expr)) { + return $expr instanceof Expr\BooleanNot ? Type::BOOL : Type::OBJECT; + } + return null; + } + + protected function detectPythonOperatorReturnClass(NodeAbstract $expr): ?string + { + if ($this->isPythonBinaryOperatorExpr($expr)) { + return $this->pythonOperatorReturnsBool($expr) ? null : 'PyObject'; + } + if ($this->isPythonUnaryOperatorExpr($expr)) { + return $expr instanceof Expr\BooleanNot ? null : 'PyObject'; + } + return null; + } + + protected function getPythonUnaryOperator(NodeAbstract $expr): ?string + { + return match (true) { + $expr instanceof Expr\UnaryMinus => 'neg', + $expr instanceof Expr\UnaryPlus => 'pos', + $expr instanceof Expr\BitwiseNot => 'invert', + $expr instanceof Expr\BooleanNot => 'not_', + default => null, + }; + } + + protected function isPythonUnaryOperatorExpr(NodeAbstract $expr): bool + { + return $this->getPythonUnaryOperator($expr) !== null + && $this->isPythonObjectExpr($expr->expr); + } + + protected function parsePythonUnaryOperator(NodeAbstract $expr): ?string + { + if (!$this->isPythonUnaryOperatorExpr($expr)) { + return null; + } + + $operand = $this->parseOrderedOperand($expr->expr, false); + $call = $this->getPythonModuleExpression('operator') + . '.call(' . $this->getLiteralString($this->getPythonUnaryOperator($expr)) + . ', php::ArgList{' . $operand . '})'; + return $expr instanceof Expr\BooleanNot ? $this->convertPythonResultToBool($call) : $call; + } + + protected function convertPythonObjectToBool(NodeAbstract $expr, string $parsed): ?string + { + if (!$this->isPythonObjectExpr($expr)) { + return null; + } + $call = $this->getPythonModuleExpression('operator') + . '.call(' . $this->getLiteralString('truth') . ', php::ArgList{' . $parsed . '})'; + return $this->convertPythonResultToBool($call); + } + + protected function getPythonAssignOperator(Expr\AssignOp $expr): ?string + { + return match (true) { + $expr instanceof Expr\AssignOp\Plus => 'iadd', + $expr instanceof Expr\AssignOp\Minus => 'isub', + $expr instanceof Expr\AssignOp\Mul => 'imul', + $expr instanceof Expr\AssignOp\Div => 'itruediv', + $expr instanceof Expr\AssignOp\Mod => 'imod', + $expr instanceof Expr\AssignOp\Pow => 'ipow', + $expr instanceof Expr\AssignOp\ShiftLeft => 'ilshift', + $expr instanceof Expr\AssignOp\ShiftRight => 'irshift', + $expr instanceof Expr\AssignOp\BitwiseAnd => 'iand', + $expr instanceof Expr\AssignOp\BitwiseOr => 'ior', + $expr instanceof Expr\AssignOp\BitwiseXor => 'ixor', + default => null, + }; + } + + /** + * Lower Python compound assignments through operator.i*(). The target + * receiver and key are materialized before the RHS, then the returned + * Python object is written back through the original PHP lvalue protocol. + */ + protected function parsePythonAssignOperator(Expr\AssignOp $expr): ?string + { + $method = $this->getPythonAssignOperator($expr); + if ($method === null || !$this->isPythonObjectExpr($expr->var)) { + return null; + } + + $writeBack = null; + if ($this->isVarExpr($expr->var)) { + $left = $this->parseWritableIdentifier($expr->var); + $writeBack = static fn(string $value): string => $left . ' = ' . $value; + } elseif ($expr->var instanceof Expr\ArrayDimFetch) { + if ($expr->var->dim === null) { + $this->fatalError($expr->var, 'Cannot use [] for a Python compound assignment'); + } + $container = $this->parseOrderedOperand($expr->var->var, false); + $key = $this->parseOrderedOperand($expr->var->dim, false); + $left = $container . '.item(' . $key . ', false)'; + $writeBack = static fn(string $value): string => $container . '.offsetSet(' . $key . ', ' . $value . ')'; + } elseif ($expr->var instanceof Expr\PropertyFetch && $this->isIdExpr($expr->var->name)) { + $receiver = $this->parseOrderedOperand($expr->var->var, false); + $property = $this->identifierToStr($expr->var->name, literal: true); + $left = $receiver . '.attr(' . $property . ', php::AttrMode::Get)'; + $writeBack = static fn(string $value): string => 'typephp_write_property_scoped(' + . $receiver . ', ' . $property . ', ' . $value . ', nullptr)'; + } else { + return null; + } + + $right = $this->parseOrderedOperand($expr->expr, false); + $call = $this->getPythonModuleExpression('operator') + . '.call(' . $this->getLiteralString($method) + . ', php::ArgList{' . $left . ', ' . $right . '})'; + if ($this->isVarExpr($expr->var)) { + return $writeBack($call); + } + + $result = $this->addTmpVar(Type::OBJECT); + return '((' . $result . ' = ' . $call . ', ' . $writeBack($result) . '), ' . $result . ')'; + } + + protected function resetPythonModuleAliases(): void + { + $this->pythonModuleAliases = []; + } + + protected function parsePythonUse(Node\UseItem $use, string $name, NodeAbstract $statement): bool + { + $parts = explode('\\', trim($name, '\\')); + if (strcasecmp($parts[0] ?? '', 'python') !== 0) { + return false; + } + if (count($parts) < 2) { + $this->fatalError($statement, 'The special `python` namespace must be followed by a module name'); + } + + // PHP namespace separators express Python's dotted module path only + // in source syntax; PyCore::import() expects the canonical Python name. + $module = implode('.', array_slice($parts, 1)); + $alias = $use->alias?->toString() ?? $parts[array_key_last($parts)]; + $aliasKey = strtolower($alias); + + if (isset($this->pythonModuleAliases[$aliasKey]) && $this->pythonModuleAliases[$aliasKey] !== $module) { + $this->fatalError($use, "Python module alias `{$alias}` is already used"); + } + if (isset($this->useAliases[$alias]) || isset($this->useFunctions[$alias]) || isset($this->useConstants[$alias])) { + $this->fatalError($use, "Python module alias `{$alias}` conflicts with an existing use symbol"); + } + + $this->pythonModuleAliases[$aliasKey] = $module; + return true; + } + + protected function hasPythonModuleAlias(string $alias): bool + { + return isset($this->pythonModuleAliases[strtolower($alias)]); + } + + protected function resolvePythonModuleAlias(NodeAbstract $class): ?string + { + if (!$this->isNameExpr($class)) { + return null; + } + $alias = $this->parseIdentifier($class); + if (str_contains($alias, '\\')) { + return null; + } + return $this->pythonModuleAliases[strtolower($alias)] ?? null; + } + + protected function getPythonModuleId(string $module): int + { + if (isset($this->pythonModuleMap[$module])) { + return $this->pythonModuleMap[$module]; + } + + $id = $this->pythonModuleIndex++; + $this->pythonModuleMap[$module] = $id; + + $this->markPythonRuntimeUsed(); + $this->getFuncId('PyCore::import'); + $this->getLiteralString('PyCore::import'); + $this->getLiteralString($module); + + return $id; + } + + protected function markPythonRuntimeUsed(): void + { + if ($this->pythonRuntimeUsed) { + return; + } + $this->pythonRuntimeUsed = true; + + // Register runtime Zend symbols during conversion so generated map + // sizes are final before headers are emitted. + $this->getClassId('PyCore'); + $this->getFuncId('PyCore::setOptions'); + $this->getLiteralString('PyCore'); + $this->getLiteralString('PyCore::setOptions'); + $this->getLiteralString('return_as_object'); + } + + protected function withPythonRuntimeConfigured(string $expression): string + { + return '(' . self::PREFIX . 'configure_python_runtime(), ' . $expression . ')'; + } + + protected function getPythonModuleExpression(string $module): string + { + return 'php_get_python_module(' . $this->getPythonModuleId($module) . ', ' + . $this->getLiteralString($module) . ')'; + } + + protected function resolvePythonBuiltinName(NodeAbstract $name): ?string + { + if (!$this->isNameExpr($name) && !$this->isFullNameExpr($name)) { + return null; + } + $parts = explode('\\', trim($this->parseIdentifier($name), '\\')); + if (strcasecmp($parts[0] ?? '', 'python') !== 0) { + return null; + } + if (count($parts) !== 2 || $parts[1] === '') { + $this->fatalError($name, 'Python builtins must use the form `python\\name()`'); + } + return $parts[1]; + } + + protected function parsePythonBuiltinCall(Expr\FuncCall $expr): ?string + { + $builtin = $this->resolvePythonBuiltinName($expr->name); + if ($builtin === null) { + return null; + } + if ($expr->isFirstClassCallable()) { + $this->fatalError($expr, 'Python builtins do not support first-class callable syntax yet'); + } + $this->markPythonRuntimeUsed(); + + // This is a deliberately closed map. In particular, PyDict's PHP-array + // constructor is not equivalent to Python's dict(iterable) builtin. + $constructorClass = self::PYTHON_CONSTRUCTOR_CLASSES[$builtin] ?? null; + if ($constructorClass !== null) { + $classEntry = $this->getClassEntryPtr($constructorClass); + if ($expr->args === []) { + return $this->withPythonRuntimeConfigured('php::newObject(' . $classEntry . ')'); + } + return $this->withPythonRuntimeConfigured( + 'php::newObject(' . $classEntry . ', ' + . $this->parseCallArgs($expr->args, '__construct', $constructorClass) . ')' + ); + } + + // Explicit PyCore methods either preserve a Python wrapper by design, + // or (`scalar`) explicitly leave Python's object-preserving rules. + if (isset(self::PYTHON_CORE_FUNCTIONS[$builtin])) { + $callable = $this->getClassEntryPtr('PyCore') . ', ' + . $this->getFuncPtr('PyCore::' . $builtin); + if ($expr->args === []) { + return $this->withPythonRuntimeConfigured('php::call(' . $callable . ')'); + } + return $this->withPythonRuntimeConfigured( + $this->genRuntimeFunctionCall($callable, $expr->args, $builtin, 'PyCore') + ); + } + + $target = $this->getPythonModuleExpression('builtins'); + $name = $this->getLiteralString($builtin); + if ($expr->args === []) { + return $target . '.call(' . $name . ')'; + } + return $target . '.call(' . $name . ', ' . $this->parseCallArgs($expr->args) . ')'; + } + + protected function detectPythonExpressionReturnType(NodeAbstract $expr): ?string + { + if ($expr instanceof Expr\StaticCall && $this->resolvePythonModuleAlias($expr->class) !== null) { + return Type::OBJECT; + } + if ($expr instanceof Expr\StaticPropertyFetch && $this->resolvePythonModuleAlias($expr->class) !== null) { + return Type::OBJECT; + } + if ($expr instanceof Expr\MethodCall && $this->isPythonObjectExpr($expr->var)) { + if (!$this->isIdExpr($expr->name) + || $this->isPythonDynamicMethodCall($expr->var, $this->parseIdentifier($expr->name)) + ) { + return Type::OBJECT; + } + return null; + } + if ($expr instanceof Expr\PropertyFetch && $this->isPythonObjectExpr($expr->var)) { + return Type::OBJECT; + } + if ($expr instanceof Expr\ArrayDimFetch && $this->isPythonObjectExpr($expr->var)) { + return Type::OBJECT; + } + if ($expr instanceof Expr\FuncCall + && $expr->name instanceof NodeAbstract + && !$this->isNameExpr($expr->name) + && $this->isPythonObjectExpr($expr->name) + ) { + return Type::OBJECT; + } + if (!$expr instanceof Expr\FuncCall) { + return null; + } + $builtin = $this->resolvePythonBuiltinName($expr->name); + if ($builtin === null) { + return null; + } + if ($builtin === 'scalar') { + return Type::VAR; + } + return Type::OBJECT; + } + + protected function detectPythonExpressionReturnClass(NodeAbstract $expr): ?string + { + if ($expr instanceof Expr\StaticCall && $this->resolvePythonModuleAlias($expr->class) !== null) { + return 'PyObject'; + } + if ($expr instanceof Expr\StaticPropertyFetch && $this->resolvePythonModuleAlias($expr->class) !== null) { + return 'PyObject'; + } + if ($expr instanceof Expr\MethodCall && $this->isPythonObjectExpr($expr->var)) { + if (!$this->isIdExpr($expr->name) + || $this->isPythonDynamicMethodCall($expr->var, $this->parseIdentifier($expr->name)) + ) { + return 'PyObject'; + } + return null; + } + if ($expr instanceof Expr\PropertyFetch && $this->isPythonObjectExpr($expr->var)) { + return 'PyObject'; + } + if ($expr instanceof Expr\ArrayDimFetch && $this->isPythonObjectExpr($expr->var)) { + return 'PyObject'; + } + if ($expr instanceof Expr\FuncCall + && $expr->name instanceof NodeAbstract + && !$this->isNameExpr($expr->name) + && $this->isPythonObjectExpr($expr->name) + ) { + return 'PyObject'; + } + if (!$expr instanceof Expr\FuncCall) { + return null; + } + $builtin = $this->resolvePythonBuiltinName($expr->name); + if ($builtin === null) { + return null; + } + if ($builtin === 'scalar') { + return null; + } + return self::PYTHON_BUILTIN_RETURN_CLASSES[$builtin] ?? 'PyObject'; + } + + protected function parsePythonModuleStaticCall(Expr\StaticCall $expr): ?string + { + if (!$this->isIdExpr($expr->name)) { + return null; + } + $module = $this->resolvePythonModuleAlias($expr->class); + if ($module === null) { + return null; + } + + $method = $this->parseIdentifier($expr->name); + $target = $this->getPythonModuleExpression($module); + $methodName = $this->getLiteralString($method); + if ($expr->args === []) { + return $target . '.call(' . $methodName . ')'; + } + return $target . '.call(' . $methodName . ', ' . $this->parseCallArgs($expr->args) . ')'; + } + + protected function parsePythonModuleStaticPropertyFetch(Expr\StaticPropertyFetch $expr): ?string + { + if (!$this->isIdExpr($expr->name)) { + return null; + } + $module = $this->resolvePythonModuleAlias($expr->class); + if ($module === null) { + return null; + } + + return $this->getPythonModuleExpression($module) + . '.attr(' . $this->getLiteralString($this->parseIdentifier($expr->name)) . ')'; + } + + protected function rejectPythonModuleClassConstantFetch(Expr\ClassConstFetch $expr): void + { + if (!$this->isIdExpr($expr->name) || $this->resolvePythonModuleAlias($expr->class) === null) { + return; + } + + $alias = $this->parseIdentifier($expr->class); + $member = $this->parseIdentifier($expr->name); + $this->fatalError( + $expr, + "Python module value `{$alias}::{$member}` must use `{$alias}::\${$member}`", + ); + } + + protected function genPythonModuleDataDeclarations(): string + { + if (!$this->pythonRuntimeUsed) { + return ''; + } + + $code = 'extern THREAD_LOCAL bool ' . self::PREFIX . 'python_runtime_configured;' . PHP_EOL + . 'void ' . self::PREFIX . 'configure_python_runtime();' . PHP_EOL; + if ($this->pythonModuleMap !== []) { + $code .= 'extern THREAD_LOCAL zval ' . self::PREFIX . 'python_module_map[' + . count($this->pythonModuleMap) . '];' . PHP_EOL + . 'php::Object ' . self::PREFIX + . 'get_python_module(int module_id, const php::Str &module_name);' . PHP_EOL; + } + return $code; + } + + protected function genPythonModuleStorage(): string + { + if (!$this->pythonRuntimeUsed) { + return ''; + } + + $code = "// python runtime \n" + . 'THREAD_LOCAL bool ' . self::PREFIX . 'python_runtime_configured = false;' . PHP_EOL; + if ($this->pythonModuleMap !== []) { + $code .= 'THREAD_LOCAL zval ' . self::PREFIX . 'python_module_map[' + . count($this->pythonModuleMap) . ']{};' . PHP_EOL; + } + return $code; + } + + protected function genPythonModuleGetter(): string + { + if (!$this->pythonRuntimeUsed) { + return ''; + } + + $pyCoreClass = $this->getClassEntryPtr('PyCore'); + $setOptionsFunction = $this->getFuncPtr('PyCore::setOptions'); + $returnAsObject = $this->getLiteralString('return_as_object'); + + $code = 'void ' . self::PREFIX . 'configure_python_runtime() {' . PHP_EOL + . 'if (EXPECTED(' . self::PREFIX . 'python_runtime_configured)) {' . PHP_EOL + . 'return;' . PHP_EOL + . '}' . PHP_EOL + . 'php::Array options;' . PHP_EOL + . 'options.set(' . $returnAsObject . ', true);' . PHP_EOL + . 'php::call(' . $pyCoreClass . ', ' . $setOptionsFunction . ', php::ArgList{options});' . PHP_EOL + . self::PREFIX . 'python_runtime_configured = true;' . PHP_EOL + . '}' . PHP_EOL . PHP_EOL; + + if ($this->pythonModuleMap === []) { + return $code; + } + + $importFunction = $this->getFuncPtr('PyCore::import'); + + return $code + . 'php::Object ' . self::PREFIX + . 'get_python_module(int module_id, const php::Str &module_name) {' . PHP_EOL + . self::PREFIX . 'configure_python_runtime();' . PHP_EOL + . 'zval *module = &' . self::PREFIX . 'python_module_map[module_id];' . PHP_EOL + . 'if (UNEXPECTED(Z_ISUNDEF_P(module))) {' . PHP_EOL + . 'auto ce = ' . $pyCoreClass . ';' . PHP_EOL + . 'auto fn = ' . $importFunction . ';' . PHP_EOL + . 'php::Variant imported = php::call(ce, fn, php::ArgList{module_name});' . PHP_EOL + . '(void) php::Object(imported);' . PHP_EOL + . 'imported.moveTo(module);' . PHP_EOL + . '}' . PHP_EOL + . 'return php::Object(module);' . PHP_EOL + . '}' . PHP_EOL . PHP_EOL; + } + + protected function genPythonModuleCleanup(): string + { + if (!$this->pythonRuntimeUsed) { + return ''; + } + + $code = ''; + if ($this->pythonModuleMap !== []) { + $code .= 'for (zval &module : ' . self::PREFIX . 'python_module_map) {' . PHP_EOL + . 'if (!Z_ISUNDEF(module)) {' . PHP_EOL + . 'zval_ptr_dtor(&module);' . PHP_EOL + . 'ZVAL_UNDEF(&module);' . PHP_EOL + . '}' . PHP_EOL + . '}' . PHP_EOL; + } + return $code . self::PREFIX . 'python_runtime_configured = false;' . PHP_EOL; + } +} diff --git a/src/Resolver/DeclarationSymbolTrait.php b/src/Resolver/DeclarationSymbolTrait.php index d2fa02e9..b48ab784 100644 --- a/src/Resolver/DeclarationSymbolTrait.php +++ b/src/Resolver/DeclarationSymbolTrait.php @@ -72,6 +72,9 @@ trait DeclarationSymbolTrait foreach ($v2->uses as $use) { $id = $this->parseIdentifier($use->name); $type = $use->type !== Node\Stmt\Use_::TYPE_UNKNOWN ? $use->type : $v2->type; + if ($type === Node\Stmt\Use_::TYPE_NORMAL && $this->parsePythonUse($use, $id, $v2)) { + continue; + } if ($type === Node\Stmt\Use_::TYPE_FUNCTION) { $lastIndex = strrpos($id, '\\'); $fn = substr($id, $lastIndex + 1); @@ -99,6 +102,10 @@ trait DeclarationSymbolTrait } elseif ($idLower === 'bigint_types') { $this->bigintTypes = true; } else { + $alias = $use->alias?->toString() ?? substr($id, (int) strrpos('\\' . $id, '\\')); + if ($this->hasPythonModuleAlias($alias)) { + $this->fatalError($use, "Use alias `{$alias}` conflicts with a Python module alias"); + } $this->useNamespaces[] = $id; if ($use->alias) { $this->useAliases[$use->alias->toString()] = $id; @@ -122,4 +129,3 @@ trait DeclarationSymbolTrait } } - diff --git a/src/Translator.php b/src/Translator.php index e485cc1d..eecf4cbe 100644 --- a/src/Translator.php +++ b/src/Translator.php @@ -746,6 +746,11 @@ class Translator extends Preprocessor $funcCount = max(1, count($this->funcMap)); $lines[] = 'extern THREAD_LOCAL zend_function *' . self::PREFIX . self::FUNC_MAP . '[' . $funcCount . '];' . PHP_EOL; + $pythonModuleDeclarations = $this->genPythonModuleDataDeclarations(); + if ($pythonModuleDeclarations !== '') { + $lines[] = $pythonModuleDeclarations; + } + $propCount = max(1, count($this->propMap)); $lines[] = 'extern THREAD_LOCAL uint32_t ' . self::PREFIX . self::PROP_MAP . '[' . $propCount . '];' . PHP_EOL; @@ -809,6 +814,8 @@ class Translator extends Preprocessor $code .= "// func \n"; $code .= 'THREAD_LOCAL zend_function *' . self::PREFIX . self::FUNC_MAP . '[' . max(1, count($this->funcMap)) . '];' . PHP_EOL; + $code .= $this->genPythonModuleStorage(); + $code .= "// property \n"; $code .= 'THREAD_LOCAL uint32_t ' . self::PREFIX . self::PROP_MAP . '[' . max(1, count($this->propMap)) . '];' . PHP_EOL; @@ -846,6 +853,8 @@ uint32_t php_get_prop(int prop_id, const php::Str &prop_name, int class_id, cons CODE; $code .= "\n\n"; + $code .= $this->genPythonModuleGetter(); + $code .= "// literal strings \n"; if ($this->literalStrings) { $code .= Type::STR . ' ' . self::LITERAL_STRINGS . '[] = {' . PHP_EOL; @@ -1007,6 +1016,8 @@ CODE; $code .= $name . '.unset();' . PHP_EOL; } + $code .= $this->genPythonModuleCleanup(); + $code .= '// class array constants' . PHP_EOL; foreach ($this->getClassLikesWithConstants() as $classDef) { foreach ($classDef->constants as $constant) { diff --git a/tests/compiler/basic/unset-typed-object-reassign.phpt b/tests/compiler/basic/unset-typed-object-reassign.phpt index ee2277f6..da2db162 100644 --- a/tests/compiler/basic/unset-typed-object-reassign.phpt +++ b/tests/compiler/basic/unset-typed-object-reassign.phpt @@ -48,11 +48,19 @@ function main() echo $error::class, "\n"; } + try { + $value = makeUnsetReassignOtherDynamic(); + echo "invalid assignment after null accepted\n"; + } catch (Throwable $error) { + echo $error::class, "\n"; + } + $value = makeUnsetReassignExpected(); var_dump($value->value()); } ?> --EXPECT-- TypeError +null assignment accepted TypeError string(8) "expected" diff --git a/tests/compiler/basic/unset-typed-object-state.phpt b/tests/compiler/basic/unset-typed-object-state.phpt index e42a4371..20440071 100644 --- a/tests/compiler/basic/unset-typed-object-state.phpt +++ b/tests/compiler/basic/unset-typed-object-state.phpt @@ -1,5 +1,5 @@ --TEST-- -unset typed object reads as null and accepts a valid reassignment +typed object accepts null as its empty state and retains its declared class constraint --FILE-- value()); + + unset($value); + $value = null; + var_dump($value); + + $value = makeUnsetTypedObjectValue(); + var_dump($value->readProperty()); } ?> --EXPECT-- +NULL bool(true) bool(false) bool(false) Error string(5) "value" +NULL +int(1) diff --git a/tests/compiler/python/argument-conversion.phpt b/tests/compiler/python/argument-conversion.phpt new file mode 100644 index 00000000..59af9cc3 --- /dev/null +++ b/tests/compiler/python/argument-conversion.phpt @@ -0,0 +1,40 @@ +--TEST-- +TypePHP values convert recursively at the Python call boundary +--SKIPIF-- + +--FILE-- + 'TypePHP'], + ['nested' => [1, ['ok' => true]]], + ]; + + foreach ($values as $value) { + echo python\scalar(python\repr($value))->toString(), "\n"; + } +} +?> +--EXPECT-- +None +True +42 +1.5 +'你好' +[] +[1, 2] +{'name': 'TypePHP'} +{'nested': [1, {'ok': True}]} diff --git a/tests/compiler/python/argument-order-callable.phpt b/tests/compiler/python/argument-order-callable.phpt new file mode 100644 index 00000000..76c67c12 --- /dev/null +++ b/tests/compiler/python/argument-order-callable.phpt @@ -0,0 +1,41 @@ +--TEST-- +Python arguments evaluate left-to-right once and accept TypePHP callables +--SKIPIF-- + +--FILE-- +toInt()); + + $mapped = python\map(fn (int $value): int => $value * 2, [1, 2, 3]); + var_dump(python\scalar(python\sum($mapped))->toInt()); + + sys::$path->append(__DIR__ . '/lib'); + $callbackResult = protocol::callback_with_kwargs( + fn (string $left, int $right): string => "$left:$right" + ); + var_dump(python\scalar($callbackResult)->toString()); +} +?> +--EXPECT-- +mark:2 +mark:3 +int(8) +int(12) +string(6) "left:7" diff --git a/tests/compiler/python/builtin-errors.phpt b/tests/compiler/python/builtin-errors.phpt new file mode 100644 index 00000000..1fd36954 --- /dev/null +++ b/tests/compiler/python/builtin-errors.phpt @@ -0,0 +1,29 @@ +--TEST-- +Python builtin and constructor errors preserve their runtime exception types +--SKIPIF-- + +--FILE-- + +--EXPECT-- +python argument error +phpy constructor error diff --git a/tests/compiler/python/builtins.phpt b/tests/compiler/python/builtins.phpt new file mode 100644 index 00000000..81ea6318 --- /dev/null +++ b/tests/compiler/python/builtins.phpt @@ -0,0 +1,54 @@ +--TEST-- +Python builtins and constructors use phpy objects +--SKIPIF-- + +--FILE-- + 42]); + $tuple = PYTHON\tuple([1, 2]); + $set = python\set([1, 2]); + $str = python\str(123); + $int = python\int('42'); + $object = python\object('value'); + $bytes = python\bytes('value'); + + var_dump(get_class($list)); + var_dump(get_class($dict)); + var_dump(get_class($tuple)); + var_dump(get_class($set)); + var_dump(get_class($str)); + var_dump(get_class($int)); + var_dump(get_class($object)); + var_dump(get_class($bytes)); + var_dump(get_class(python\len($list))); + var_dump(get_class(python\bool(0))); + var_dump(python\scalar($int)->toInt()); + + // The constructor syntax is deliberately phpy's PHP-array conversion, + // not CPython's dict(iterable-of-pairs) constructor. + var_dump(python\scalar($dict['answer'])->toInt()); + python\print('hello from python'); +} +?> +--EXPECT-- +string(6) "PyList" +string(6) "PyDict" +string(7) "PyTuple" +string(5) "PySet" +string(5) "PyStr" +string(8) "PyObject" +string(8) "PyObject" +string(8) "PyObject" +string(8) "PyObject" +string(8) "PyObject" +int(42) +int(42) +hello from python diff --git a/tests/compiler/python/case-sensitive-builtin.phpt b/tests/compiler/python/case-sensitive-builtin.phpt new file mode 100644 index 00000000..8c94367d --- /dev/null +++ b/tests/compiler/python/case-sensitive-builtin.phpt @@ -0,0 +1,22 @@ +--TEST-- +Python builtin names remain case-sensitive +--SKIPIF-- + +--FILE-- + +--EXPECT-- +unknown builtin diff --git a/tests/compiler/python/constructor-method-return.phpt b/tests/compiler/python/constructor-method-return.phpt new file mode 100644 index 00000000..962fd3fe --- /dev/null +++ b/tests/compiler/python/constructor-method-return.phpt @@ -0,0 +1,22 @@ +--TEST-- +Python constructor-only programs preserve proxy results as PyObject +--SKIPIF-- + +--FILE-- +toInt()); +} +?> +--EXPECT-- +string(8) "PyObject" +int(42) diff --git a/tests/compiler/python/conversion-errors.phpt b/tests/compiler/python/conversion-errors.phpt new file mode 100644 index 00000000..38f86048 --- /dev/null +++ b/tests/compiler/python/conversion-errors.phpt @@ -0,0 +1,31 @@ +--TEST-- +Python conversion rejects invalid UTF-8 and recursive PHP arrays safely +--SKIPIF-- + +--FILE-- + +--EXPECT-- +invalid utf8 +recursive php array diff --git a/tests/compiler/python/empty.ini b/tests/compiler/python/empty.ini new file mode 100644 index 00000000..8e7ba986 --- /dev/null +++ b/tests/compiler/python/empty.ini @@ -0,0 +1 @@ +; Intentionally empty: used to verify the optional phpy runtime boundary. diff --git a/tests/compiler/python/lib/typephp_protocol.py b/tests/compiler/python/lib/typephp_protocol.py new file mode 100644 index 00000000..27f1e180 --- /dev/null +++ b/tests/compiler/python/lib/typephp_protocol.py @@ -0,0 +1,17 @@ +class ProtocolObject: + def __init__(self): + self.name = "initial" + self.values = [10, 20, 30] + + def greet(self, prefix, suffix="!"): + return f"{prefix} {self.name}{suffix}" + + def __call__(self, left, right=0): + return left + right + +def protocol_object(): + return ProtocolObject() + + +def callback_with_kwargs(callback): + return callback("left", right=7) diff --git a/tests/compiler/python/missing-phpy.phpt b/tests/compiler/python/missing-phpy.phpt new file mode 100644 index 00000000..15c916dc --- /dev/null +++ b/tests/compiler/python/missing-phpy.phpt @@ -0,0 +1,19 @@ +--TEST-- +Using a Python module without phpy raises a catchable PHP Error +--ENV-- +PHPRC=tests/compiler/python/empty.ini +--FILE-- + +--EXPECT-- +Error diff --git a/tests/compiler/python/module-access.phpt b/tests/compiler/python/module-access.phpt new file mode 100644 index 00000000..561032cf --- /dev/null +++ b/tests/compiler/python/module-access.phpt @@ -0,0 +1,19 @@ +--TEST-- +Python module aliases lazily import through phpy +--SKIPIF-- + +--FILE-- +toFloat() > 3.14); +} +?> +--EXPECT-- +bool(true) diff --git a/tests/compiler/python/module-return-object.phpt b/tests/compiler/python/module-return-object.phpt new file mode 100644 index 00000000..9595568a --- /dev/null +++ b/tests/compiler/python/module-return-object.phpt @@ -0,0 +1,23 @@ +--TEST-- +Python module scalar results remain wrapped objects +--SKIPIF-- + +--FILE-- +toFloat()); +} +?> +--EXPECT-- +string(8) "PyObject" +float(2) diff --git a/tests/compiler/python/object-protocol.phpt b/tests/compiler/python/object-protocol.phpt new file mode 100644 index 00000000..e5f47fa6 --- /dev/null +++ b/tests/compiler/python/object-protocol.phpt @@ -0,0 +1,99 @@ +--TEST-- +Python proxies support attributes, methods, items, iteration and callable objects +--SKIPIF-- + +--FILE-- +append(__DIR__ . '/lib'); + $object = protocol::protocol_object(); + + var_dump(scalarValue($object->name)); + $object->name = 'changed'; + var_dump(scalarValue($object->greet('hello', suffix: '?'))); + var_dump(scalarValue($object(mark('L', 3), right: mark('R', 4)))); + $args = [5]; + var_dump(scalarValue($object(...$args, right: 6))); + + $values = $object->values; + var_dump(isset($values[-1])); + var_dump(isset($values[-4])); + var_dump(scalarValue($values[-1])); + $values[-1] = 40; + unset($values[-2]); + $values[0] += python\int(5); + + $object->counter = python\int(2); + $object->counter += 3; + var_dump(scalarValue($object->counter)); + + foreach ($values as $key => $value) { + echo $key, ':', scalarValue($value), "\n"; + } + + $dict = python\dict(['first' => 1, 'second' => 2]); + var_dump(isset($dict['missing'])); + $dict['third'] = 3; + unset($dict['first']); + foreach ($dict as $key => $value) { + echo $key, '=', scalarValue($value), "\n"; + } + + unset($object->name); + try { + $object->name; + } catch (PyError $error) { + echo "attribute deleted\n"; + } + + $object->name = 'not callable'; + try { + $object->name(); + } catch (PyError $error) { + echo "attribute is not callable\n"; + } + + try { + python\int(42)(); + } catch (PyError $error) { + echo "object is not callable\n"; + } +} +?> +--EXPECT-- +string(7) "initial" +string(14) "hello changed?" +LRint(7) +int(11) +bool(true) +bool(false) +int(30) +int(5) +0:15 +1:40 +bool(false) +second=2 +third=3 +attribute deleted +attribute is not callable +object is not callable diff --git a/tests/compiler/python/operators.phpt b/tests/compiler/python/operators.phpt new file mode 100644 index 00000000..124e640d --- /dev/null +++ b/tests/compiler/python/operators.phpt @@ -0,0 +1,149 @@ +--TEST-- +Python objects use Python arithmetic, comparison, identity and truthiness protocols +--SKIPIF-- + +--FILE-- +toInt(); +} + +function asFloat($value): float +{ + return python\scalar($value)->toFloat(); +} + +function mark(string $name, int $value): PyObject +{ + echo $name; + return python\int($value); +} + +function main(): void +{ + $seven = python\int(7); + $three = python\int(3); + + echo asInt($seven + $three), "\n"; + echo asInt($seven - $three), "\n"; + echo asInt($seven * $three), "\n"; + echo asFloat($seven / 2), "\n"; + echo asInt($seven % $three), "\n"; + echo asInt($three ** 3), "\n"; + echo asInt($three << 2), "\n"; + echo asInt(16 >> $three), "\n"; + echo asInt($seven & $three), "\n"; + echo asInt($seven | $three), "\n"; + echo asInt($seven ^ $three), "\n"; + echo asInt(math::sqrt(9) + 1), "\n"; + + // The Python reflected protocol handles the native left operand. + echo python\scalar(python\repr(1 + python\complex(2, 3)))->toString(), "\n"; + + var_dump($seven == python\int(7)); + var_dump($seven != $three); + var_dump($seven > $three); + var_dump($seven >= python\int(7)); + var_dump($three < $seven); + var_dump($three <= python\int(3)); + + $list = python\list([7]); + $alias = $list; + var_dump($list === $alias); + var_dump($list !== python\list([7])); + + $three += 4; + echo asInt($three), "\n"; + + $compound = python\int(10); + $compound -= 3; + echo asInt($compound), "\n"; + $compound *= 2; + echo asInt($compound), "\n"; + $compound /= 4; + echo asFloat($compound), "\n"; + $compound = python\int(10); + $compound %= 4; + echo asInt($compound), "\n"; + $compound **= 3; + echo asInt($compound), "\n"; + $compound <<= 2; + echo asInt($compound), "\n"; + $compound >>= 1; + echo asInt($compound), "\n"; + $compound &= 6; + echo asInt($compound), "\n"; + $compound |= 1; + echo asInt($compound), "\n"; + $compound ^= 3; + echo asInt($compound), "\n"; + + echo asInt(-$three), "\n"; + echo asInt(+$three), "\n"; + echo asInt(~$three), "\n"; + + if (python\int(0)) { + echo "bad\n"; + } else { + echo "false\n"; + } + if (!python\list()) { + echo "empty\n"; + } + var_dump(python\int(0) || python\int(1)); + var_dump(python\int(1) && python\list([1])); + var_dump(python\int(0) xor python\list([1])); + + echo asInt(mark('L', 4) + mark('R', 5)), "\n"; +} +?> +--EXPECT-- +10 +4 +21 +3.5 +1 +27 +12 +2 +3 +7 +4 +4 +(3+3j) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +7 +7 +14 +3.5 +2 +8 +32 +16 +0 +1 +2 +-7 +7 +-8 +false +empty +bool(true) +bool(true) +bool(true) +LR9 diff --git a/tests/compiler/python/plain-value.phpt b/tests/compiler/python/plain-value.phpt new file mode 100644 index 00000000..3a83bedb --- /dev/null +++ b/tests/compiler/python/plain-value.phpt @@ -0,0 +1,53 @@ +--TEST-- +PyObject toPlainValue() explicitly returns a PHP value +--SKIPIF-- + +--FILE-- +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) diff --git a/tests/compiler/python/unused-module.phpt b/tests/compiler/python/unused-module.phpt new file mode 100644 index 00000000..d966c70a --- /dev/null +++ b/tests/compiler/python/unused-module.phpt @@ -0,0 +1,13 @@ +--TEST-- +An unused Python module alias does not import the module +--FILE-- + +--EXPECT-- +no import diff --git a/tests/compiler/trait/trait-constant-array-spread.phpt b/tests/compiler/trait/trait-constant-array-spread.phpt new file mode 100644 index 00000000..70e70238 --- /dev/null +++ b/tests/compiler/trait/trait-constant-array-spread.phpt @@ -0,0 +1,36 @@ +--TEST-- +Trait constants can merge another trait constant with array unpacking +--FILE-- + 'PyList', + 'dict' => 'PyDict', + ]; + + public const MERGED = [ + ...self::BASE, + 'int' => 'PyObject', + ]; +} + +class TraitConstantArraySpreadUser +{ + use TraitConstantArraySpread; +} + +function main(): void +{ + var_dump(TraitConstantArraySpreadUser::MERGED); +} +?> +--EXPECT-- +array(3) { + ["list"]=> + string(6) "PyList" + ["dict"]=> + string(6) "PyDict" + ["int"]=> + string(8) "PyObject" +}