├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── TODO.org ├── composer.json ├── composer.lock ├── examples └── doctrine.py ├── phpbridge ├── __init__.py ├── classes.py ├── docblocks.py ├── functions.py ├── modules.py ├── objects.py ├── php-server │ ├── CommandServer.php │ ├── Commands.php │ ├── Exceptions │ │ ├── AttributeError.php │ │ └── ConnectionLostException.php │ ├── NonFunctionProxy.php │ ├── ObjectStore.php │ ├── Representer │ │ ├── PythonRepresenter.php │ │ ├── Representer.php │ │ └── RepresenterInterface.php │ └── StdioCommandServer.php ├── server.php └── utils.py ├── psalm.xml └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | *.pyc 3 | /.mypy_cache 4 | /vendor 5 | /.idea 6 | /phpbridge.egg-info 7 | /build 8 | /dist 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2018 Jan Verbeek 2 | 3 | Permission to use, copy, modify, and distribute this software for any 4 | purpose with or without fee is hereby granted, provided that the above 5 | copyright notice and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 14 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: php python 2 | 3 | php: phpcs psalm 4 | 5 | python: flake8 mypy 6 | 7 | phpcs: 8 | vendor/bin/phpcs --standard=PSR2 phpbridge/php-server 9 | 10 | psalm: 11 | vendor/bin/psalm 12 | 13 | flake8: 14 | python3 -m flake8 phpbridge 15 | 16 | mypy: 17 | python3 -m mypy --strict -m phpbridge 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This is a Python module for running PHP programs. It lets you import PHP functions, classes, objects, constants and variables to work just like regular Python versions. 2 | 3 | # Examples 4 | 5 | You can call functions: 6 | ```pycon 7 | >>> from phpbridge import php 8 | >>> php.array_reverse(['foo', 'bar', 'baz']) 9 | Array.list(['baz', 'bar', 'foo']) 10 | >>> php.echo("foo\n") 11 | foo 12 | >>> php.getimagesize("http://php.net/images/logos/new-php-logo.png") 13 | Array([('0', 200), ('1', 106), ('2', 3), ('3', 'width="200" height="106"'), ('bits', 8), ('mime', 'image/png')]) 14 | ``` 15 | 16 | You can create and use objects: 17 | ```pycon 18 | >>> php.DateTime 19 | 20 | >>> date = php.DateTime() 21 | >>> print(date) 22 | 23 | >>> date.getOffset() 24 | 7200 25 | >>> php.ArrayAccess 26 | 27 | >>> issubclass(php.ArrayObject, php.ArrayAccess) 28 | True 29 | ``` 30 | 31 | You can use keyword arguments, even though PHP doesn't support them: 32 | ```pycon 33 | >>> date.setDate(year=1900, day=20, month=10) 34 | 35 | ``` 36 | 37 | You can loop over iterators and traversables: 38 | ```pycon 39 | >>> for path, file in php.RecursiveIteratorIterator(php.RecursiveDirectoryIterator('.git/logs')): 40 | ... print("{}: {}".format(path, file.getSize())) 41 | ... 42 | .git/logs/.: 16 43 | .git/logs/..: 144 44 | .git/logs/HEAD: 2461 45 | [...] 46 | ``` 47 | 48 | You can get help: 49 | ```pycon 50 | >>> help(php.echo) 51 | Help on function echo: 52 | 53 | echo(arg1, *rest) 54 | Output one or more strings. 55 | 56 | @param mixed $arg1 57 | @param mixed ...$rest 58 | 59 | @return void 60 | ``` 61 | 62 | You can import namespaces as modules: 63 | ```pycon 64 | >>> from phpbridge.php.blyxxyz.PythonServer import NonFunctionProxy 65 | >>> help(NonFunctionProxy) 66 | Help on class blyxxyz\PythonServer\NonFunctionProxy in module phpbridge.php.blyxxyz.PythonServer: 67 | 68 | class blyxxyz\PythonServer\NonFunctionProxy(phpbridge.objects.PHPObject) 69 | | Provide function-like language constructs as static methods. 70 | | 71 | | `isset` and `empty` are not provided because it's impossible for a real 72 | | function to check whether its argument is defined. 73 | | 74 | | Method resolution order: 75 | | blyxxyz\PythonServer\NonFunctionProxy 76 | | phpbridge.objects.PHPObject 77 | | builtins.object 78 | | 79 | | Class methods defined here: 80 | | 81 | | array(val) -> dict from phpbridge.objects.PHPClass 82 | | Cast a value to an array. 83 | | 84 | | @param mixed $val 85 | | 86 | | @return array 87 | [...] 88 | ``` 89 | 90 | You can index, and get lengths: 91 | ```pycon 92 | >>> arr = php.ArrayObject(['foo', 'bar', 'baz']) 93 | >>> arr[10] = 'foobar' 94 | >>> len(arr) 95 | 4 96 | ``` 97 | 98 | You can work with PHP's exceptions: 99 | ```pycon 100 | >>> try: 101 | ... php.get_resource_type(3) 102 | ... except php.TypeError as e: 103 | ... print(e.getMessage()) 104 | ... 105 | get_resource_type() expects parameter 1 to be resource, integer given 106 | ``` 107 | 108 | # Features 109 | * Using PHP functions 110 | * Keyword arguments are supported and translated based on the signature 111 | * Docblocks are also converted, so `help` is informative 112 | * Using PHP classes like Python classes 113 | * Methods and constants are defined right away based on the PHP class 114 | * Docblocks are treated like docstrings, so `help` works and is informative 115 | * The original inheritance structure is copied 116 | * Default properties become Python properties with documentation 117 | * Other properties are accessed on the fly as a fallback for attribute access 118 | * Creating and using objects 119 | * Importing namespaces as modules 120 | * Getting and setting constants 121 | * Getting and setting global variables 122 | * Translating exceptions so they can be treated as both Python exceptions and PHP objects 123 | * Tab completion in the interpreter 124 | * Python-like reprs for PHP objects, with information like var_dump in a more compact form 125 | 126 | # Caveats 127 | * On Windows, stdin and stderr are used to communicate, so PHP can't read input and if it writes to stderr the connection is lost 128 | * You can only pass basic Python objects into PHP 129 | * Namespaces can shadow names in an unintuitive way 130 | * Because PHP only has one kind of array, its arrays are translated to a special kind of ordered dictionary 131 | 132 | # Name conflicts 133 | Some PHP packages use the same name both for a class and a namespace. As an example, take `nikic/PHP-Parser`. 134 | 135 | `PhpParser\Node` is a class, but `PhpParser\Node\Param` is also a class. This means `phpbridge.php.PhpParser.Node` becomes ambiguous - it could either refer to the `Node` class, or the namespace of the `Param` class. 136 | 137 | In case of such a conflict, the class is preferred over the namespace. To get `Param`, a `from` import has to be used: 138 | ```pycon 139 | >>> php.require('vendor/autoload.php') 140 | 141 | >>> import phpbridge.php.PhpParser.Node as Node # Not the namespace! 142 | >>> Node 143 | 144 | >>> from phpbridge.php.PhpParser.Node import Param # The class we want 145 | >>> Param 146 | 147 | >>> import phpbridge.php.PhpParser.Node.Param as Param # Doesn't work 148 | Traceback (most recent call last): 149 | File "", line 1, in 150 | AttributeError: type object 'PhpParser\Node' has no attribute 'Param' 151 | ``` 152 | 153 | If there are no conflicts, things work as expected: 154 | ```pycon 155 | >>> from phpbridge.php.blyxxyz.PythonServer import Commands 156 | >>> Commands 157 | 158 | >>> import phpbridge.php.blyxxyz.PythonServer as PythonServer 159 | >>> PythonServer 160 | 161 | >>> PythonServer.Commands 162 | 163 | ``` 164 | 165 | # Installing 166 | 167 | ``` 168 | pip3 install phpbridge 169 | ``` 170 | 171 | The only dependencies are PHP 7.0+, Python 3.5+, ext-json, ext-reflection and ext-mbstring. Composer can be used to install development tools and set up autoloading, but it's not required. 172 | -------------------------------------------------------------------------------- /TODO.org: -------------------------------------------------------------------------------- 1 | - Convert use of send_command to bridge methods 2 | - Change phpbridge.classes to use inherited fake traits 3 | - Add a helpers submodule for things like autoloading and Symfony 4 | - If composer dump-autoload -o is used, namespaces could be imported in advance and importing new ones could be disabled after that 5 | - This would help with some nasty edge cases in the current system 6 | - A clean way to get Symfony services 7 | - Document more 8 | - Don't let bridge namespace modules conflict with other modules 9 | - Publish to PyPi 10 | - Write tests 11 | - Don't use stdin and stderr on Windows, if possible 12 | - Make bridge more symmetric 13 | - Allow sending Python proxy objects into PHP 14 | - Allow Python to receive commands from PHP 15 | - Try to make it possible to recover from a missed message 16 | - Try to deal with KeyboardInterrupts simultaneously killing PHP 17 | - Try to auto-generate mypy stubs 18 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "blyxxyz/python-server", 3 | "description": "A way to call PHP code from Python", 4 | "type": "library", 5 | "keywords": ["python"], 6 | "homepage": "https://github.com/blyxxyz/Python-PHP-Bridge", 7 | "license": "ISC", 8 | "require-dev": { 9 | "vimeo/psalm": "^1.1", 10 | "squizlabs/php_codesniffer": "^3.2" 11 | }, 12 | "authors": [ 13 | { 14 | "name": "Jan Verbeek", 15 | "email": "jan.verbeek@posteo.nl" 16 | } 17 | ], 18 | "require": { 19 | "php": "^7.0", 20 | "ext-json": "*", 21 | "ext-Reflection": "*" 22 | }, 23 | "autoload": { 24 | "psr-4": { 25 | "blyxxyz\\PythonServer\\": "phpbridge/php-server" 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /composer.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_readme": [ 3 | "This file locks the dependencies of your project to a known state", 4 | "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", 5 | "This file is @generated automatically" 6 | ], 7 | "content-hash": "7e24cafb0cff3c1d3b45d96a56b57b15", 8 | "packages": [], 9 | "packages-dev": [ 10 | { 11 | "name": "composer/xdebug-handler", 12 | "version": "1.3.1", 13 | "source": { 14 | "type": "git", 15 | "url": "https://github.com/composer/xdebug-handler.git", 16 | "reference": "dc523135366eb68f22268d069ea7749486458562" 17 | }, 18 | "dist": { 19 | "type": "zip", 20 | "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/dc523135366eb68f22268d069ea7749486458562", 21 | "reference": "dc523135366eb68f22268d069ea7749486458562", 22 | "shasum": "" 23 | }, 24 | "require": { 25 | "php": "^5.3.2 || ^7.0", 26 | "psr/log": "^1.0" 27 | }, 28 | "require-dev": { 29 | "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.5" 30 | }, 31 | "type": "library", 32 | "autoload": { 33 | "psr-4": { 34 | "Composer\\XdebugHandler\\": "src" 35 | } 36 | }, 37 | "notification-url": "https://packagist.org/downloads/", 38 | "license": [ 39 | "MIT" 40 | ], 41 | "authors": [ 42 | { 43 | "name": "John Stevenson", 44 | "email": "john-stevenson@blueyonder.co.uk" 45 | } 46 | ], 47 | "description": "Restarts a process without xdebug.", 48 | "keywords": [ 49 | "Xdebug", 50 | "performance" 51 | ], 52 | "time": "2018-11-29T10:59:02+00:00" 53 | }, 54 | { 55 | "name": "muglug/package-versions-56", 56 | "version": "1.2.4", 57 | "source": { 58 | "type": "git", 59 | "url": "https://github.com/muglug/PackageVersions.git", 60 | "reference": "a67bed26deaaf9269a348e53063bc8d4dcc60ffd" 61 | }, 62 | "dist": { 63 | "type": "zip", 64 | "url": "https://api.github.com/repos/muglug/PackageVersions/zipball/a67bed26deaaf9269a348e53063bc8d4dcc60ffd", 65 | "reference": "a67bed26deaaf9269a348e53063bc8d4dcc60ffd", 66 | "shasum": "" 67 | }, 68 | "require": { 69 | "composer-plugin-api": "^1.0", 70 | "php": "^5.6 || ^7.0" 71 | }, 72 | "require-dev": { 73 | "composer/composer": "^1.3", 74 | "ext-zip": "*", 75 | "phpunit/phpunit": "^5.7.5" 76 | }, 77 | "type": "composer-plugin", 78 | "extra": { 79 | "class": "Muglug\\PackageVersions\\Installer", 80 | "branch-alias": { 81 | "dev-master": "2.0.x-dev" 82 | } 83 | }, 84 | "autoload": { 85 | "psr-4": { 86 | "Muglug\\PackageVersions\\": "src/PackageVersions" 87 | } 88 | }, 89 | "notification-url": "https://packagist.org/downloads/", 90 | "license": [ 91 | "MIT" 92 | ], 93 | "authors": [ 94 | { 95 | "name": "Marco Pivetta", 96 | "email": "ocramius@gmail.com" 97 | }, 98 | { 99 | "name": "Abdul Malik Ikhsan", 100 | "email": "samsonasik@gmail.com" 101 | }, 102 | { 103 | "name": "Matt Brown", 104 | "email": "github@muglug.com" 105 | } 106 | ], 107 | "description": "A backport of ocramius/package-versions that supports php ^5.6. Composer plugin that provides efficient querying for installed package versions (no runtime IO)", 108 | "time": "2018-03-26T03:22:13+00:00" 109 | }, 110 | { 111 | "name": "nikic/php-parser", 112 | "version": "v3.1.5", 113 | "source": { 114 | "type": "git", 115 | "url": "https://github.com/nikic/PHP-Parser.git", 116 | "reference": "bb87e28e7d7b8d9a7fda231d37457c9210faf6ce" 117 | }, 118 | "dist": { 119 | "type": "zip", 120 | "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/bb87e28e7d7b8d9a7fda231d37457c9210faf6ce", 121 | "reference": "bb87e28e7d7b8d9a7fda231d37457c9210faf6ce", 122 | "shasum": "" 123 | }, 124 | "require": { 125 | "ext-tokenizer": "*", 126 | "php": ">=5.5" 127 | }, 128 | "require-dev": { 129 | "phpunit/phpunit": "~4.0|~5.0" 130 | }, 131 | "bin": [ 132 | "bin/php-parse" 133 | ], 134 | "type": "library", 135 | "extra": { 136 | "branch-alias": { 137 | "dev-master": "3.0-dev" 138 | } 139 | }, 140 | "autoload": { 141 | "psr-4": { 142 | "PhpParser\\": "lib/PhpParser" 143 | } 144 | }, 145 | "notification-url": "https://packagist.org/downloads/", 146 | "license": [ 147 | "BSD-3-Clause" 148 | ], 149 | "authors": [ 150 | { 151 | "name": "Nikita Popov" 152 | } 153 | ], 154 | "description": "A PHP parser written in PHP", 155 | "keywords": [ 156 | "parser", 157 | "php" 158 | ], 159 | "time": "2018-02-28T20:30:58+00:00" 160 | }, 161 | { 162 | "name": "openlss/lib-array2xml", 163 | "version": "0.5.1", 164 | "source": { 165 | "type": "git", 166 | "url": "https://github.com/nullivex/lib-array2xml.git", 167 | "reference": "c8b5998a342d7861f2e921403f44e0a2f3ef2be0" 168 | }, 169 | "dist": { 170 | "type": "zip", 171 | "url": "https://api.github.com/repos/nullivex/lib-array2xml/zipball/c8b5998a342d7861f2e921403f44e0a2f3ef2be0", 172 | "reference": "c8b5998a342d7861f2e921403f44e0a2f3ef2be0", 173 | "shasum": "" 174 | }, 175 | "require": { 176 | "php": ">=5.3.2" 177 | }, 178 | "type": "library", 179 | "autoload": { 180 | "psr-0": { 181 | "LSS": "" 182 | } 183 | }, 184 | "notification-url": "https://packagist.org/downloads/", 185 | "license": [ 186 | "Apache-2.0" 187 | ], 188 | "authors": [ 189 | { 190 | "name": "Bryan Tong", 191 | "email": "contact@nullivex.com", 192 | "homepage": "http://bryantong.com" 193 | }, 194 | { 195 | "name": "Tony Butler", 196 | "email": "spudz76@gmail.com", 197 | "homepage": "http://openlss.org" 198 | } 199 | ], 200 | "description": "Array2XML conversion library credit to lalit.org", 201 | "homepage": "http://openlss.org", 202 | "keywords": [ 203 | "array", 204 | "array conversion", 205 | "xml", 206 | "xml conversion" 207 | ], 208 | "time": "2016-11-10T19:10:18+00:00" 209 | }, 210 | { 211 | "name": "php-cs-fixer/diff", 212 | "version": "v1.3.0", 213 | "source": { 214 | "type": "git", 215 | "url": "https://github.com/PHP-CS-Fixer/diff.git", 216 | "reference": "78bb099e9c16361126c86ce82ec4405ebab8e756" 217 | }, 218 | "dist": { 219 | "type": "zip", 220 | "url": "https://api.github.com/repos/PHP-CS-Fixer/diff/zipball/78bb099e9c16361126c86ce82ec4405ebab8e756", 221 | "reference": "78bb099e9c16361126c86ce82ec4405ebab8e756", 222 | "shasum": "" 223 | }, 224 | "require": { 225 | "php": "^5.6 || ^7.0" 226 | }, 227 | "require-dev": { 228 | "phpunit/phpunit": "^5.7.23 || ^6.4.3", 229 | "symfony/process": "^3.3" 230 | }, 231 | "type": "library", 232 | "autoload": { 233 | "classmap": [ 234 | "src/" 235 | ] 236 | }, 237 | "notification-url": "https://packagist.org/downloads/", 238 | "license": [ 239 | "BSD-3-Clause" 240 | ], 241 | "authors": [ 242 | { 243 | "name": "Kore Nordmann", 244 | "email": "mail@kore-nordmann.de" 245 | }, 246 | { 247 | "name": "Sebastian Bergmann", 248 | "email": "sebastian@phpunit.de" 249 | }, 250 | { 251 | "name": "SpacePossum" 252 | } 253 | ], 254 | "description": "sebastian/diff v2 backport support for PHP5.6", 255 | "homepage": "https://github.com/PHP-CS-Fixer", 256 | "keywords": [ 257 | "diff" 258 | ], 259 | "time": "2018-02-15T16:58:55+00:00" 260 | }, 261 | { 262 | "name": "psr/log", 263 | "version": "1.1.0", 264 | "source": { 265 | "type": "git", 266 | "url": "https://github.com/php-fig/log.git", 267 | "reference": "6c001f1daafa3a3ac1d8ff69ee4db8e799a654dd" 268 | }, 269 | "dist": { 270 | "type": "zip", 271 | "url": "https://api.github.com/repos/php-fig/log/zipball/6c001f1daafa3a3ac1d8ff69ee4db8e799a654dd", 272 | "reference": "6c001f1daafa3a3ac1d8ff69ee4db8e799a654dd", 273 | "shasum": "" 274 | }, 275 | "require": { 276 | "php": ">=5.3.0" 277 | }, 278 | "type": "library", 279 | "extra": { 280 | "branch-alias": { 281 | "dev-master": "1.0.x-dev" 282 | } 283 | }, 284 | "autoload": { 285 | "psr-4": { 286 | "Psr\\Log\\": "Psr/Log/" 287 | } 288 | }, 289 | "notification-url": "https://packagist.org/downloads/", 290 | "license": [ 291 | "MIT" 292 | ], 293 | "authors": [ 294 | { 295 | "name": "PHP-FIG", 296 | "homepage": "http://www.php-fig.org/" 297 | } 298 | ], 299 | "description": "Common interface for logging libraries", 300 | "homepage": "https://github.com/php-fig/log", 301 | "keywords": [ 302 | "log", 303 | "psr", 304 | "psr-3" 305 | ], 306 | "time": "2018-11-20T15:27:04+00:00" 307 | }, 308 | { 309 | "name": "squizlabs/php_codesniffer", 310 | "version": "3.3.2", 311 | "source": { 312 | "type": "git", 313 | "url": "https://github.com/squizlabs/PHP_CodeSniffer.git", 314 | "reference": "6ad28354c04b364c3c71a34e4a18b629cc3b231e" 315 | }, 316 | "dist": { 317 | "type": "zip", 318 | "url": "https://api.github.com/repos/squizlabs/PHP_CodeSniffer/zipball/6ad28354c04b364c3c71a34e4a18b629cc3b231e", 319 | "reference": "6ad28354c04b364c3c71a34e4a18b629cc3b231e", 320 | "shasum": "" 321 | }, 322 | "require": { 323 | "ext-simplexml": "*", 324 | "ext-tokenizer": "*", 325 | "ext-xmlwriter": "*", 326 | "php": ">=5.4.0" 327 | }, 328 | "require-dev": { 329 | "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0" 330 | }, 331 | "bin": [ 332 | "bin/phpcs", 333 | "bin/phpcbf" 334 | ], 335 | "type": "library", 336 | "extra": { 337 | "branch-alias": { 338 | "dev-master": "3.x-dev" 339 | } 340 | }, 341 | "notification-url": "https://packagist.org/downloads/", 342 | "license": [ 343 | "BSD-3-Clause" 344 | ], 345 | "authors": [ 346 | { 347 | "name": "Greg Sherwood", 348 | "role": "lead" 349 | } 350 | ], 351 | "description": "PHP_CodeSniffer tokenizes PHP, JavaScript and CSS files and detects violations of a defined set of coding standards.", 352 | "homepage": "http://www.squizlabs.com/php-codesniffer", 353 | "keywords": [ 354 | "phpcs", 355 | "standards" 356 | ], 357 | "time": "2018-09-23T23:08:17+00:00" 358 | }, 359 | { 360 | "name": "vimeo/psalm", 361 | "version": "1.1.9", 362 | "source": { 363 | "type": "git", 364 | "url": "https://github.com/vimeo/psalm.git", 365 | "reference": "d15cf3b7f50249caf933144c8926c8e69aff3d34" 366 | }, 367 | "dist": { 368 | "type": "zip", 369 | "url": "https://api.github.com/repos/vimeo/psalm/zipball/d15cf3b7f50249caf933144c8926c8e69aff3d34", 370 | "reference": "d15cf3b7f50249caf933144c8926c8e69aff3d34", 371 | "shasum": "" 372 | }, 373 | "require": { 374 | "composer/xdebug-handler": "^1.1", 375 | "muglug/package-versions-56": "1.2.4", 376 | "nikic/php-parser": "^3.1", 377 | "openlss/lib-array2xml": "^0.0.10||^0.5.1", 378 | "php": "^5.6||^7.0", 379 | "php-cs-fixer/diff": "^1.2" 380 | }, 381 | "provide": { 382 | "psalm/psalm": "self.version" 383 | }, 384 | "require-dev": { 385 | "bamarni/composer-bin-plugin": "^1.2", 386 | "php-coveralls/php-coveralls": "^2.0", 387 | "phpunit/phpunit": "^5.7.4", 388 | "squizlabs/php_codesniffer": "^3.0" 389 | }, 390 | "suggest": { 391 | "ext-igbinary": "^2.0.5" 392 | }, 393 | "bin": [ 394 | "psalm", 395 | "psalter" 396 | ], 397 | "type": "library", 398 | "extra": { 399 | "branch-alias": { 400 | "dev-master": "2.x-dev", 401 | "dev-1.x": "1.x-dev" 402 | } 403 | }, 404 | "autoload": { 405 | "psr-4": { 406 | "Psalm\\": "src/Psalm" 407 | } 408 | }, 409 | "notification-url": "https://packagist.org/downloads/", 410 | "license": [ 411 | "MIT" 412 | ], 413 | "authors": [ 414 | { 415 | "name": "Matthew Brown" 416 | } 417 | ], 418 | "description": "A static analysis tool for finding errors in PHP applications", 419 | "keywords": [ 420 | "code", 421 | "inspection", 422 | "php" 423 | ], 424 | "time": "2018-08-14T16:06:16+00:00" 425 | } 426 | ], 427 | "aliases": [], 428 | "minimum-stability": "stable", 429 | "stability-flags": [], 430 | "prefer-stable": false, 431 | "prefer-lowest": false, 432 | "platform": { 433 | "php": "^7.0", 434 | "ext-json": "*", 435 | "ext-reflection": "*" 436 | }, 437 | "platform-dev": [] 438 | } 439 | -------------------------------------------------------------------------------- /examples/doctrine.py: -------------------------------------------------------------------------------- 1 | """Use Symfony and Doctrine to print a list of test users.""" 2 | 3 | # Initialize PHP 4 | from phpbridge import php 5 | php.require('vendor/autoload.php') 6 | 7 | # Make this namespace accessible for later 8 | import phpbridge.php.MyNamespace.Entities 9 | 10 | # Start Symfony and get a container object 11 | kernel = php.AppKernel('dev', False) 12 | kernel.boot() 13 | container = kernel.getContainer() 14 | 15 | # Start and get an entity manager 16 | em = container.get('doctrine.orm.entity_manager') 17 | 18 | # You can use the class object instead of a string 19 | users = em.getRepository(php.MyNamespace.Entities.User) 20 | 21 | for user in users.findBy({'email': 'test@example.org'}): 22 | print(user.getUsername()) 23 | -------------------------------------------------------------------------------- /phpbridge/__init__.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import json 3 | import math 4 | import os 5 | import subprocess as sp 6 | import sys 7 | import types 8 | 9 | from collections import ChainMap, OrderedDict 10 | from typing import (Any, Callable, IO, Iterator, List, Dict, # noqa: F401 11 | Iterable, Optional, Set, Union) 12 | from weakref import finalize 13 | 14 | from phpbridge import functions, modules, objects 15 | 16 | php_server_path = os.path.join( 17 | os.path.dirname(__file__), 'server.php') 18 | 19 | 20 | class PHPBridge: 21 | def __init__(self, input_: IO[str], output: IO[str], name: str) -> None: 22 | self.input = input_ 23 | self.output = output 24 | self.classes = {} # type: Dict[str, objects.PHPClass] 25 | self.functions = {} # type: Dict[str, Callable] 26 | self.constants = {} # type: Dict[str, Any] 27 | self.cache = ChainMap(self.classes, self.functions, self.constants) 28 | self._remotes = {} # type: Dict[Union[int, str], finalize] 29 | self._collected = set() # type: Set[Union[int, str]] 30 | self._debug = False 31 | self.__name__ = name 32 | 33 | def send(self, command: str, data: Any) -> None: 34 | if self._debug: 35 | print(command, data) 36 | garbage = list(self._collected.copy()) 37 | if self._debug and garbage: 38 | print("Asking to collect {}".format(garbage)) 39 | json.dump({'cmd': command, 40 | 'data': data, 41 | 'garbage': garbage}, 42 | self.input) 43 | self.input.write('\n') 44 | self.input.flush() 45 | 46 | def receive(self) -> Any: 47 | line = self.output.readline() 48 | if self._debug: 49 | print(line, end='') 50 | if not line: 51 | # Empty response, not even a newline 52 | raise RuntimeError("Connection closed") 53 | response = json.loads(line) 54 | for key in response['collected']: 55 | if self._debug: 56 | print("Confirmed {} collected".format(key)) 57 | if key in self._collected: 58 | self._collected.remove(key) 59 | else: 60 | if self._debug: 61 | print("But {} is not pending collection".format(key)) 62 | if response['type'] == 'exception': 63 | try: 64 | exception = self.decode(response['data']['value']) 65 | except Exception: 66 | raise Exception( 67 | "Failed decoding exception with message '{}'".format( 68 | response['data']['message'])) 69 | raise exception 70 | elif response['type'] == 'result': 71 | return response['data'] 72 | else: 73 | raise Exception("Received response with unknown type {}".format( 74 | response['type'])) 75 | 76 | def encode(self, data: Any) -> dict: 77 | if isinstance(data, str): 78 | try: 79 | data.encode() 80 | return {'type': 'string', 'value': data} 81 | except UnicodeEncodeError: 82 | # string contains surrogates 83 | data = data.encode(errors='surrogateescape') 84 | 85 | if isinstance(data, bytes): 86 | return {'type': 'bytes', 87 | 'value': base64.b64encode(data).decode()} 88 | 89 | if isinstance(data, bool): 90 | return {'type': 'boolean', 'value': data} 91 | 92 | if isinstance(data, int): 93 | return {'type': 'integer', 'value': data} 94 | 95 | if isinstance(data, float): 96 | if math.isnan(data): 97 | data = 'NAN' 98 | elif math.isinf(data): 99 | data = 'INF' if data > 0 else '-INF' 100 | return {'type': 'double', 'value': data} 101 | 102 | if data is None: 103 | return {'type': 'NULL', 'value': data} 104 | 105 | if isinstance(data, dict) and all( 106 | isinstance(key, str) or isinstance(key, int) 107 | for key in data): 108 | return {'type': 'array', 'value': {k: self.encode(v) 109 | for k, v in data.items()}} 110 | if isinstance(data, list): 111 | return {'type': 'array', 'value': [self.encode(item) 112 | for item in data]} 113 | 114 | if isinstance(data, objects.PHPObject) and data._bridge is self: 115 | return {'type': 'object', 116 | 'value': {'class': data.__class__._name, # type: ignore 117 | 'hash': data._hash}} 118 | 119 | if isinstance(data, objects.PHPResource) and data._bridge is self: 120 | return {'type': 'resource', 121 | 'value': {'type': data._type, 122 | 'hash': data._id}} 123 | 124 | if isinstance(data, objects.PHPClass) and data._bridge is self: 125 | # PHP uses strings to represent functions and classes 126 | # This unfortunately means they will be strings if they come back 127 | return {'type': 'string', 'value': data._name} 128 | 129 | if (isinstance(data, types.FunctionType) and 130 | getattr(data, '_bridge', None) is self): 131 | return {'type': 'string', 'value': data.__name__} 132 | 133 | if (isinstance(data, types.MethodType) and 134 | getattr(data.__self__, '_bridge', None) is self): 135 | return self.encode([data.__self__, data.__name__]) 136 | 137 | raise RuntimeError("Can't encode {!r}".format(data)) 138 | 139 | def decode(self, data: dict) -> Any: 140 | type_ = data['type'] 141 | value = data['value'] 142 | if type_ in {'string', 'integer', 'NULL', 'boolean'}: 143 | return value 144 | elif type_ == 'double': 145 | if value == 'INF': 146 | return math.inf 147 | elif value == '-INF': 148 | return -math.inf 149 | elif value == 'NAN': 150 | return math.nan 151 | return value 152 | elif type_ == 'array': 153 | if isinstance(value, list): 154 | return Array.list(map(self.decode, value)) 155 | elif isinstance(value, dict): 156 | return Array((key, self.decode(item)) 157 | for key, item in value.items()) 158 | elif type_ == 'object': 159 | cls = self.get_class(value['class']) 160 | return self.get_object(cls, value['hash']) 161 | elif type_ == 'resource': 162 | return self.get_resource(value['type'], value['hash']) 163 | elif type_ == 'bytes': 164 | # PHP's strings are just byte arrays 165 | # Decoding this to a bytes object would be problematic 166 | # It might be meant as a legitimate string, and some binary data 167 | # could be valid unicode by accident 168 | value = base64.b64decode(value) 169 | return value.decode(errors='surrogateescape') 170 | raise RuntimeError("Unknown type {!r}".format(type_)) 171 | 172 | def send_command(self, cmd: str, data: Any = None, 173 | decode: bool = False) -> Any: 174 | self.send(cmd, data) 175 | result = self.receive() 176 | if decode: 177 | result = self.decode(result) 178 | return result 179 | 180 | def resolve(self, path: str, name: str) -> Any: 181 | if path: 182 | name = path + '\\' + name 183 | 184 | if name in self.cache: 185 | return self.cache[name] 186 | else: 187 | kind = self.send_command('resolveName', name) 188 | 189 | if kind == 'class': 190 | return self.get_class(name) 191 | elif kind == 'func': 192 | return self.get_function(name) 193 | elif kind == 'const': 194 | return self.get_const(name) 195 | elif kind == 'global': 196 | return self.get_global(name) 197 | elif kind == 'none': 198 | raise AttributeError("Nothing named '{}' found".format(name)) 199 | else: 200 | raise RuntimeError("Resolved unknown data type {}".format(kind)) 201 | 202 | def get_class(self, name: str) -> objects.PHPClass: 203 | if name not in self.classes: 204 | objects.create_class(self, name) 205 | return self.classes[name] 206 | 207 | def get_function(self, name: str) -> Callable: 208 | if name not in self.functions: 209 | functions.create_function(self, name) 210 | return self.functions[name] 211 | 212 | def get_const(self, name: str) -> Any: 213 | if name not in self.constants: 214 | self.constants[name] = self.send_command( 215 | 'getConst', name, decode=True) 216 | return self.constants[name] 217 | 218 | def get_global(self, name: str) -> Any: 219 | return self.send_command('getGlobal', name, decode=True) 220 | 221 | def get_object(self, cls: objects.PHPClass, 222 | hash_: str) -> objects.PHPObject: 223 | obj = self._lookup(hash_) 224 | if obj is not None: 225 | return obj # type: ignore 226 | new_obj = super(objects.PHPObject, cls).__new__(cls) # type: ignore 227 | object.__setattr__(new_obj, '_hash', hash_) 228 | self._register(hash_, new_obj) 229 | return new_obj # type: ignore 230 | 231 | def get_resource(self, type_: str, id_: int) -> objects.PHPResource: 232 | resource = self._lookup(id_) 233 | if resource is not None: 234 | return resource # type: ignore 235 | new_resource = objects.PHPResource(self, type_, id_) 236 | self._register(id_, new_resource) 237 | return new_resource 238 | 239 | def _register(self, ident: Union[int, str], 240 | entity: Union[objects.PHPResource, 241 | objects.PHPObject]) -> None: 242 | """Register an object or resource with a weakref.""" 243 | if self._debug: 244 | print("Registering {}".format(ident)) 245 | self._remotes[ident] = finalize(entity, self._collect, ident) 246 | 247 | def _lookup(self, ident: Union[int, str]) -> Optional[ 248 | Union[objects.PHPResource, objects.PHPObject]]: 249 | """Look up an existing object for a remote entity.""" 250 | try: 251 | ref = self._remotes[ident] 252 | except KeyError: 253 | return None 254 | contents = ref.peek() 255 | if contents is None: 256 | return None 257 | return contents[0] 258 | 259 | def _collect(self, ident: Union[int, str]) -> None: 260 | """Mark an object or resource identifier as garbage collected.""" 261 | if self._debug: 262 | print("Lost {}".format(ident)) 263 | self._collected.add(ident) 264 | del self._remotes[ident] 265 | 266 | 267 | class Array(OrderedDict): 268 | """An ordered dictionary with some of PHP's idiosyncrasies. 269 | 270 | These can be treated like lists, to some extent. Simple looping yields 271 | values, not keys. To get keys, explicitly use .keys(). Positive integer 272 | keys are automatically converted to strings. 273 | 274 | Creating these arrays yourself or modifying them is a bad idea. This class 275 | only exists to deal with PHP's ambiguities. If not consumed immediately, 276 | it's best to convert it to a list or a dict, depending on the kind of array 277 | you expect. 278 | """ 279 | def __iter__(self) -> Iterator: 280 | yield from self.values() 281 | 282 | def __getitem__(self, index: Union[int, str, slice]) -> Any: 283 | if isinstance(index, slice) or isinstance(index, int) and index < 0: 284 | return list(self.values())[index] 285 | if isinstance(index, int): 286 | index = str(index) 287 | return super().__getitem__(index) 288 | 289 | def __contains__(self, value: Any) -> bool: 290 | return value in self.values() 291 | 292 | def __setitem__(self, index: Union[int, str], value: Any) -> None: 293 | if isinstance(index, int): 294 | index = str(index) 295 | super().__setitem__(index, value) 296 | 297 | def __delitem__(self, index: Union[int, str]) -> None: 298 | if isinstance(index, int): 299 | index = str(index) 300 | super().__delitem__(index) 301 | 302 | def listable(self) -> bool: 303 | """Return whether the array could be created from a list.""" 304 | return all(str(ind) == key for ind, key in enumerate(self.keys())) 305 | 306 | @classmethod 307 | def list(cls, iterable: Iterable) -> 'Array': 308 | """Create by taking values from a list and using indexes as keys.""" 309 | return cls((str(ind), item) # type: ignore 310 | for ind, item in enumerate(iterable)) 311 | 312 | def __repr__(self) -> str: 313 | if self and self.listable(): 314 | return "{}.list({})".format(self.__class__.__name__, 315 | list(self.values())) 316 | return super().__repr__() 317 | 318 | 319 | def start_process_unix(fname: str, name: str) -> PHPBridge: 320 | """Start a server.php bridge using two pipes. 321 | 322 | pass_fds is not supported on Windows. It may be that some other way to 323 | inherit file descriptors exists on Windows. In that case, the Windows 324 | function should be adjusted, or merged with this one. 325 | """ 326 | php_in, py_in = os.pipe() 327 | py_out, php_out = os.pipe() 328 | sp.Popen(['php', fname, 'php://fd/{}'.format(php_in), 329 | 'php://fd/{}'.format(php_out)], 330 | pass_fds=[0, 1, 2, php_in, php_out]) 331 | os.close(php_in) 332 | os.close(php_out) 333 | return PHPBridge(os.fdopen(py_in, 'w'), os.fdopen(py_out, 'r'), name) 334 | 335 | 336 | def start_process_windows(fname: str, name: str) -> PHPBridge: 337 | """Start a server.php bridge over stdin and stderr.""" 338 | proc = sp.Popen(['php', fname, 'php://stdin', 'php://stderr'], 339 | stdin=sp.PIPE, stderr=sp.PIPE, 340 | universal_newlines=True) 341 | return PHPBridge(proc.stdin, proc.stderr, name) 342 | 343 | 344 | def start_process(fname: str = php_server_path, 345 | name: str = 'php') -> PHPBridge: 346 | """Start server.php and open a bridge to it.""" 347 | if sys.platform.startswith('win32'): 348 | return start_process_windows(fname, name) 349 | return start_process_unix(fname, name) 350 | 351 | 352 | modules.NamespaceFinder(start_process, 'php').register() 353 | -------------------------------------------------------------------------------- /phpbridge/classes.py: -------------------------------------------------------------------------------- 1 | """PHP classes with special Python behavior. 2 | 3 | These classes either provide special methods or inherit from Python classes. 4 | They are not subclassed or instantiated directly. Instead, their methods and 5 | bases are copied to the corresponding PHPClasses. This helps keep the class 6 | hierarchy clean, because each class must be bound to a bridge. 7 | """ 8 | 9 | import typing 10 | 11 | from typing import Any, Callable, Dict, Optional, Type, Union # noqa: F401 12 | 13 | from phpbridge.objects import PHPObject 14 | 15 | predef_classes = {} # type: Dict[str, Type] 16 | 17 | magic_aliases = { 18 | '__toString': '__str__', 19 | '__invoke': '__call__' 20 | } # type: Dict[str, str] 21 | 22 | 23 | def predef(_cls: Optional[Type] = None, *, 24 | name: Optional[str] = None) -> Union[Callable[[Type], Type], Type]: 25 | """A decorator to add a class to the dictionary of pre-defined classes.""" 26 | 27 | def decorator(cls: Type) -> Type: 28 | nonlocal name 29 | if name is None: 30 | name = cls.__name__ 31 | predef_classes[name] = cls 32 | return cls 33 | 34 | if _cls is None: 35 | return decorator 36 | 37 | return decorator(_cls) 38 | 39 | 40 | @predef 41 | class Countable(PHPObject): 42 | """Classes that can be used with count() (PHP) or len() (Python). 43 | 44 | See also: collections.abc.Sized. 45 | """ 46 | def __len__(self) -> int: 47 | return self._bridge.send_command( # type: ignore 48 | 'count', self._bridge.encode(self)) 49 | 50 | 51 | @predef 52 | class Iterator(PHPObject): 53 | """Interface for objects that can be iterated. 54 | 55 | See also: collections.abc.Iterator. 56 | """ 57 | def __next__(self) -> Any: 58 | status, key, value = self._bridge.send_command( 59 | 'nextIteration', self._bridge.encode(self), decode=True) 60 | if not status: 61 | raise StopIteration 62 | return key, value 63 | 64 | 65 | @predef 66 | class Traversable(PHPObject): 67 | """Interface to detect if a class is traversable using a for(each) loop. 68 | 69 | See also: collections.abc.Iterable. 70 | """ 71 | def __iter__(self) -> typing.Iterator: 72 | return self._bridge.send_command( # type: ignore 73 | 'startIteration', self._bridge.encode(self), decode=True) 74 | 75 | 76 | @predef 77 | class ArrayAccess(PHPObject): 78 | """Interface to provide accessing objects as arrays. 79 | 80 | Note that the "in" operator only ever checks for valid keys when it comes 81 | to this class. It's less general than the usual possibilities. 82 | """ 83 | def __contains__(self, item: Any) -> bool: 84 | return self._bridge.send_command( # type: ignore 85 | 'hasItem', 86 | {'obj': self._bridge.encode(self), 87 | 'offset': self._bridge.encode(item)}) 88 | 89 | def __getitem__(self, item: Any) -> Any: 90 | return self._bridge.send_command( 91 | 'getItem', 92 | {'obj': self._bridge.encode(self), 93 | 'offset': self._bridge.encode(item)}, 94 | decode=True) 95 | 96 | def __setitem__(self, item: Any, value: Any) -> None: 97 | self._bridge.send_command( 98 | 'setItem', 99 | {'obj': self._bridge.encode(self), 100 | 'offset': self._bridge.encode(item), 101 | 'value': self._bridge.encode(value)}) 102 | 103 | def __delitem__(self, item: Any) -> None: 104 | self._bridge.send_command( 105 | 'delItem', 106 | {'obj': self._bridge.encode(self), 107 | 'offset': self._bridge.encode(item)}) 108 | 109 | 110 | @predef 111 | class Throwable(PHPObject, Exception): 112 | """An exception created in PHP. 113 | 114 | Both a valid Exception and a valid PHPObject, so can be raised and 115 | caught. 116 | """ 117 | def __init__(self, *args: Any, **kwargs: Any) -> None: 118 | super(Exception, self).__init__(self.getMessage()) 119 | 120 | 121 | # Closure is somehow hardwired to be callable, without __invoke 122 | # TODO: It would be nice to generate a signature for this 123 | @predef 124 | class Closure(PHPObject): 125 | def __call__(self, *args: Any) -> Any: 126 | return self._bridge.send_command( 127 | 'callObj', 128 | {'obj': self._bridge.encode(self), 129 | 'args': [self._bridge.encode(arg) for arg in args]}, 130 | decode=True) 131 | 132 | 133 | @predef(name='ArithmeticError') 134 | class PHPArithmeticError(PHPObject, ArithmeticError): 135 | pass 136 | 137 | 138 | @predef(name='AssertionError') 139 | class PHPAssertionError(PHPObject, AssertionError): 140 | pass 141 | 142 | 143 | @predef 144 | class OutOfBoundsException(PHPObject, IndexError): 145 | pass 146 | 147 | 148 | @predef 149 | class OverflowException(PHPObject, OverflowError): 150 | pass 151 | 152 | 153 | @predef 154 | class ParseError(PHPObject, SyntaxError): 155 | pass 156 | 157 | 158 | @predef 159 | class RuntimeException(PHPObject, RuntimeError): 160 | pass 161 | 162 | 163 | @predef(name='TypeError') 164 | class PHPTypeError(PHPObject, TypeError): 165 | pass 166 | 167 | 168 | @predef 169 | class UnexpectedValueException(PHPObject, ValueError): 170 | pass 171 | 172 | 173 | @predef(name=r'blyxxyz\PythonServer\Exceptions\AttributeError') 174 | class PHPAttributeError(PHPObject, AttributeError): 175 | pass 176 | -------------------------------------------------------------------------------- /phpbridge/docblocks.py: -------------------------------------------------------------------------------- 1 | """A module for parsing docblock types into mypy-style types. 2 | 3 | This was originally going to be used to enhance the function signatures, but 4 | it's useful to know the types that PHP will actually check for and the docblock 5 | types are visible in the __doc__ anyway so it's not used at all now. 6 | 7 | It could still be useful for auto-generating mypy stubs, or something. 8 | """ 9 | 10 | from typing import Any, Callable, Dict, Optional, Sequence, Type, Union 11 | 12 | import phpbridge 13 | import re 14 | 15 | 16 | def parse_type(bridge: 'phpbridge.PHPBridge', 17 | spec: str, 18 | cls: Optional[Type] = None, 19 | bases: Optional[Sequence[Type]] = None) -> Any: 20 | """Try to parse a PHPDoc type specification. 21 | 22 | Mostly follows https://docs.phpdoc.org/guides/types.html. 23 | """ 24 | 25 | if not all(char.isalnum() or char in '|[]?\\' 26 | for char in spec): 27 | # The spec is probably too advanced for us to deal with properly 28 | # In that case, return a string 29 | # This excludes some valid class names, but if you're using 30 | # non-alphanumeric unicode in your class names, please stop 31 | return spec 32 | 33 | simple_types = { 34 | 'string': str, 35 | 'int': int, 36 | 'integer': int, 37 | 'float': float, 38 | 'double': float, # This is not official but probably better 39 | 'bool': bool, 40 | 'boolean': bool, 41 | 'array': dict, 42 | 'resource': phpbridge.objects.PHPResource, 43 | 'object': phpbridge.objects.PHPObject, 44 | 'null': None, 45 | 'void': None, 46 | 'callable': Callable, 47 | 'mixed': Any, 48 | 'false': False, 49 | 'true': True, 50 | 'self': cls if cls is not None else 'self', 51 | 'static': 'static', # These are tricky, maybe work them out later 52 | '$this': '$this' 53 | } 54 | 55 | parsed = [] 56 | 57 | for option in spec.split('|'): # type: Any 58 | 59 | is_array = False 60 | if option.endswith('[]'): 61 | option = option[:-2] 62 | is_array = True 63 | 64 | is_optional = False 65 | if option.startswith('?'): 66 | option = option[1:] 67 | is_optional = True 68 | 69 | if any(char in option for char in '[]?'): 70 | # This is too advanced, abort 71 | return spec 72 | 73 | if option in simple_types: 74 | option = simple_types[option] 75 | else: 76 | try: 77 | option = bridge.get_class(option) 78 | except Exception: 79 | # Give up and keep it as a string 80 | pass 81 | 82 | if is_array: 83 | option = Dict[Union[int, str], option] 84 | 85 | if is_optional: 86 | option = Optional[option] 87 | 88 | parsed.append(option) 89 | 90 | if parsed != [None]: 91 | # A Union with one member just becomes that member so this is ok 92 | return Union[tuple(parsed)] 93 | else: 94 | # But Union[None] becomes NoneType and that's ugly 95 | return None 96 | 97 | 98 | def get_signature(bridge: 'phpbridge.PHPBridge', 99 | docblock: str, 100 | cls: Optional[Type] = None, 101 | bases: Optional[Sequence[Type]] = None): 102 | params = {name: parse_type(bridge, spec, cls, bases) 103 | for spec, name in 104 | re.findall(r'\@param\s+([^\s]*)\s+\$([^\s\*]+)', docblock)} 105 | match = re.search(r'\@return\s+([^\s]*)', docblock) 106 | if match is not None: 107 | ret = parse_type(bridge, match.group(1), cls, bases) 108 | else: 109 | # "None" would be a valid return type, so we use this 110 | ret = '' 111 | return params, ret 112 | -------------------------------------------------------------------------------- /phpbridge/functions.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | 3 | from inspect import Parameter, Signature 4 | from typing import Any, Callable, Dict, Iterator, Optional, Set # noqa: F401 5 | 6 | from phpbridge import modules, utils 7 | 8 | MYPY = False 9 | if MYPY: 10 | from phpbridge import PHPBridge # noqa: F401 11 | 12 | 13 | def parse_type_info(bridge: 'PHPBridge', info: Dict[str, Any]) -> Any: 14 | """Turn a type info dict into an annotation.""" 15 | from phpbridge import objects 16 | 17 | if info['isClass']: 18 | try: 19 | annotation = bridge.get_class(info['name']) # type: Any 20 | except Exception: 21 | # This probably means the type annotation is invalid. 22 | # PHP just lets that happen, because the annotation might start 23 | # existing in the future. So we'll allow it too. But we can't get 24 | # the class, so use the name of the class instead. 25 | annotation = (modules.get_module(bridge, info['name']) + '.' + 26 | modules.basename(info['name'])) 27 | else: 28 | annotation = objects.php_types.get(info['name'], info['name']) 29 | if info['nullable']: 30 | annotation = Optional[annotation] 31 | 32 | return annotation 33 | 34 | 35 | def different_name(name: str) -> Iterator[str]: 36 | """Look for new names that don't conflict with existing names.""" 37 | yield name 38 | for n in itertools.count(2): 39 | yield name + str(n) 40 | 41 | 42 | def make_signature(bridge: 'PHPBridge', info: Dict[str, Any], 43 | add_first: Optional[str] = None) -> Signature: 44 | """Create a function signature from an info dict.""" 45 | parameters = [] 46 | used_names = set() # type: Set[str] 47 | 48 | if add_first: 49 | parameters.append(Parameter( 50 | name=add_first, 51 | kind=Parameter.POSITIONAL_OR_KEYWORD, 52 | default=Parameter.empty, 53 | annotation=Parameter.empty)) 54 | used_names.add(add_first) 55 | 56 | # Mysteriously enough, PHP lets you make a function with default arguments 57 | # before arguments without a default. Python doesn't. So if that happens, 58 | # we mark it as an unknown default. 59 | # default_required is set to True as soon as we find the first parameter 60 | # with a default value. 61 | default_required = False 62 | 63 | for param in info['params']: 64 | 65 | for param_name in different_name(param['name']): 66 | if param_name not in used_names: 67 | used_names.add(param_name) 68 | break 69 | 70 | default = (bridge.decode(param['default']) if param['hasDefault'] else 71 | Parameter.empty) 72 | 73 | if (param['isOptional'] and not param['variadic'] and 74 | default is Parameter.empty): 75 | # Some methods have optional parameters without (visible) default 76 | # values. We'll use this to represent those. 77 | default = utils.unknown_param_default 78 | 79 | if (default_required and default is Parameter.empty and 80 | not param['variadic']): 81 | default = utils.unknown_param_default 82 | 83 | if default is not Parameter.empty: 84 | # From now on, we need a default value even if we can't find one. 85 | default_required = True 86 | 87 | if param['type'] is None: 88 | annotation = Parameter.empty 89 | else: 90 | annotation = parse_type_info(bridge, param['type']) 91 | 92 | kind = (Parameter.VAR_POSITIONAL if param['variadic'] else 93 | Parameter.POSITIONAL_OR_KEYWORD) 94 | 95 | parameters.append(Parameter( 96 | name=param_name, 97 | kind=kind, 98 | default=default, 99 | annotation=annotation)) 100 | 101 | return_annotation = (Signature.empty if info['returnType'] is None else 102 | parse_type_info(bridge, info['returnType'])) 103 | 104 | return Signature(parameters=parameters, 105 | return_annotation=return_annotation) 106 | 107 | 108 | default_constructor_signature = Signature( 109 | parameters=[Parameter(name='cls', 110 | kind=Parameter.POSITIONAL_OR_KEYWORD, 111 | default=Parameter.empty, 112 | annotation=Parameter.empty)], 113 | return_annotation=Signature.empty) 114 | 115 | 116 | def create_function(bridge: 'PHPBridge', name: str) -> None: 117 | """Create and register a PHP function.""" 118 | info = bridge.send_command('funcInfo', name) 119 | 120 | if info['name'] in bridge.functions: 121 | bridge.functions[name] = bridge.functions[info['name']] 122 | return 123 | 124 | def func(*args: Any, **kwargs: Any) -> Any: 125 | args = utils.parse_args( 126 | func.__signature__, args, kwargs) # type: ignore 127 | return bridge.send_command( 128 | 'callFun', 129 | {'name': name, 130 | 'args': [bridge.encode(arg) for arg in args]}, 131 | decode=True) 132 | 133 | func.__doc__ = utils.convert_docblock(info['doc']) 134 | func.__module__ = modules.get_module(bridge, name) 135 | func.__name__ = info['name'] 136 | func.__qualname__ = modules.basename(info['name']) 137 | func.__signature__ = make_signature(bridge, info) # type: ignore 138 | func._bridge = bridge # type: ignore 139 | 140 | bridge.functions[name] = func 141 | bridge.functions[info['name']] = func 142 | -------------------------------------------------------------------------------- /phpbridge/modules.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import importlib.abc 3 | import sys 4 | 5 | from importlib.machinery import ModuleSpec 6 | from types import ModuleType 7 | from typing import (Any, Callable, Dict, Generator, # noqa: F401 8 | List, Optional, Union) 9 | 10 | import phpbridge 11 | 12 | MYPY = False 13 | if MYPY: 14 | from phpbridge import PHPBridge # noqa: F401 15 | 16 | bridges = {} # type: Dict[PHPBridge, str] 17 | 18 | 19 | class Namespace(ModuleType): 20 | def __init__(self, name: str, doc: Optional[str] = None, *, 21 | bridge: 'PHPBridge', path: str) -> None: 22 | super().__init__(name, doc) 23 | self._bridge = bridge 24 | self._path = path 25 | self.__path__ = [] # type: List[str] 26 | 27 | def __getattribute__(self, attr: str) -> Any: 28 | val = None 29 | try: 30 | val = super().__getattribute__(attr) 31 | if not isinstance(val, Namespace): 32 | return val 33 | except AttributeError: 34 | pass 35 | try: 36 | path = super().__getattribute__('_path') 37 | return self._bridge.resolve(path, attr) 38 | except AttributeError: 39 | if val is not None: 40 | # We did manage to get val, but it's a Namespace 41 | # It's ok as a fallback 42 | return val 43 | raise 44 | 45 | def __dir__(self) -> Generator: 46 | yield from super().__dir__() 47 | for entry in self._bridge.send_command('listEverything', self._path): 48 | if '\\' not in entry: 49 | yield entry 50 | continue 51 | 52 | @property 53 | def __all__(self) -> List[str]: 54 | """This is unreliable because of autoloading.""" 55 | return list(dir(self)) 56 | 57 | def __repr__(self) -> str: 58 | if self._path: 59 | return "".format(self._path) 60 | return "" 61 | 62 | def __getitem__(self, index: str) -> Any: 63 | try: 64 | path = super().__getattribute__('_path') 65 | return self._bridge.resolve(path, index) 66 | except AttributeError as e: 67 | raise KeyError(*e.args) 68 | 69 | 70 | class NamespaceLoader(importlib.abc.Loader): 71 | def __init__(self, bridge: 'PHPBridge', path: str) -> None: 72 | self.bridge = bridge 73 | self.path = path 74 | 75 | def create_module(self, spec: ModuleSpec) -> Namespace: 76 | return Namespace(spec.name, bridge=self.bridge, path=self.path) 77 | 78 | def exec_module(self, module: ModuleType) -> None: 79 | pass 80 | 81 | 82 | class NamespaceFinder(importlib.abc.MetaPathFinder): 83 | """Import PHP namespaces over a bridge.""" 84 | def __init__(self, bridge: Union['PHPBridge', Callable[[], 'PHPBridge']], 85 | name: str) -> None: 86 | self.bridge = bridge 87 | self.prefix = __package__ + '.' + name 88 | if isinstance(self.bridge, phpbridge.PHPBridge): 89 | bridges[self.bridge] = self.prefix 90 | 91 | def resolve(self, name: str) -> Optional[str]: 92 | """Parse a module name into a namespace identity. 93 | 94 | Args: 95 | - name: The prospective module path. 96 | 97 | Return: 98 | - None if the name doesn't specify a namespace. 99 | - A string representing the namespace otherwise. 100 | """ 101 | if name == self.prefix: 102 | return '' 103 | 104 | if not name.startswith(self.prefix + '.'): 105 | return None 106 | 107 | name = name[len(self.prefix) + 1:] 108 | return name.replace('.', '\\') 109 | 110 | def find_spec(self, fullname: str, path: Any, 111 | target: Optional[ModuleType] = None) -> Optional[ModuleSpec]: 112 | namespace = self.resolve(fullname) 113 | if namespace is None: 114 | return None 115 | if not isinstance(self.bridge, phpbridge.PHPBridge): 116 | # Lazy bridge 117 | self.bridge = self.bridge() 118 | bridges[self.bridge] = self.prefix 119 | loader = NamespaceLoader(self.bridge, namespace) 120 | return ModuleSpec(fullname, loader, is_package=True) 121 | 122 | def register(self) -> None: 123 | """Add self to sys.meta_path.""" 124 | if self not in sys.meta_path: 125 | sys.meta_path.append(self) 126 | 127 | 128 | def get_module(bridge: 'PHPBridge', name: str) -> str: 129 | """Get the full name of the module that contains a name's namespace.""" 130 | prefix = bridges[bridge] 131 | namespace, _, name = name.rpartition('\\') 132 | if namespace == '': 133 | module = prefix 134 | else: 135 | module = prefix + '.' + namespace.replace('\\', '.') 136 | if module not in sys.modules: 137 | importlib.import_module(module) 138 | return module 139 | 140 | 141 | def basename(fullyqualname: str) -> str: 142 | """Remove a name's namespace, so it can be used in a __qualname__. 143 | 144 | It may seem wrong that the fully qualified PHP name is used as __name__, 145 | and the unqualified PHP name is used as __qualname__, but Python's 146 | qualified names shouldn't include the module name. 147 | """ 148 | return fullyqualname.rpartition('\\')[2] 149 | -------------------------------------------------------------------------------- /phpbridge/objects.py: -------------------------------------------------------------------------------- 1 | """Translation of PHP classes and objects to Python.""" 2 | 3 | from itertools import product 4 | from typing import (Any, Callable, Dict, List, Optional, Type, # noqa: F401 5 | Union) 6 | from warnings import warn 7 | 8 | from phpbridge.functions import make_signature, default_constructor_signature 9 | from phpbridge import modules, utils 10 | 11 | MYPY = False 12 | if MYPY: 13 | from phpbridge import PHPBridge # noqa: F401 14 | 15 | 16 | class PHPClass(type): 17 | """The metaclass of all PHP classes and interfaces.""" 18 | _bridge = None # type: PHPBridge 19 | _name = None # type: str 20 | _is_abstract = False 21 | _is_interface = False 22 | _is_trait = False 23 | 24 | def __call__(self, *a: Any, **kw: Any) -> Any: 25 | if self._is_trait: 26 | raise TypeError("Cannot instantiate trait {}".format( 27 | self.__name__)) 28 | elif self._is_interface: 29 | raise TypeError("Cannot instantiate interface {}".format( 30 | self.__name__)) 31 | elif self._is_abstract: 32 | raise TypeError("Cannot instantiate abstract class {}".format( 33 | self.__name__)) 34 | return super().__call__(*a, **kw) 35 | 36 | def __repr__(self) -> str: 37 | if self._is_trait: 38 | return "".format(self.__name__) 39 | elif self._is_interface: 40 | return "".format(self.__name__) 41 | elif self._is_abstract: 42 | return "".format(self.__name__) 43 | return "".format(self.__name__) 44 | 45 | 46 | class PHPObject(metaclass=PHPClass): 47 | """The base class of all instantiatable PHP classes.""" 48 | def __new__(cls, *args: Any) -> Any: 49 | """Create and return a new object.""" 50 | return cls._bridge.send_command( 51 | 'createObject', 52 | {'name': cls._name, 53 | 'args': [cls._bridge.encode(arg) for arg in args]}, 54 | decode=True) 55 | 56 | # In theory, this __new__ only shows up if no constructor has been defined, 57 | # so it doesn't take arguments. In practice we don't want to enforce that, 58 | # but we'll override the ugly signature. 59 | __new__.__signature__ = default_constructor_signature # type: ignore 60 | 61 | def __repr__(self) -> str: 62 | return self._bridge.send_command( # type: ignore 63 | 'repr', self._bridge.encode(self), decode=True) 64 | 65 | def __getattr__(self, attr: str) -> Any: 66 | return self._bridge.send_command( 67 | 'getProperty', 68 | {'obj': self._bridge.encode(self), 69 | 'name': attr}, 70 | decode=True) 71 | 72 | def __setattr__(self, attr: str, value: Any) -> None: 73 | self._bridge.send_command( 74 | 'setProperty', 75 | {'obj': self._bridge.encode(self), 76 | 'name': attr, 77 | 'value': self._bridge.encode(value)}) 78 | 79 | def __delattr__(self, attr: str) -> None: 80 | self._bridge.send_command( 81 | 'unsetProperty', 82 | {'obj': self._bridge.encode(self), 83 | 'name': attr}) 84 | 85 | def __dir__(self) -> List[str]: 86 | return super().__dir__() + self._bridge.send_command( # type: ignore 87 | 'listNonDefaultProperties', self._bridge.encode(self)) 88 | 89 | 90 | def make_method(bridge: 'PHPBridge', classname: str, name: str, 91 | info: dict) -> Callable: 92 | 93 | def method(*args: Any, **kwargs: Any) -> Any: 94 | self, *args = utils.parse_args( 95 | method.__signature__, args, kwargs) # type: ignore 96 | return bridge.send_command( 97 | 'callMethod', 98 | {'obj': bridge.encode(self), 99 | 'name': name, 100 | 'args': [bridge.encode(arg) for arg in args]}, 101 | decode=True) 102 | 103 | method.__module__ = modules.get_module(bridge, classname) 104 | method.__name__ = name 105 | method.__qualname__ = modules.basename(classname) + '.' + name 106 | 107 | if info['doc'] is not False: 108 | method.__doc__ = utils.convert_docblock(info['doc']) 109 | 110 | if info['static']: 111 | # mypy doesn't know classmethods are callable 112 | return classmethod(method) # type: ignore 113 | 114 | return method 115 | 116 | 117 | def create_property(name: str, doc: Optional[str]) -> property: 118 | def getter(self: PHPObject) -> Any: 119 | return self._bridge.send_command( 120 | 'getProperty', 121 | {'obj': self._bridge.encode(self), 'name': name}, 122 | decode=True) 123 | 124 | def setter(self: PHPObject, value: Any) -> None: 125 | self._bridge.send_command( 126 | 'setProperty', 127 | {'obj': self._bridge.encode(self), 128 | 'name': name, 129 | 'value': self._bridge.encode(value)}) 130 | 131 | def deleter(self: PHPObject) -> None: 132 | self._bridge.send_command( 133 | 'unsetProperty', {'obj': self._bridge.encode(self), 'name': name}) 134 | 135 | getter.__doc__ = doc 136 | 137 | return property(getter, setter, deleter) 138 | 139 | 140 | def create_class(bridge: 'PHPBridge', unresolved_classname: str) -> None: 141 | """Create and register a PHPClass. 142 | 143 | Args: 144 | bridge: The bridge the class belongs to. 145 | unresolved_classname: The name of the class. 146 | """ 147 | info = bridge.send_command('classInfo', unresolved_classname) 148 | 149 | classname = info['name'] # type: str 150 | methods = info['methods'] # type: Dict[str, Dict[str, Any]] 151 | interfaces = info['interfaces'] # type: List[str] 152 | traits = info['traits'] # type: List[str] 153 | consts = info['consts'] # type: Dict[str, Any] 154 | properties = info['properties'] # type: Dict[str, Dict[str, Any]] 155 | doc = info['doc'] # type: Optional[str] 156 | parent = info['parent'] # type: str 157 | is_abstract = info['isAbstract'] # type: bool 158 | is_interface = info['isInterface'] # type: bool 159 | is_trait = info['isTrait'] # type: bool 160 | 161 | # PHP turns empty associative arrays into empty lists, so 162 | if not properties: 163 | properties = {} 164 | if not consts: 165 | consts = {} 166 | if not methods: 167 | methods = {} 168 | 169 | # "\Foo" resolves to the same class as "Foo", so we want them to be the 170 | # same Python objects as well 171 | if classname in bridge.classes: 172 | bridge.classes[unresolved_classname] = bridge.classes[classname] 173 | return 174 | 175 | bases = [PHPObject] # type: List[Type] 176 | 177 | if parent is not False: 178 | bases.append(bridge.get_class(parent)) 179 | 180 | for trait in traits: 181 | bases.append(bridge.get_class(trait)) 182 | 183 | for interface in interfaces: 184 | bases.append(bridge.get_class(interface)) 185 | 186 | bindings = {} # type: Dict[str, Any] 187 | 188 | for name, value in consts.items(): 189 | bindings[name] = value 190 | 191 | for name, property_info in properties.items(): 192 | if name in bindings: 193 | warn("'{}' on class '{}' has multiple meanings".format( 194 | name, classname)) 195 | property_doc = utils.convert_docblock(property_info['doc']) 196 | bindings[name] = create_property(name, property_doc) 197 | 198 | created_methods = {} # type: Dict[str, Callable] 199 | 200 | from phpbridge.classes import magic_aliases 201 | for name, method_info in methods.items(): 202 | if name in bindings: 203 | warn("'{}' on class '{}' has multiple meanings".format( 204 | name, classname)) 205 | if method_info['owner'] != classname: 206 | # Make inheritance visible 207 | continue 208 | 209 | method = make_method(bridge, classname, name, method_info) 210 | 211 | if (isinstance(method.__doc__, str) and 212 | '@inheritdoc' in method.__doc__.lower()): 213 | # If @inheritdoc is used, we manually look for inheritance. 214 | # If method.__doc__ is empty we leave it empty, and pydoc and 215 | # inspect know where to look. 216 | for base in bases: 217 | try: 218 | base_doc = getattr(base, name).__doc__ 219 | if isinstance(base_doc, str): 220 | method.__doc__ = base_doc 221 | break 222 | except AttributeError: 223 | pass 224 | 225 | bindings[name] = method 226 | created_methods[name] = method 227 | if name in magic_aliases: 228 | bindings[magic_aliases[name]] = method 229 | 230 | if method_info['isConstructor']: 231 | 232 | def __new__(*args: Any, **kwargs: Any) -> Any: 233 | cls, *args = utils.parse_args( 234 | __new__.__signature__, args, kwargs) # type: ignore 235 | return PHPObject.__new__(cls, *args) 236 | 237 | __new__.__module__ = method.__module__ 238 | __new__.__qualname__ = modules.basename(classname) + '.__new__' 239 | __new__.__doc__ = method.__doc__ 240 | bindings['__new__'] = __new__ 241 | 242 | # Bind the magic methods needed to make these interfaces work 243 | # TODO: figure out something less ugly 244 | from phpbridge.classes import predef_classes 245 | if classname in predef_classes: 246 | predef = predef_classes[classname] 247 | for name, value in predef.__dict__.items(): 248 | if callable(value): 249 | bindings[name] = value 250 | if doc is False: 251 | doc = predef.__doc__ 252 | bases += predef.__bases__ 253 | 254 | # Remove redundant bases 255 | while True: 256 | for (ind_a, a), (ind_b, b) in product(enumerate(bases), 257 | enumerate(bases)): 258 | if ind_a != ind_b and issubclass(a, b): 259 | # Don't use list.remove because maybe a == b 260 | # It's cleaner to keep the first occurrence 261 | del bases[ind_b] 262 | # Restart the loop because we modified bases 263 | break 264 | else: 265 | break 266 | 267 | # do this last to make sure it isn't replaced 268 | bindings['_bridge'] = bridge 269 | bindings['__doc__'] = utils.convert_docblock(doc) 270 | bindings['__module__'] = modules.get_module(bridge, classname) 271 | bindings['_is_abstract'] = is_abstract 272 | bindings['_is_interface'] = is_interface 273 | bindings['_is_trait'] = is_trait 274 | 275 | # PHP does something really nasty when you make an anonymous class. 276 | # Each class needs to have a unique name, so it makes a name that goes 277 | # class@anonymous 278 | # The null byte is probably to trick naively written C code into 279 | # printing only the class@anonymous part. 280 | # Unfortunately, Python doesn't like those class names, so we'll 281 | # insert another character that you'll (hopefully) not find in any 282 | # named class's name because the syntax doesn't allow it. 283 | typename = classname.replace('\0', '$') 284 | bindings['_name'] = classname 285 | 286 | cls = PHPClass(typename, tuple(bases), bindings) 287 | 288 | cls.__qualname__ = modules.basename(cls.__name__) 289 | 290 | bridge.classes[unresolved_classname] = cls 291 | bridge.classes[classname] = cls 292 | 293 | # Only now do we attach signatures, because the signatures may contain the 294 | # class we just registered 295 | for name, func in created_methods.items(): 296 | method_info = methods[name] 297 | if method_info['static']: 298 | # classmethod 299 | func = func.__func__ # type: ignore 300 | signature = make_signature(bridge, method_info, add_first='self') 301 | func.__signature__ = signature # type: ignore 302 | 303 | if method_info['isConstructor']: 304 | cls.__new__.__signature__ = make_signature( # type: ignore 305 | bridge, method_info, add_first='cls') 306 | 307 | 308 | class PHPResource: 309 | """A representation of a remote resource value. 310 | 311 | Not technically an object, but similar in a lot of ways. Resources have a 312 | type (represented by a string) and an identifier used for reference 313 | counting. 314 | """ 315 | def __init__(self, bridge: 'PHPBridge', type_: str, id_: int) -> None: 316 | # Leading underscores are not necessary here but nice for consistency 317 | self._bridge = bridge 318 | self._type = type_ 319 | self._id = id_ 320 | 321 | def __repr__(self) -> str: 322 | """Mimics print_r output for resources, but more informative.""" 323 | return "".format(self._type, self._id) 324 | 325 | 326 | php_types = { 327 | 'int': int, 328 | 'integer': int, 329 | 'bool': bool, 330 | 'boolean': bool, 331 | 'array': dict, 332 | 'float': float, 333 | 'double': float, 334 | 'string': str, 335 | 'void': None, 336 | 'NULL': None, 337 | 'null': None, 338 | 'callable': Callable, 339 | 'true': True, 340 | 'false': False, 341 | 'mixed': Any, 342 | 'object': PHPObject, 343 | 'resource': PHPResource 344 | } 345 | -------------------------------------------------------------------------------- /phpbridge/php-server/CommandServer.php: -------------------------------------------------------------------------------- 1 | objectStore = new ObjectStore(); 23 | } 24 | 25 | /** 26 | * Receive a command from the other side of the bridge. 27 | * 28 | * Waits for a command. If one is received, returns it. If the other side 29 | * is closed, return false. 30 | * 31 | * @psalm-suppress MismatchingDocblockReturnType 32 | * @return array{cmd: string, data: mixed, garbage: array}|false 33 | */ 34 | abstract public function receive(): array; 35 | 36 | /** 37 | * Send a response to the other side of the bridge. 38 | * 39 | * @param array $data 40 | * 41 | * @return void 42 | */ 43 | abstract public function send(array $data); 44 | 45 | /** 46 | * Encode a value into something JSON-serializable. 47 | * 48 | * @param mixed $data 49 | * 50 | * @return array{type: string, value: mixed} 51 | */ 52 | protected function encode($data): array 53 | { 54 | if (is_int($data) || is_null($data) || is_bool($data)) { 55 | return [ 56 | 'type' => gettype($data), 57 | 'value' => $data 58 | ]; 59 | } elseif (is_string($data)) { 60 | if (mb_check_encoding($data)) { 61 | return [ 62 | 'type' => 'string', 63 | 'value' => $data 64 | ]; 65 | } 66 | return [ 67 | 'type' => 'bytes', 68 | 'value' => base64_encode($data) 69 | ]; 70 | } elseif (is_float($data)) { 71 | if (is_nan($data) || is_infinite($data)) { 72 | $data = (string)$data; 73 | } 74 | return [ 75 | 'type' => 'double', 76 | 'value' => $data 77 | ]; 78 | } elseif (is_array($data)) { 79 | return [ 80 | 'type' => 'array', 81 | 'value' => array_map([$this, 'encode'], $data) 82 | ]; 83 | } elseif (is_object($data)) { 84 | return [ 85 | 'type' => 'object', 86 | 'value' => [ 87 | 'class' => get_class($data), 88 | 'hash' => $this->objectStore->encode($data) 89 | ] 90 | ]; 91 | } elseif (is_resource($data)) { 92 | return [ 93 | 'type' => 'resource', 94 | 'value' => [ 95 | 'type' => get_resource_type($data), 96 | 'hash' => $this->objectStore->encode($data) 97 | ] 98 | ]; 99 | } else { 100 | $type = gettype($data); 101 | throw new \Exception("Can't encode value of type '$type'"); 102 | } 103 | } 104 | 105 | /** 106 | * Convert deserialized data into the value it represents, inverts encode. 107 | * 108 | * @param array{type: string, value: mixed} $data 109 | * 110 | * @return mixed 111 | */ 112 | protected function decode(array $data) 113 | { 114 | $type = $data['type']; 115 | $value = $data['value']; 116 | switch ($type) { 117 | case 'integer': 118 | case 'string': 119 | case 'NULL': 120 | case 'boolean': 121 | return $value; 122 | case 'double': 123 | if ($value === 'NAN') { 124 | return NAN; 125 | } elseif ($value === 'INF') { 126 | return INF; 127 | } elseif ($value === '-INF') { 128 | return -INF; 129 | } 130 | return $value; 131 | case 'array': 132 | return $this->decodeArray($value); 133 | case 'object': 134 | case 'resource': 135 | return $this->objectStore->decode($value['hash']); 136 | case 'bytes': 137 | return base64_decode($value); 138 | default: 139 | throw new \Exception("Unknown type '$type'"); 140 | } 141 | } 142 | 143 | /** 144 | * Decode an array of values. 145 | * 146 | * @param array $dataItems 147 | * 148 | * @return array 149 | */ 150 | protected function decodeArray(array $dataItems) 151 | { 152 | return array_map([$this, 'decode'], $dataItems); 153 | } 154 | 155 | /** 156 | * Continually listen for commands. 157 | * 158 | * @return void 159 | */ 160 | public function communicate() 161 | { 162 | while (($command = $this->receive()) !== false) { 163 | $cmd = $command['cmd']; 164 | $data = $command['data']; 165 | $garbage = $command['garbage']; 166 | $collected = []; 167 | try { 168 | foreach ($garbage as $key) { 169 | // It might have been removed before, but ObjectStore 170 | // doesn't mind 171 | $this->objectStore->remove($key); 172 | $collected[] = $key; 173 | } 174 | $response = $this->execute($cmd, $data); 175 | } catch (\Throwable $exception) { 176 | $this->send($this->encodeThrownException( 177 | $exception, 178 | $collected 179 | )); 180 | continue; 181 | } 182 | $this->send([ 183 | 'type' => 'result', 184 | 'data' => $response, 185 | 'collected' => $collected 186 | ]); 187 | } 188 | } 189 | 190 | /** 191 | * Encode an exception into a thrownException response. 192 | * 193 | * @param \Throwable $exception 194 | * @param array $collected 195 | * @return array 196 | */ 197 | protected function encodeThrownException( 198 | \Throwable $exception, 199 | array $collected = [] 200 | ): array { 201 | return [ 202 | 'type' => 'exception', 203 | 'data' => [ 204 | 'value' => $this->encode($exception), 205 | 'message' => $exception->getMessage() 206 | ], 207 | 'collected' => $collected 208 | ]; 209 | } 210 | 211 | /** 212 | * Execute a command and return the (unencoded) result. 213 | * 214 | * @param string $command The name of the command 215 | * @param mixed $data The parameters of the commands 216 | * 217 | * @return mixed 218 | */ 219 | private function execute(string $command, $data) 220 | { 221 | switch ($command) { 222 | case 'getConst': 223 | return $this->encode(Commands::getConst($data)); 224 | case 'setConst': 225 | return Commands::setConst( 226 | $data['name'], 227 | $this->decode($data['value']) 228 | ); 229 | case 'getGlobal': 230 | return $this->encode(Commands::getGlobal($data)); 231 | case 'setGlobal': 232 | return Commands::setGlobal( 233 | $data['name'], 234 | $this->decode($data['value']) 235 | ); 236 | case 'callFun': 237 | return $this->encode(Commands::callFun( 238 | $data['name'], 239 | $this->decodeArray($data['args']) 240 | )); 241 | case 'callObj': 242 | return $this->encode(Commands::callObj( 243 | $this->decode($data['obj']), 244 | $this->decodeArray($data['args']) 245 | )); 246 | case 'callMethod': 247 | return $this->encode(Commands::callMethod( 248 | $this->decode($data['obj']), 249 | $data['name'], 250 | $this->decodeArray($data['args']) 251 | )); 252 | case 'hasItem': 253 | return Commands::hasItem( 254 | $this->decode($data['obj']), 255 | $this->decode($data['offset']) 256 | ); 257 | case 'getItem': 258 | return $this->encode(Commands::getItem( 259 | $this->decode($data['obj']), 260 | $this->decode($data['offset']) 261 | )); 262 | case 'setItem': 263 | return Commands::setItem( 264 | $this->decode($data['obj']), 265 | $this->decode($data['offset']), 266 | $this->decode($data['value']) 267 | ); 268 | case 'delItem': 269 | return Commands::delItem( 270 | $this->decode($data['obj']), 271 | $this->decode($data['offset']) 272 | ); 273 | case 'createObject': 274 | return $this->encode(Commands::createObject( 275 | $data['name'], 276 | $this->decodeArray($data['args']) 277 | )); 278 | case 'getProperty': 279 | return $this->encode(Commands::getProperty( 280 | $this->decode($data['obj']), 281 | $data['name'] 282 | )); 283 | case 'setProperty': 284 | return Commands::setProperty( 285 | $this->decode($data['obj']), 286 | $data['name'], 287 | $this->decode($data['value']) 288 | ); 289 | case 'unsetProperty': 290 | return Commands::unsetProperty( 291 | $this->decode($data['obj']), 292 | $data['name'] 293 | ); 294 | case 'listNonDefaultProperties': 295 | return Commands::listNonDefaultProperties($this->decode($data)); 296 | case 'classInfo': 297 | $classInfo = Commands::classInfo($data); 298 | foreach ($classInfo['methods'] as &$method) { 299 | foreach ($method['params'] as &$param) { 300 | $param['default'] = $this->encode($param['default']); 301 | } 302 | } 303 | return $classInfo; 304 | case 'funcInfo': 305 | $funcInfo = Commands::funcInfo($data); 306 | foreach ($funcInfo['params'] as &$param) { 307 | $param['default'] = $this->encode($param['default']); 308 | } 309 | return $funcInfo; 310 | case 'listConsts': 311 | return Commands::listConsts(); 312 | case 'listGlobals': 313 | return Commands::listGlobals(); 314 | case 'listFuns': 315 | return Commands::listFuns(); 316 | case 'listClasses': 317 | return Commands::listClasses(); 318 | case 'listEverything': 319 | return iterator_to_array(Commands::listEverything($data)); 320 | case 'listNamespaces': 321 | return Commands::listNamespaces($data); 322 | case 'resolveName': 323 | return Commands::resolveName($data); 324 | case 'repr': 325 | return $this->encode(Commands::repr($this->decode($data))); 326 | case 'str': 327 | return $this->encode(Commands::str($this->decode($data))); 328 | case 'count': 329 | return Commands::count($this->decode($data)); 330 | case 'startIteration': 331 | return $this->encode(Commands::startIteration( 332 | $this->decode($data) 333 | )); 334 | case 'nextIteration': 335 | return $this->encode(Commands::nextIteration( 336 | $this->decode($data) 337 | )); 338 | case 'throwException': 339 | Commands::throwException( 340 | $data['class'], 341 | $data['message'] 342 | ); 343 | return null; 344 | default: 345 | throw new \Exception("Unknown command '$command'"); 346 | } 347 | } 348 | } 349 | -------------------------------------------------------------------------------- /phpbridge/php-server/Commands.php: -------------------------------------------------------------------------------- 1 | $value) { 63 | if ($key !== 'GLOBALS') { 64 | $result[$key] = $value; 65 | } 66 | } 67 | return $result; 68 | } 69 | return $GLOBALS[$name]; 70 | } 71 | 72 | /** 73 | * Set a global variable. 74 | * 75 | * @param string $name 76 | * @param mixed $value 77 | * 78 | * @return null 79 | */ 80 | public static function setGlobal(string $name, $value) 81 | { 82 | $GLOBALS[$name] = $value; 83 | return null; 84 | } 85 | 86 | /** 87 | * Call a function. 88 | * 89 | * @param string $name 90 | * @param array $args 91 | * 92 | * @return mixed 93 | */ 94 | public static function callFun(string $name, array $args) 95 | { 96 | if (is_callable($name)) { 97 | return $name(...$args); 98 | } elseif (method_exists(NonFunctionProxy::class, $name)) { 99 | return NonFunctionProxy::$name(...$args); 100 | } 101 | throw new \Exception("Could not resolve function '$name'"); 102 | } 103 | 104 | /** 105 | * Call an object. 106 | * 107 | * @param callable $obj 108 | * @param array $args 109 | * 110 | * @return mixed 111 | */ 112 | public static function callObj($obj, array $args) 113 | { 114 | return $obj(...$args); 115 | } 116 | 117 | /** 118 | * Call an object or class method. 119 | * 120 | * @param object|string $obj 121 | * @param string $name 122 | * @param array $args 123 | * 124 | * @return mixed 125 | */ 126 | public static function callMethod($obj, string $name, array $args) 127 | { 128 | $method = [$obj, $name]; 129 | return $method(...$args); 130 | } 131 | 132 | /** 133 | * Instantiate an object. 134 | * 135 | * @param string $name 136 | * @param array $args 137 | * 138 | * @return object 139 | */ 140 | public static function createObject(string $name, array $args) 141 | { 142 | return new $name(...$args); 143 | } 144 | 145 | /** 146 | * Check whether an offset exists. 147 | * 148 | * @param \ArrayAccess $obj 149 | * @param mixed $offset 150 | * 151 | * @return bool 152 | */ 153 | public static function hasItem($obj, $offset): bool 154 | { 155 | return isset($obj[$offset]); 156 | } 157 | 158 | /** 159 | * Get the value at an offset. 160 | * 161 | * @param \ArrayAccess $obj 162 | * @param mixed $offset 163 | * 164 | * @return mixed 165 | */ 166 | public static function getItem($obj, $offset) 167 | { 168 | return $obj[$offset]; 169 | } 170 | 171 | /** 172 | * Set the value at an offset. 173 | * 174 | * @param \ArrayAccess $obj 175 | * @param mixed $offset 176 | * @param mixed $value 177 | * 178 | * @return null 179 | */ 180 | public static function setItem($obj, $offset, $value) 181 | { 182 | $obj[$offset] = $value; 183 | return null; 184 | } 185 | 186 | /** 187 | * Remove the value at an offset. 188 | * 189 | * @param \ArrayAccess $obj 190 | * @param mixed $offset 191 | * 192 | * @return null 193 | */ 194 | public static function delItem($obj, $offset) 195 | { 196 | unset($obj[$offset]); 197 | return null; 198 | } 199 | 200 | /** 201 | * Get an object property. 202 | * 203 | * @param object $obj 204 | * @param string $name 205 | * 206 | * @return mixed 207 | */ 208 | public static function getProperty($obj, string $name) 209 | { 210 | if (property_exists($obj, $name)) { 211 | return $obj->$name; 212 | } else { 213 | $class = get_class($obj); 214 | throw new AttributeError( 215 | "'$class' object has no property '$name'" 216 | ); 217 | } 218 | } 219 | 220 | /** 221 | * Set an object property to a value. 222 | * 223 | * @param object $obj 224 | * @param string $name 225 | * @param mixed $value 226 | * 227 | * @return null 228 | */ 229 | public static function setProperty($obj, $name, $value) 230 | { 231 | $obj->$name = $value; 232 | return null; 233 | } 234 | 235 | /** 236 | * Unset an object property. 237 | * 238 | * @param object $obj 239 | * @param string $name 240 | * 241 | * @return null 242 | */ 243 | public static function unsetProperty($obj, string $name) 244 | { 245 | if (!property_exists($obj, $name)) { 246 | $class = get_class($obj); 247 | throw new AttributeError( 248 | "'$class' object has no property '$name'" 249 | ); 250 | } 251 | unset($obj->$name); 252 | return null; 253 | } 254 | 255 | /** 256 | * Get an array of all property names. 257 | * 258 | * Doesn't work for all objects. For example, properties set on an 259 | * ArrayObject don't show up. They don't show up either when the object 260 | * is cast to an array, so that's probably related. 261 | * 262 | * @param object $obj 263 | * 264 | * @return array 265 | */ 266 | public static function listNonDefaultProperties($obj) 267 | { 268 | $properties = []; 269 | foreach ((new \ReflectionObject($obj))->getProperties() as $property) { 270 | if ($property->isPublic() && !$property->isDefault()) { 271 | $properties[] = $property->getName(); 272 | } 273 | } 274 | return $properties; 275 | } 276 | 277 | /** 278 | * Get a summary of a class 279 | * 280 | * @param string $class 281 | * 282 | * @return array 283 | */ 284 | public static function classInfo(string $class): array 285 | { 286 | $reflectionClass = new \ReflectionClass($class); 287 | $parent = $reflectionClass->getParentClass(); 288 | if ($parent !== false) { 289 | $parent = $parent->getName(); 290 | } 291 | $info = [ 292 | 'name' => $reflectionClass->getName(), 293 | 'doc' => $reflectionClass->getDocComment(), 294 | 'consts' => [], 295 | 'methods' => [], 296 | 'properties' => [], 297 | 'interfaces' => $reflectionClass->getInterfaceNames(), 298 | 'traits' => $reflectionClass->getTraitNames(), 299 | 'isAbstract' => $reflectionClass->isAbstract(), 300 | 'isInterface' => $reflectionClass->isInterface(), 301 | 'isTrait' => $reflectionClass->isTrait(), 302 | 'parent' => $parent 303 | ]; 304 | 305 | foreach ($reflectionClass->getReflectionConstants() as $constant) { 306 | if ($constant->isPublic()) { 307 | $info['consts'][$constant->getName()] = $constant->getValue(); 308 | } 309 | } 310 | 311 | foreach ($reflectionClass->getMethods() as $method) { 312 | if ($method->isPublic()) { 313 | $info['methods'][$method->getName()] = [ 314 | 'static' => $method->isStatic(), 315 | 'doc' => $method->getDocComment(), 316 | 'params' => array_map( 317 | [static::class, 'paramInfo'], 318 | $method->getParameters() 319 | ), 320 | 'returnType' => static::typeInfo($method->getReturnType()), 321 | 'owner' => $method->getDeclaringClass()->getName(), 322 | 'isConstructor' => $method->isConstructor() 323 | ]; 324 | } 325 | } 326 | 327 | $defaults = $reflectionClass->getDefaultProperties(); 328 | foreach ($reflectionClass->getProperties() as $property) { 329 | if ($property->isPublic()) { 330 | $name = $property->getName(); 331 | $info['properties'][$name] = [ 332 | 'default' => array_key_exists($name, $defaults) 333 | ? $defaults[$name] : null, 334 | 'doc' => $property->getDocComment() 335 | ]; 336 | } 337 | } 338 | 339 | return $info; 340 | } 341 | 342 | /** 343 | * Get detailed information about a function. 344 | * 345 | * @param string $name 346 | * 347 | * @return array 348 | */ 349 | public static function funcInfo(string $name): array 350 | { 351 | if (is_callable($name) && is_string($name)) { 352 | $function = new \ReflectionFunction($name); 353 | } elseif (method_exists(NonFunctionProxy::class, $name)) { 354 | $function = (new \ReflectionClass(NonFunctionProxy::class)) 355 | ->getMethod($name); 356 | } else { 357 | throw new \Exception("Could not resolve function '$name'"); 358 | } 359 | return [ 360 | 'name' => $function->getName(), 361 | 'doc' => $function->getDocComment(), 362 | 'params' => array_map( 363 | [static::class, 'paramInfo'], 364 | $function->getParameters() 365 | ), 366 | 'returnType' => static::typeInfo($function->getReturnType()) 367 | ]; 368 | } 369 | 370 | /** 371 | * Serialize information about a function parameter. 372 | * 373 | * @param \ReflectionParameter $parameter 374 | * 375 | * @return array 376 | */ 377 | private static function paramInfo(\ReflectionParameter $parameter): array 378 | { 379 | $hasDefault = $parameter->isDefaultValueAvailable(); 380 | return [ 381 | 'name' => $parameter->getName(), 382 | 'type' => static::typeInfo($parameter->getType()), 383 | 'hasDefault' => $hasDefault, 384 | 'default' => $hasDefault ? $parameter->getDefaultValue() : null, 385 | 'variadic' => $parameter->isVariadic(), 386 | 'isOptional' => $parameter->isOptional() 387 | ]; 388 | } 389 | 390 | /** 391 | * Serialize information about a parameter or return type. 392 | * 393 | * @param \ReflectionType|null $type 394 | * 395 | * @return array|null 396 | */ 397 | private static function typeInfo(\ReflectionType $type = null) 398 | { 399 | if ($type === null) { 400 | return null; 401 | } 402 | return [ 403 | 'name' => $type->getName(), 404 | 'isClass' => !$type->isBuiltin(), 405 | 'nullable' => $type->allowsNull(), 406 | ]; 407 | } 408 | 409 | /** 410 | * Get an array of all defined constant names. 411 | * 412 | * @return array 413 | */ 414 | public static function listConsts(): array 415 | { 416 | return array_keys(get_defined_constants()); 417 | } 418 | 419 | /** 420 | * Get an array of all global variable names. 421 | * 422 | * @return array 423 | */ 424 | public static function listGlobals(): array 425 | { 426 | return array_keys($GLOBALS); 427 | } 428 | 429 | /** 430 | * Get an array of names of all defined functions. 431 | * 432 | * @return array 433 | */ 434 | public static function listFuns(): array 435 | { 436 | $result = get_class_methods(NonFunctionProxy::class); 437 | foreach (get_defined_functions() as $functions) { 438 | $result = array_merge($result, $functions); 439 | } 440 | return $result; 441 | } 442 | 443 | /** 444 | * Get an array of names of all declared classes. 445 | * 446 | * @return array 447 | */ 448 | public static function listClasses(): array 449 | { 450 | return array_merge(get_declared_classes(), get_declared_interfaces()); 451 | } 452 | 453 | /** 454 | * List all resolvable names. 455 | * 456 | * @param string $namespace 457 | * 458 | * @return \Generator 459 | */ 460 | public static function listEverything(string $namespace = ''): \Generator 461 | { 462 | $prefix = $namespace === '' ? '' : "$namespace\\"; 463 | $names = array_merge( 464 | static::listConsts(), 465 | static::listFuns(), 466 | static::listClasses(), 467 | static::listGlobals() 468 | ); 469 | if ($prefix === '') { 470 | yield from $names; 471 | return; 472 | } 473 | foreach ($names as $name) { 474 | if (substr($name, 0, strlen($prefix)) === $prefix) { 475 | yield substr($name, strlen($prefix)); 476 | } 477 | } 478 | } 479 | 480 | /** 481 | * List all known (sub)namespaces. 482 | * 483 | * @param string $namespace 484 | * 485 | * @return array 486 | */ 487 | public static function listNamespaces(string $namespace = ''): array 488 | { 489 | // We'll use this as a set by only using the keys 490 | $namespaces = []; 491 | foreach (static::listEverything($namespace) as $name) { 492 | if (strpos($name, '\\') !== false) { 493 | $namespaces[explode('\\', $name)[0]] = null; 494 | } 495 | } 496 | return array_keys($namespaces); 497 | } 498 | 499 | /** 500 | * Try to guess what a name represents. 501 | * 502 | * @param string $name 503 | * 504 | * @return string 505 | */ 506 | public static function resolveName(string $name): string 507 | { 508 | if (defined($name)) { 509 | return 'const'; 510 | } elseif (function_exists($name) || 511 | method_exists(NonFunctionProxy::class, $name)) { 512 | return 'func'; 513 | } elseif (class_exists($name) || interface_exists($name) || 514 | trait_exists($name)) { 515 | return 'class'; 516 | } elseif (array_key_exists($name, $GLOBALS)) { 517 | return 'global'; 518 | } else { 519 | return 'none'; 520 | } 521 | } 522 | 523 | /** 524 | * Build a string representation for Python reprs using Representer. 525 | * 526 | * @param mixed $value 527 | * 528 | * @return string 529 | */ 530 | public static function repr($value): string 531 | { 532 | return (new PythonRepresenter)->repr($value); 533 | } 534 | 535 | /** 536 | * Cast a value to a string. 537 | * 538 | * @param mixed $value 539 | * 540 | * @return string 541 | */ 542 | public static function str($value): string 543 | { 544 | return (string)$value; 545 | } 546 | 547 | /** 548 | * Get the count/length of an object. 549 | * 550 | * @param \Countable $value 551 | * 552 | * @return int 553 | */ 554 | public static function count(\Countable $value): int 555 | { 556 | return count($value); 557 | } 558 | 559 | /** 560 | * Start iterating over something. 561 | * 562 | * We deliberately return the Generator object so we can get values out 563 | * of it in further commands. 564 | * 565 | * @param iterable $iterable 566 | * 567 | * @return \Generator 568 | */ 569 | public static function startIteration($iterable): \Generator 570 | { 571 | /** @psalm-suppress RedundantConditionGivenDocblockType */ 572 | if (!(is_array($iterable) || $iterable instanceof \Traversable)) { 573 | if (is_object($iterable)) { 574 | $class = get_class($iterable); 575 | throw new \TypeError("'$class' object is not iterable"); 576 | } 577 | $type = gettype($iterable); 578 | throw new \TypeError("'$type' value is not iterable"); 579 | } 580 | foreach ($iterable as $key => $value) { 581 | yield $key => $value; 582 | } 583 | } 584 | 585 | /** 586 | * Get the next key and value from a generator. 587 | * 588 | * Returns an array containing a bool indicating whether the generator is 589 | * still going, the new key, and the new value. 590 | * 591 | * @param \Generator $generator 592 | * 593 | * @return array 594 | */ 595 | public static function nextIteration(\Generator $generator): array 596 | { 597 | $ret = [$generator->valid(), $generator->key(), $generator->current()]; 598 | $generator->next(); 599 | return $ret; 600 | } 601 | 602 | /** 603 | * Throw an exception. Used for throwing an error while receiving a command. 604 | * 605 | * @param string $class 606 | * @param string $message 607 | * @return void 608 | */ 609 | public static function throwException(string $class, string $message) 610 | { 611 | $obj = new $class($message); 612 | if (!$obj instanceof \Throwable) { 613 | throw new \RuntimeException( 614 | "Can't throw '$class' with message '$message' - " . 615 | "not throwable" 616 | ); 617 | } 618 | throw $obj; 619 | } 620 | } 621 | -------------------------------------------------------------------------------- /phpbridge/php-server/Exceptions/AttributeError.php: -------------------------------------------------------------------------------- 1 | */ 12 | private $objects; 13 | 14 | /** @var array */ 15 | private $resources; 16 | 17 | public function __construct() 18 | { 19 | $this->objects = []; 20 | $this->resources = []; 21 | } 22 | 23 | /** 24 | * @param object|resource $object 25 | * 26 | * @return string|int 27 | */ 28 | public function encode($object) 29 | { 30 | if (is_resource($object)) { 31 | // This uses an implementation detail, but it's the best we have 32 | $key = intval($object); 33 | $this->resources[$key] = $object; 34 | } else { 35 | $key = spl_object_hash($object); 36 | $this->objects[$key] = $object; 37 | } 38 | return $key; 39 | } 40 | 41 | /** 42 | * @param string|int $key 43 | * 44 | * @return object|resource 45 | */ 46 | public function decode($key) 47 | { 48 | if (is_int($key)) { 49 | return $this->resources[$key]; 50 | } else { 51 | return $this->objects[$key]; 52 | } 53 | } 54 | 55 | /** 56 | * @param string|int $key 57 | * 58 | * @return void 59 | */ 60 | public function remove($key) 61 | { 62 | if (is_int($key)) { 63 | unset($this->resources[$key]); 64 | } else { 65 | unset($this->objects[$key]); 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /phpbridge/php-server/Representer/PythonRepresenter.php: -------------------------------------------------------------------------------- 1 | module = $module; 17 | } 18 | 19 | const TRUE = 'True'; 20 | const FALSE = 'False'; 21 | const NULL = 'None'; 22 | 23 | const OBJECT_IDEN = 'PHP object'; 24 | const RESOURCE_IDEN = 'PHP resource'; 25 | 26 | const SEQ_ARRAY_DELIMS = ['[', ']']; 27 | const ASSOC_ARRAY_DELIMS = ['{', '}']; 28 | const KEY_SEP = ': '; 29 | const ITEM_SEP = ', '; 30 | 31 | const NAN = 'nan'; 32 | const INF = 'inf'; 33 | const NEG_INF = '-inf'; 34 | 35 | // Overriding reprString would be good for correctness but a lot of work 36 | 37 | protected function convertClassName(string $name): string 38 | { 39 | $repr = str_replace('\\', '.', $name); 40 | if ($this->module !== '') { 41 | $repr = $this->module . '.' . $repr; 42 | } 43 | return $repr; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /phpbridge/php-server/Representer/Representer.php: -------------------------------------------------------------------------------- 1 | . 17 | */ 18 | class Representer implements RepresenterInterface 19 | { 20 | /** 21 | * Shorthand to get a representation without making an object manually. 22 | * 23 | * @param mixed $thing 24 | * @param int $depth 25 | * @return string 26 | */ 27 | public static function r($thing, int $depth = 2): string 28 | { 29 | return (new static)->repr($thing, $depth); 30 | } 31 | 32 | const TRUE = 'true'; 33 | const FALSE = 'false'; 34 | const NULL = 'null'; 35 | 36 | /** 37 | * Represent any value. 38 | * 39 | * $depth can be used to specify how many levels deep the representation 40 | * may go. 41 | * 42 | * @param mixed $thing 43 | * @param int $depth 44 | * @return string 45 | */ 46 | public function repr($thing, int $depth = 2): string 47 | { 48 | $depth -= 1; 49 | 50 | switch (gettype($thing)) { 51 | case 'resource': 52 | return $this->reprResource($thing, $depth); 53 | case 'array': 54 | return $this->reprArray($thing, $depth); 55 | case 'boolean': 56 | return $thing ? static::TRUE : static::FALSE; 57 | case 'NULL': 58 | return static::NULL; 59 | case 'double': 60 | return $this->reprFloat($thing, $depth); 61 | case 'string': 62 | return $this->reprString($thing, $depth); 63 | case 'object': 64 | return $this->reprObject($thing, $depth); 65 | default: 66 | return $this->reprFallback($thing, $depth); 67 | } 68 | } 69 | 70 | const RESOURCE_IDEN = 'resource'; 71 | 72 | /** 73 | * Represent a resource, including its type. 74 | * 75 | * @param resource $resource 76 | * @param int $depth 77 | * @return string 78 | */ 79 | protected function reprResource($resource, int $depth): string 80 | { 81 | $kind = get_resource_type($resource); 82 | $id = intval($resource); 83 | return "<$kind " . static::RESOURCE_IDEN . " id #$id>"; 84 | } 85 | 86 | const SEQ_ARRAY_DELIMS = ['[', ']']; 87 | const ASSOC_ARRAY_DELIMS = ['[', ']']; 88 | const KEY_SEP = ' => '; 89 | const ITEM_SEP = ', '; 90 | 91 | /** 92 | * Represent an array using modern syntax, up to a certain depth. 93 | * 94 | * @param array $array 95 | * @param int $depth 96 | * @return string 97 | */ 98 | protected function reprArray(array $array, int $depth): string 99 | { 100 | if ($array === []) { 101 | return implode(static::ASSOC_ARRAY_DELIMS); 102 | } 103 | $sequential = self::arrayIsSequential($array); 104 | if ($depth <= 0) { 105 | $count = count($array); 106 | if ($sequential) { 107 | return implode( 108 | "... ($count)", 109 | static::ASSOC_ARRAY_DELIMS 110 | ); 111 | } else { 112 | return implode( 113 | "..." . static::KEY_SEP . "($count)", 114 | static::SEQ_ARRAY_DELIMS 115 | ); 116 | } 117 | } 118 | $content = []; 119 | if (self::arrayIsSequential($array)) { 120 | foreach ($array as $item) { 121 | $content[] = $this->repr($item, $depth); 122 | } 123 | return implode( 124 | implode(static::ITEM_SEP, $content), 125 | static::SEQ_ARRAY_DELIMS 126 | ); 127 | } else { 128 | foreach ($array as $key => $item) { 129 | $content[] = $this->repr($key, $depth) . static::KEY_SEP 130 | . $this->repr($item, $depth); 131 | } 132 | return implode( 133 | implode(static::ITEM_SEP, $content), 134 | static::ASSOC_ARRAY_DELIMS 135 | ); 136 | } 137 | } 138 | 139 | /** 140 | * Determine whether an array could have been created without using 141 | * associative syntax. 142 | * 143 | * @param array $array 144 | * @return bool 145 | */ 146 | protected static function arrayIsSequential(array $array): bool 147 | { 148 | if (count($array) === 0) { 149 | return true; 150 | } 151 | return array_keys($array) === range(0, count($array) - 1); 152 | } 153 | 154 | const NAN = 'NAN'; 155 | const INF = 'INF'; 156 | const NEG_INF = '-INF'; 157 | 158 | protected function reprFloat(float $thing, int $depth): string 159 | { 160 | if (is_nan($thing)) { 161 | return static::NAN; 162 | } elseif (is_infinite($thing) && $thing > 0) { 163 | return static::INF; 164 | } elseif (is_infinite($thing) && $thing < 0) { 165 | return static::NEG_INF; 166 | } 167 | return $this->reprFallback($thing, $depth); 168 | } 169 | 170 | protected function reprString(string $thing, int $depth): string 171 | { 172 | return $this->reprFallback($thing, $depth); 173 | } 174 | 175 | const OBJECT_IDEN = 'object'; 176 | 177 | /** 178 | * Represent an object using its properties. 179 | * 180 | * @param object $object 181 | * @param int $depth 182 | * @return string 183 | */ 184 | protected function reprObject($object, int $depth): string 185 | { 186 | if ($depth <= 0) { 187 | return $this->opaqueReprObject($object); 188 | } 189 | 190 | $cls = $this->convertClassName(get_class($object)); 191 | $properties = (array)$object; 192 | 193 | if (count($properties) === 0) { 194 | return $this->opaqueReprObject($object); 195 | } 196 | 197 | $propertyReprs = []; 198 | foreach ($properties as $key => $value) { 199 | // private properties do something obscene with null bytes 200 | $keypieces = explode("\0", (string)$key); 201 | $key = $keypieces[count($keypieces) - 1]; 202 | $propertyReprs[] = "$key=" . $this->repr($value, $depth); 203 | } 204 | return "<$cls " . static::OBJECT_IDEN . 205 | " (" . implode(', ', $propertyReprs) . ")>"; 206 | } 207 | 208 | /** 209 | * Represent an object using only its class and hash. 210 | * 211 | * @param object $object 212 | * @return string 213 | */ 214 | protected function opaqueReprObject($object): string 215 | { 216 | $cls = $this->convertClassName(get_class($object)); 217 | $hash = spl_object_hash($object); 218 | if (strlen($hash) === 32) { 219 | // Get the only interesting part 220 | $hash = substr($hash, 8, 8); 221 | } 222 | return "<$cls " . static::OBJECT_IDEN . " 0x$hash>"; 223 | } 224 | 225 | /** 226 | * Convert a class name to the preferred notation. 227 | * 228 | * @param string $name 229 | * @return string 230 | */ 231 | protected function convertClassName(string $name): string 232 | { 233 | return $name; 234 | } 235 | 236 | /** 237 | * Represent a value if no other handler is available. 238 | * 239 | * @param mixed $thing 240 | * @param int $depth 241 | * 242 | * @return string 243 | */ 244 | protected function reprFallback($thing, int $depth): string 245 | { 246 | if (is_null($thing) || is_bool($thing)) { 247 | return strtolower(var_export($thing, true)); 248 | } 249 | 250 | if (is_int($thing) || is_float($thing) || is_string($thing)) { 251 | return var_export($thing, true); 252 | } 253 | 254 | // We don't know what this is so we won't risk var_export 255 | $type = gettype($thing); 256 | return "<$type>"; 257 | } 258 | } 259 | -------------------------------------------------------------------------------- /phpbridge/php-server/Representer/RepresenterInterface.php: -------------------------------------------------------------------------------- 1 | in = fopen($in, 'r'); 26 | $this->out = fopen($out, 'w'); 27 | } 28 | 29 | public function receive(): array 30 | { 31 | $line = fgets($this->in); 32 | if ($line === false) { 33 | throw new ConnectionLostException("Can't read from input"); 34 | } 35 | $result = json_decode($line, true); 36 | if (!is_array($result)) { 37 | return [ 38 | 'cmd' => 'throwException', 39 | 'data' => [ 40 | 'class' => \RuntimeException::class, 41 | 'message' => "Error decoding JSON: " . json_last_error_msg() 42 | ] 43 | ]; 44 | } 45 | return $result; 46 | } 47 | 48 | public function send(array $data) 49 | { 50 | $encoded = json_encode($data, JSON_PRESERVE_ZERO_FRACTION); 51 | if ($encoded === false) { 52 | $encoded = json_encode($this->encodeThrownException( 53 | new \RuntimeException(json_last_error_msg()), 54 | $data['collected'] 55 | )); 56 | } 57 | fwrite($this->out, $encoded); 58 | fwrite($this->out, "\n"); 59 | } 60 | 61 | /** 62 | * Promote warnings to exceptions. This is vital if stderr is used for 63 | * communication, because anything else written there will then disrupt 64 | * the connection. 65 | * 66 | * @return mixed 67 | */ 68 | public static function promoteWarnings() 69 | { 70 | return set_error_handler(function (int $errno, string $errstr): bool { 71 | if (error_reporting() !== 0) { 72 | throw new \ErrorException($errstr); 73 | } 74 | return false; 75 | }); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /phpbridge/server.php: -------------------------------------------------------------------------------- 1 | communicate(); 46 | } catch (\blyxxyz\PythonServer\Exceptions\ConnectionLostException $exception) { 47 | } 48 | -------------------------------------------------------------------------------- /phpbridge/utils.py: -------------------------------------------------------------------------------- 1 | """Stand-alone utility functions.""" 2 | 3 | from inspect import Parameter, Signature 4 | from typing import Any, Dict, Optional, Tuple 5 | 6 | 7 | def convert_docblock(doc: Optional[str]) -> Optional[str]: 8 | """Strip the comment syntax out of a docblock.""" 9 | if not isinstance(doc, str): 10 | return doc 11 | doc = doc.strip('/*') 12 | lines = doc.split('\n') 13 | lines = [line.strip() for line in lines] 14 | lines = [line[1:] if line.startswith('*') else line 15 | for line in lines] 16 | return '\n'.join(lines) 17 | 18 | 19 | def parse_args(signature: Signature, orig_args: Tuple, 20 | orig_kwargs: Dict[str, Any]) -> Tuple: 21 | """Use a function signature to interpret keyword arguments. 22 | 23 | If no keyword arguments are provided, args is always returned unchanged. 24 | This ensures that it's possible to call a function even if the signature 25 | is inaccurate. inspect.Signature.bind is similar, but less forgiving. 26 | """ 27 | if not orig_kwargs: 28 | return orig_args 29 | args = list(orig_args) 30 | kwargs = dict(orig_kwargs) 31 | parameters = list(signature.parameters.values()) 32 | while kwargs: 33 | index = len(args) 34 | if index >= len(parameters): 35 | key, value = kwargs.popitem() 36 | raise TypeError("Can't handle keyword argument '{}'".format(key)) 37 | cur_param = parameters[index] 38 | name = cur_param.name 39 | default = cur_param.default 40 | if default is unknown_param_default: 41 | raise TypeError("Missing value for argument '{}' with " 42 | "unknown default".format(name)) 43 | if name in kwargs: 44 | args.append(kwargs.pop(name)) 45 | else: 46 | if default == Parameter.empty: 47 | raise TypeError("Missing required argument '{}'".format(name)) 48 | args.append(default) 49 | return tuple(args) 50 | 51 | 52 | class _UnknownParameterDefaultValue: 53 | """Represent optional parameters without known defaults.""" 54 | def __repr__(self) -> str: 55 | return '?' 56 | 57 | 58 | unknown_param_default = _UnknownParameterDefaultValue() 59 | -------------------------------------------------------------------------------- /psalm.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | with open("README.md", 'r') as f: 4 | long_description = f.read() 5 | 6 | setuptools.setup( 7 | name="phpbridge", 8 | version="0.0.3", 9 | author="Jan Verbeek", 10 | author_email="jan.verbeek@posteo.nl", 11 | description="Import PHP code into Python", 12 | long_description=long_description, 13 | long_description_content_type="text/markdown", 14 | url="https://github.com/blyxxyz/Python-PHP-Bridge", 15 | packages=setuptools.find_packages(), 16 | package_data={ 17 | 'phpbridge': ['*.php', '*/*.php', '*/*/*.php', '*/*/*/*.php'], 18 | }, 19 | license="ISC", 20 | classifiers=[ 21 | "Programming Language :: PHP", 22 | "Programming Language :: Python :: 3", 23 | "Programming Language :: Python :: 3", 24 | "Programming Language :: Python :: 3 :: Only", 25 | "Programming Language :: Python :: 3.5", 26 | "License :: OSI Approved :: ISC License (ISCL)", 27 | ] 28 | ) 29 | --------------------------------------------------------------------------------