From 69e2a29ec2a9d0af4fc8c33b2a46483fd0c8bb68 Mon Sep 17 00:00:00 2001 From: yavon007 Date: Tue, 22 Sep 2026 14:37:34 +0800 Subject: [PATCH] fix(python): retain facade types without phpy loaded (#128) --skip-tests --- .../python/facade-class-name-collision.php | 24 ++++ phpunit/code/python/facade-methods.php | 14 ++ phpunit/src/Python/PythonModuleTest.php | 29 +++++ .../src/Python/PythonWithoutExtensionTest.php | 63 +++++++++ src/CompilerBase.php | 6 + src/Context/CompilationStateTrait.php | 2 +- src/Python/PythonModuleTrait.php | 123 +++++++++++++++++- tests/compiler/python/facade-methods.phpt | 42 ++++++ 8 files changed, 296 insertions(+), 7 deletions(-) create mode 100644 phpunit/code/python/facade-class-name-collision.php create mode 100644 phpunit/code/python/facade-methods.php create mode 100644 phpunit/src/Python/PythonWithoutExtensionTest.php create mode 100644 tests/compiler/python/facade-methods.phpt diff --git a/phpunit/code/python/facade-class-name-collision.php b/phpunit/code/python/facade-class-name-collision.php new file mode 100644 index 00000000..fa1d7ac8 --- /dev/null +++ b/phpunit/code/python/facade-class-name-collision.php @@ -0,0 +1,24 @@ +toValue(), $list->toArray()); +} diff --git a/phpunit/code/python/facade-methods.php b/phpunit/code/python/facade-methods.php new file mode 100644 index 00000000..fe2633cf --- /dev/null +++ b/phpunit/code/python/facade-methods.php @@ -0,0 +1,14 @@ +count(); + $contains = $list->contains(2); + $slice = $list->slice(0, 2)->toArray(); + $set = python\set([1, 2]); + $member = $set->contains(1); + $value = python\int(42); + $value = $list; + var_dump($count, $contains, $slice, $member, $value->toArray()); +} diff --git a/phpunit/src/Python/PythonModuleTest.php b/phpunit/src/Python/PythonModuleTest.php index 099160e5..5731c98d 100644 --- a/phpunit/src/Python/PythonModuleTest.php +++ b/phpunit/src/Python/PythonModuleTest.php @@ -348,6 +348,35 @@ final class PythonModuleTest extends TestCase $this->assertStringNotContainsString('phpy::', $cpp); } + public function testPythonFacadeMethodsKeepTheirPhpReturnTypes(): void + { + global $translator; + $translator = $compiler = CompilerTest::create(TYPEPHP_ROOT_PATH); + $source = TYPEPHP_ROOT_PATH . '/phpunit/code/python/facade-methods.php'; + $compiler->addFiles([$source]); + $compiler->prepareFile($source); + $cpp = file_get_contents($compiler->convertFile($source)); + + self::assertStringContainsString('php::Int count = 0;', $cpp); + self::assertStringContainsString('php::Bool contains = 0;', $cpp); + self::assertStringContainsString('php::Bool member = 0;', $cpp); + self::assertStringContainsString('php::Array slice;', $cpp); + self::assertStringContainsString('php::python::toArray(', $cpp); + self::assertStringNotContainsString('php::python::callMember(', $cpp); + } + + public function testProjectClassesWithFacadeNamesDoNotUseThePythonBridge(): void + { + global $translator; + $translator = $compiler = CompilerTest::create(TYPEPHP_ROOT_PATH); + $source = TYPEPHP_ROOT_PATH . '/phpunit/code/python/facade-class-name-collision.php'; + $compiler->addFiles([$source]); + $compiler->prepareFile($source); + $cpp = file_get_contents($compiler->convertFile($source)); + + self::assertStringNotContainsString('php::python::', $cpp); + } + private function compileFixture(string $file): void { global $translator; diff --git a/phpunit/src/Python/PythonWithoutExtensionTest.php b/phpunit/src/Python/PythonWithoutExtensionTest.php new file mode 100644 index 00000000..9119c969 --- /dev/null +++ b/phpunit/src/Python/PythonWithoutExtensionTest.php @@ -0,0 +1,63 @@ +addFiles([$source]); + $translator->prepareFile($source); + $result[$fixture] = file_get_contents($translator->convertFile($source)) + . file_get_contents($translator->genExtension()); +} +echo "\nTYPEPHP_RESULT:" . json_encode($result, JSON_THROW_ON_ERROR); +PHP; + $process = proc_open($command, [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ], $pipes, TYPEPHP_ROOT_PATH); + self::assertIsResource($process); + fclose($pipes[0]); + $stdout = stream_get_contents($pipes[1]); + fclose($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[2]); + self::assertSame(0, proc_close($process), $stdout . $stderr); + self::assertSame(1, preg_match('/\nTYPEPHP_RESULT:(.*)\z/s', $stdout, $matches), $stdout); + $generated = json_decode($matches[1], true, flags: JSON_THROW_ON_ERROR); + + self::assertStringNotContainsString('php::python::', $generated['facade-class-name-collision']); + self::assertStringContainsString('iadd', $generated['operators']); + self::assertStringContainsString('is_not', $generated['operators']); + self::assertStringContainsString('php::python::call(', $generated['object-protocol']); + self::assertStringContainsString('php::python::toArray(', $generated['object-conversion-methods']); + self::assertStringNotContainsString('php::toArray(', $generated['object-conversion-methods']); + self::assertStringContainsString('php::Var value;', $generated['object-conversion-methods']); + self::assertStringContainsString('php::python::toArray(', $generated['facade-methods']); + self::assertStringNotContainsString('php::python::callMember(', $generated['facade-methods']); + } +} diff --git a/src/CompilerBase.php b/src/CompilerBase.php index dc7427ef..da2bd0d1 100644 --- a/src/CompilerBase.php +++ b/src/CompilerBase.php @@ -2596,6 +2596,12 @@ class CompilerBase implements PropertyAccessContext return true; } + // Python facade inheritance is a compiler-known bridge contract and + // does not depend on phpy being loaded in the compiler process. + if ($this->isPythonFacadeAssignableTo($class, $expected)) { + return true; + } + if (!$this->hasClass($class) && !$this->hasInterface($class) && !$this->isInternalClass($class) diff --git a/src/Context/CompilationStateTrait.php b/src/Context/CompilationStateTrait.php index 4daaf5ef..88c343e7 100644 --- a/src/Context/CompilationStateTrait.php +++ b/src/Context/CompilationStateTrait.php @@ -258,7 +258,7 @@ trait CompilationStateTrait // Interfaces have no concrete method body for native calls. Abstract classes may have concrete methods. if ($this->isInterface($class)) { $this->context->declaredObjects[$name] = $class; - } elseif ($this->isNativeClass($class) or $this->isInternalClass($class)) { + } elseif ($this->isNativeClass($class) || $this->isInternalClass($class) || $this->isPythonFacadeClass($class)) { $this->context->objects[$name] = $class; } } diff --git a/src/Python/PythonModuleTrait.php b/src/Python/PythonModuleTrait.php index cd2a3dd6..19762de9 100644 --- a/src/Python/PythonModuleTrait.php +++ b/src/Python/PythonModuleTrait.php @@ -9,6 +9,51 @@ use TypePhp\Type; trait PythonModuleTrait { + /** phpy facade inheritance is part of the Python language bridge, even without the extension loaded. */ + private const PYTHON_FACADE_PARENTS = [ + 'pyobject' => '', + 'pysequence' => 'pyobject', + 'pylist' => 'pysequence', + 'pytuple' => 'pysequence', + 'pydict' => 'pyobject', + 'pyset' => 'pyobject', + 'pystr' => 'pyobject', + 'pytype' => 'pyobject', + 'pyfn' => 'pyobject', + 'pyiter' => 'pyobject', + 'pymodule' => 'pyobject', + ]; + + /** + * Public method contracts from phpy's stubs/phpy_{object,sequence,set}.stub.php. + * These Zend methods must not become Python attribute calls when phpy is absent. + */ + private const PYTHON_FACADE_METHOD_RETURN_TYPES = [ + 'pyobject' => [ + '__construct' => Type::VOID, + '__call' => Type::VAR, + '__get' => Type::VAR, + '__set' => Type::VOID, + '__unset' => Type::VOID, + '__tostring' => Type::STR, + 'toarray' => Type::ARRAY, + 'tovalue' => Type::VAR, + '__invoke' => Type::VAR, + 'offsetget' => Type::VAR, + 'offsetset' => Type::VOID, + 'offsetunset' => Type::VOID, + 'offsetexists' => Type::BOOL, + 'key' => Type::VAR, + 'next' => Type::VOID, + 'rewind' => Type::VOID, + 'valid' => Type::BOOL, + 'current' => Type::VAR, + 'count' => Type::INT, + ], + 'pysequence' => ['contains' => Type::BOOL, 'slice' => Type::OBJECT], + 'pyset' => ['contains' => Type::BOOL], + ]; + /** @var array TypePHP constructor sugar to the existing phpy facade class. */ private const PYTHON_CONSTRUCTOR_CLASSES = [ 'list' => 'PyList', @@ -56,6 +101,47 @@ trait PythonModuleTrait protected bool $pythonRuntimeUsed = false; + protected function isPythonFacadeClass(string $class): bool + { + $class = ltrim($class, '\\'); + return isset(self::PYTHON_FACADE_PARENTS[strtolower($class)]) + && !$this->hasClass($class) + && !$this->hasInterface($class); + } + + protected function isPythonFacadeAssignableTo(string $class, string $expected): bool + { + if (!$this->isPythonFacadeClass($class) || !$this->isPythonFacadeClass($expected)) { + return false; + } + $class = strtolower(ltrim($class, '\\')); + $expected = strtolower(ltrim($expected, '\\')); + do { + if ($class === $expected) { + return true; + } + $class = self::PYTHON_FACADE_PARENTS[$class]; + } while ($class !== ''); + return false; + } + + private function getPythonFacadeMethodReturnType(string $class, string $method): ?string + { + if (!$this->isPythonFacadeClass($class)) { + return null; + } + $method = strtolower($method); + $class = strtolower(ltrim($class, '\\')); + do { + $type = self::PYTHON_FACADE_METHOD_RETURN_TYPES[$class][$method] ?? null; + if ($type !== null) { + return $type; + } + $class = self::PYTHON_FACADE_PARENTS[$class]; + } while ($class !== ''); + return null; + } + /** Return true when the expression is statically known to hold a phpy proxy. */ protected function isPythonObjectExpr(NodeAbstract $expr): bool { @@ -63,8 +149,12 @@ trait PythonModuleTrait if ($class === '') { return false; } - return strcasecmp($class, 'PyObject') === 0 - || $this->isObjectClassStaticallyAssignableTo($class, 'PyObject'); + if ($this->isPythonFacadeClass($class)) { + return true; + } + return !$this->hasClass('PyObject') + && !$this->hasInterface('PyObject') + && $this->isObjectClassStaticallyAssignableTo($class, 'PyObject'); } /** @@ -79,6 +169,9 @@ trait PythonModuleTrait } $class = $this->detectClassOfExpr($receiver); + if ($this->getPythonFacadeMethodReturnType($class, $method) !== null) { + return false; + } return $class === '' || !\TypePhp\Resolver\Reflection::hasMethod($class, $method); } @@ -87,17 +180,17 @@ trait PythonModuleTrait if (!$this->isNamedMethod($expr->name) || !$this->isPythonObjectExpr($expr->var)) { return null; } - $method = $expr->name->toString(); + $method = strtolower($expr->name->toString()); $helper = match ($method) { - 'toValue' => 'toValue', - 'toArray' => 'toArray', + 'tovalue' => 'toValue', + 'toarray' => 'toArray', default => null, }; if ($helper === null) { return null; } if ($expr->args !== []) { - $this->fatalError($expr, "The {$method} method does not accept parameters"); + $this->fatalError($expr, "The {$helper} method does not accept parameters"); } $this->markPythonRuntimeUsed(); return 'php::python::' . $helper . '(' . $receiver . ')'; @@ -571,6 +664,15 @@ trait PythonModuleTrait return Type::OBJECT; } if ($expr instanceof Expr\MethodCall && $this->isPythonObjectExpr($expr->var)) { + if ($this->isIdExpr($expr->name)) { + $facadeType = $this->getPythonFacadeMethodReturnType( + $this->detectClassOfExpr($expr->var), + $this->parseIdentifier($expr->name), + ); + if ($facadeType !== null) { + return $facadeType; + } + } if (!$this->isIdExpr($expr->name) || $this->isPythonDynamicMethodCall($expr->var, $this->parseIdentifier($expr->name)) ) { @@ -613,6 +715,15 @@ trait PythonModuleTrait return 'PyObject'; } if ($expr instanceof Expr\MethodCall && $this->isPythonObjectExpr($expr->var)) { + if ($this->isIdExpr($expr->name)) { + $facadeType = $this->getPythonFacadeMethodReturnType( + $this->detectClassOfExpr($expr->var), + $this->parseIdentifier($expr->name), + ); + if ($facadeType !== null) { + return $facadeType === Type::OBJECT ? 'PyObject' : ''; + } + } if (!$this->isIdExpr($expr->name) || $this->isPythonDynamicMethodCall($expr->var, $this->parseIdentifier($expr->name)) ) { diff --git a/tests/compiler/python/facade-methods.phpt b/tests/compiler/python/facade-methods.phpt new file mode 100644 index 00000000..358fdb51 --- /dev/null +++ b/tests/compiler/python/facade-methods.phpt @@ -0,0 +1,42 @@ +--TEST-- +Python facade methods retain their PHP return types and inherited behavior +--SKIPIF-- + +--FILE-- +count(); + $contains = $list->contains(2); + $slice = $list->slice(0, 2)->toArray(); + $set = python\set([1, 2]); + $member = $set->contains(1); + $value = python\int(42); + $value = $list; + var_dump($count, $contains, $slice, $member, $value->toArray()); +} +?> +--EXPECT-- +int(3) +bool(true) +array(2) { + [0]=> + int(1) + [1]=> + int(2) +} +bool(true) +array(3) { + [0]=> + int(1) + [1]=> + int(2) + [2]=> + int(3) +}