php-parser-py 1.2.4__py3-none-win_amd64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- php_parser_py/__init__.py +33 -0
- php_parser_py/_exceptions.py +58 -0
- php_parser_py/_graph/__init__.py +19 -0
- php_parser_py/_graph/_ast.py +227 -0
- php_parser_py/_graph/_edge.py +102 -0
- php_parser_py/_graph/_modifier.py +143 -0
- php_parser_py/_graph/_node.py +256 -0
- php_parser_py/_graph/_node_id.py +56 -0
- php_parser_py/_mapping/__init__.py +14 -0
- php_parser_py/_mapping/_build.py +227 -0
- php_parser_py/_mapping/_contract.py +71 -0
- php_parser_py/_mapping/_serialize.py +220 -0
- php_parser_py/_parser.py +311 -0
- php_parser_py/_printer.py +147 -0
- php_parser_py/_resources/ExtendedPrinter.php +212 -0
- php_parser_py/_resources/parse.php +112 -0
- php_parser_py/_resources/php +0 -0
- php_parser_py/_resources/php-parser-5.7.0.tar.gz +0 -0
- php_parser_py/_resources/php.exe +0 -0
- php_parser_py/_resources/print.php +150 -0
- php_parser_py/_resources/transport.php +195 -0
- php_parser_py/_runtime/__init__.py +11 -0
- php_parser_py/_runtime/_binary.py +113 -0
- php_parser_py/_runtime/_bundle.py +164 -0
- php_parser_py/_runtime/_runner.py +258 -0
- php_parser_py/_transport.py +104 -0
- php_parser_py/_types.py +87 -0
- php_parser_py/py.typed +0 -0
- php_parser_py-1.2.4.dist-info/METADATA +100 -0
- php_parser_py-1.2.4.dist-info/RECORD +32 -0
- php_parser_py-1.2.4.dist-info/WHEEL +5 -0
- php_parser_py-1.2.4.dist-info/licenses/LICENSE +339 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Resolve the platform PHP binary php-parser-py carries.
|
|
2
|
+
|
|
3
|
+
The binary is locked to php-parser's supported PHP version and built with
|
|
4
|
+
the tokenizer, json, ctype, and phar extensions. A platform-tagged wheel
|
|
5
|
+
ships the matching host binary pre-extracted under ``_resources``; a source
|
|
6
|
+
or editable install instead carries the platform tar.gz archives, resolved
|
|
7
|
+
through ``static_php_py.PackagedBinary`` on first use.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from static_php_py import PHP, BinaryNotFoundError, PackagedBinary, Requirements
|
|
18
|
+
|
|
19
|
+
from .._exceptions import RunnerError
|
|
20
|
+
from ._bundle import get_resources_path
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
_PHP_BINARY_NAME = "php.exe" if sys.platform == "win32" else "php"
|
|
25
|
+
# Wheel members are archived with 0o644 permissions, so the pre-extracted
|
|
26
|
+
# binary lands non-executable and needs the bit restored on first resolve.
|
|
27
|
+
_EXECUTABLE_MODE = 0o755
|
|
28
|
+
# Every carried php-cli archive is a PHP 8.5 build (tools/make_wheels.py).
|
|
29
|
+
_PHP_SERIES = "8.5"
|
|
30
|
+
|
|
31
|
+
# php-parser 5.x requires PHP >= 7.4; the phar runs on the tokenizer,
|
|
32
|
+
# json, and ctype extensions and is loaded through the phar extension.
|
|
33
|
+
# Applied via PHP.require and read by `static-php-build --manifest`.
|
|
34
|
+
REQUIREMENTS = Requirements(
|
|
35
|
+
version=">=7.4",
|
|
36
|
+
extensions=frozenset({"tokenizer", "json", "ctype", "phar"}),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# Source and editable installs carry the platform archives as package data;
|
|
40
|
+
# PackagedBinary selects the host's archive and extracts its binary lazily.
|
|
41
|
+
_PACKAGED = PackagedBinary("php_parser_py", series=_PHP_SERIES)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def resolve_php() -> PHP:
|
|
45
|
+
"""Resolve php-parser's own carried PHP binary, falling back to PATH.
|
|
46
|
+
|
|
47
|
+
When the carried binary is unavailable -- an unbuilt platform or a
|
|
48
|
+
source install missing the host archive -- falls back to a ``php`` on
|
|
49
|
+
the system PATH and warns. Both paths run the requirement check, so an
|
|
50
|
+
unsuitable PHP fails fast rather than silently misparsing.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
A validated PHP wrapper.
|
|
54
|
+
|
|
55
|
+
Raises:
|
|
56
|
+
RunnerError: When neither the carried binary nor a system PHP can
|
|
57
|
+
be located.
|
|
58
|
+
VersionUnsatisfiedError: When the located PHP is older than 7.4.
|
|
59
|
+
ExtensionUnsatisfiedError: When the located PHP lacks a required
|
|
60
|
+
extension.
|
|
61
|
+
"""
|
|
62
|
+
try:
|
|
63
|
+
php = PHP.local(_carried_binary())
|
|
64
|
+
except BinaryNotFoundError as carried_error:
|
|
65
|
+
logger.warning(
|
|
66
|
+
"Carried PHP binary unavailable (%s); falling back to system PHP",
|
|
67
|
+
carried_error,
|
|
68
|
+
)
|
|
69
|
+
php = _system_php(carried_error)
|
|
70
|
+
return php.require(REQUIREMENTS)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _carried_binary() -> Path:
|
|
74
|
+
"""Return the binary the package carries as a real filesystem path.
|
|
75
|
+
|
|
76
|
+
A platform wheel ships the binary pre-extracted (and archive-free), so
|
|
77
|
+
it is returned directly with its execute bit restored; a source or
|
|
78
|
+
editable install resolves through the packaged archives instead.
|
|
79
|
+
|
|
80
|
+
Raises:
|
|
81
|
+
BinaryNotFoundError: When neither the pre-extracted binary nor a
|
|
82
|
+
matching archive exists, or the platform is unsupported.
|
|
83
|
+
InvalidArchiveError: When the matching archive holds no PHP member.
|
|
84
|
+
"""
|
|
85
|
+
pre_extracted = get_resources_path() / _PHP_BINARY_NAME
|
|
86
|
+
if pre_extracted.exists():
|
|
87
|
+
if not os.access(pre_extracted, os.X_OK):
|
|
88
|
+
pre_extracted.chmod(_EXECUTABLE_MODE)
|
|
89
|
+
return pre_extracted
|
|
90
|
+
return _PACKAGED.resolve()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _system_php(carried_error: BinaryNotFoundError) -> PHP:
|
|
94
|
+
"""Wrap a ``php`` discovered on the system PATH.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
carried_error: The failure that triggered the fallback, surfaced
|
|
98
|
+
in the error message when no system PHP exists.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
A PHP wrapper around the PATH binary.
|
|
102
|
+
|
|
103
|
+
Raises:
|
|
104
|
+
RunnerError: When no php is on PATH.
|
|
105
|
+
"""
|
|
106
|
+
try:
|
|
107
|
+
return PHP.system()
|
|
108
|
+
except BinaryNotFoundError as path_error:
|
|
109
|
+
raise RunnerError(
|
|
110
|
+
f"No bundled PHP binary for this platform ({carried_error}) "
|
|
111
|
+
"and no php on PATH. Install PHP or pass a custom PHP instance.",
|
|
112
|
+
exit_code=1,
|
|
113
|
+
) from path_error
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Bundled archive location and extraction for php-parser-py.
|
|
2
|
+
|
|
3
|
+
Handles extraction of the bundled PHP-Parser archive on first use.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import tarfile
|
|
10
|
+
import threading
|
|
11
|
+
from importlib import resources
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
# Read granularity for archive hashing; keeps memory flat for large files.
|
|
15
|
+
_HASH_BLOCK_SIZE = 4096
|
|
16
|
+
|
|
17
|
+
# Root package holding the _resources/ data directory and the vendor
|
|
18
|
+
# extraction target; this module lives in a subpackage, so __package__
|
|
19
|
+
# is not the data anchor.
|
|
20
|
+
_DATA_PACKAGE = "php_parser_py"
|
|
21
|
+
|
|
22
|
+
# Global lock for thread-safe extraction
|
|
23
|
+
_extraction_lock = threading.Lock()
|
|
24
|
+
_extraction_state: dict[str, bool] = {"done": False}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _package_dir() -> Path:
|
|
28
|
+
# Anchor package data via importlib.resources, not __file__ arithmetic.
|
|
29
|
+
return Path(str(resources.files(_DATA_PACKAGE)))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_vendor_path() -> Path:
|
|
33
|
+
"""Get the path where PHP-Parser should be extracted."""
|
|
34
|
+
return _package_dir() / "vendor"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_resources_path() -> Path:
|
|
38
|
+
"""Get the path to the resources directory."""
|
|
39
|
+
return _package_dir() / "_resources"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_marker_file() -> Path:
|
|
43
|
+
"""Get the path to the extraction marker file."""
|
|
44
|
+
return get_vendor_path() / ".extracted"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def calculate_archive_hash(archive_path: Path) -> str:
|
|
48
|
+
"""Calculate SHA256 hash of the archive file."""
|
|
49
|
+
sha256_hash = hashlib.sha256()
|
|
50
|
+
with open(archive_path, "rb") as f:
|
|
51
|
+
for byte_block in iter(lambda: f.read(_HASH_BLOCK_SIZE), b""):
|
|
52
|
+
sha256_hash.update(byte_block)
|
|
53
|
+
return sha256_hash.hexdigest()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def is_already_extracted(archive_path: Path) -> bool:
|
|
57
|
+
"""Check if PHP-Parser has already been extracted.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
True if the marker file exists and the archive hash matches.
|
|
61
|
+
"""
|
|
62
|
+
marker_file = get_marker_file()
|
|
63
|
+
if not marker_file.exists():
|
|
64
|
+
return False
|
|
65
|
+
|
|
66
|
+
# Check if hash matches
|
|
67
|
+
try:
|
|
68
|
+
stored_hash = marker_file.read_text(encoding="utf-8").strip()
|
|
69
|
+
current_hash = calculate_archive_hash(archive_path)
|
|
70
|
+
return stored_hash == current_hash
|
|
71
|
+
except OSError:
|
|
72
|
+
# If we can't read the marker or calculate hash, re-extract
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def extract_php_parser(archive_path: Path, vendor_path: Path) -> None:
|
|
77
|
+
"""Extract PHP-Parser archive to vendor directory.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
archive_path: Path to the php-parser tar.gz archive.
|
|
81
|
+
vendor_path: Path where files should be extracted.
|
|
82
|
+
"""
|
|
83
|
+
# Create vendor directory if it doesn't exist
|
|
84
|
+
vendor_path.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
|
|
86
|
+
# Extract the archive, guarding against path traversal
|
|
87
|
+
with tarfile.open(archive_path, "r:gz") as tar_ref:
|
|
88
|
+
_extract_members_safely(tar_ref, vendor_path)
|
|
89
|
+
|
|
90
|
+
# Create marker file with archive hash
|
|
91
|
+
archive_hash = calculate_archive_hash(archive_path)
|
|
92
|
+
marker_file = get_marker_file()
|
|
93
|
+
marker_file.write_text(archive_hash, encoding="utf-8")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _extract_members_safely(tar_ref: tarfile.TarFile, vendor_path: Path) -> None:
|
|
97
|
+
"""Extract tar members, rejecting links and escaping paths."""
|
|
98
|
+
target = vendor_path.resolve()
|
|
99
|
+
for member in tar_ref.getmembers():
|
|
100
|
+
# A link member could point outside the target and let a later
|
|
101
|
+
# member write through it; the archive carries only regular files.
|
|
102
|
+
if not (member.isfile() or member.isdir()):
|
|
103
|
+
raise ValueError(
|
|
104
|
+
f"Archive member {member.name!r} is not a regular file or directory"
|
|
105
|
+
)
|
|
106
|
+
destination = (target / member.name).resolve()
|
|
107
|
+
if not destination.is_relative_to(target):
|
|
108
|
+
raise ValueError(
|
|
109
|
+
f"Archive member {member.name!r} escapes the target directory"
|
|
110
|
+
)
|
|
111
|
+
tar_ref.extractall(vendor_path)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _find_archive() -> Path:
|
|
115
|
+
# Locate the single bundled php-parser archive.
|
|
116
|
+
resources_path = get_resources_path()
|
|
117
|
+
archive_files = list(resources_path.glob("php-parser-*.tar.gz"))
|
|
118
|
+
if not archive_files:
|
|
119
|
+
raise FileNotFoundError(
|
|
120
|
+
f"PHP-Parser archive not found in {resources_path}. "
|
|
121
|
+
f"Expected a file matching pattern 'php-parser-*.tar.gz'"
|
|
122
|
+
)
|
|
123
|
+
if len(archive_files) > 1:
|
|
124
|
+
names = ", ".join(sorted(f.name for f in archive_files))
|
|
125
|
+
raise RuntimeError(
|
|
126
|
+
f"Multiple PHP-Parser archives found in {resources_path}: "
|
|
127
|
+
f"{names}. The installation is inconsistent; reinstall the "
|
|
128
|
+
"package."
|
|
129
|
+
)
|
|
130
|
+
return archive_files[0]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def ensure_php_parser_extracted() -> Path:
|
|
134
|
+
"""Ensure PHP-Parser is extracted and ready to use.
|
|
135
|
+
|
|
136
|
+
Called before any PHP invocation. Thread-safe; extraction runs at
|
|
137
|
+
most once per process.
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
Path to the vendor directory containing PHP-Parser.
|
|
141
|
+
|
|
142
|
+
Raises:
|
|
143
|
+
FileNotFoundError: If the php-parser archive is not found.
|
|
144
|
+
RuntimeError: If extraction fails or more than one php-parser
|
|
145
|
+
archive is bundled.
|
|
146
|
+
"""
|
|
147
|
+
if _extraction_state["done"]:
|
|
148
|
+
return get_vendor_path()
|
|
149
|
+
|
|
150
|
+
with _extraction_lock:
|
|
151
|
+
if _extraction_state["done"]:
|
|
152
|
+
return get_vendor_path()
|
|
153
|
+
|
|
154
|
+
archive_path = _find_archive()
|
|
155
|
+
if not is_already_extracted(archive_path):
|
|
156
|
+
try:
|
|
157
|
+
extract_php_parser(archive_path, get_vendor_path())
|
|
158
|
+
except (OSError, tarfile.TarError, ValueError) as e:
|
|
159
|
+
raise RuntimeError(
|
|
160
|
+
f"Failed to extract PHP-Parser from {archive_path}: {e}"
|
|
161
|
+
) from e
|
|
162
|
+
|
|
163
|
+
_extraction_state["done"] = True
|
|
164
|
+
return get_vendor_path()
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""Runner class for PHP-Parser invocation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import json
|
|
7
|
+
from collections.abc import Callable, Mapping
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, cast
|
|
10
|
+
|
|
11
|
+
from static_php_py import PHP, ExecutionError, ExecutionResult
|
|
12
|
+
|
|
13
|
+
from .._exceptions import ParseError, RunnerError
|
|
14
|
+
from .._transport import decode_values
|
|
15
|
+
from .._types import PrintMode, PrintRequest
|
|
16
|
+
from ._binary import resolve_php
|
|
17
|
+
from ._bundle import ensure_php_parser_extracted, get_resources_path
|
|
18
|
+
|
|
19
|
+
# Stdout carries the JSON/base64 protocol; PHP warnings and fatals must
|
|
20
|
+
# never interleave with it, so diagnostics go to stderr on every run.
|
|
21
|
+
_BASE_INI = {"display_errors": "stderr"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _failure_message(result: ExecutionResult) -> str:
|
|
25
|
+
# Assemble the RunnerError message carrying the process's full output.
|
|
26
|
+
message = f"PHP execution failed with exit code {result.return_code}"
|
|
27
|
+
if result.stderr:
|
|
28
|
+
message += f"\nStderr: {result.stderr}"
|
|
29
|
+
if result.stdout:
|
|
30
|
+
message += f"\nStdout: {result.stdout}"
|
|
31
|
+
return message
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Runner:
|
|
35
|
+
"""Manages PHP-Parser invocation via PHP binary.
|
|
36
|
+
|
|
37
|
+
Handles execution of PHP scripts for parsing and code generation,
|
|
38
|
+
communicating with PHP-Parser through the PHP wrapper's stdin/stdout
|
|
39
|
+
channel.
|
|
40
|
+
|
|
41
|
+
Attributes:
|
|
42
|
+
_php: PHP binary wrapper executing every invocation.
|
|
43
|
+
_vendor_dir: Path to directory containing PHP-Parser PHAR.
|
|
44
|
+
_ini_settings: PHP ini overrides applied as ``-d name=value`` on
|
|
45
|
+
every invocation; always routes diagnostics to stderr.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
php: PHP | None = None,
|
|
51
|
+
ini_settings: Mapping[str, str] | None = None,
|
|
52
|
+
) -> None:
|
|
53
|
+
"""Initialize Runner with PHP binary wrapper.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
php: Optional PHP instance. If not provided, uses the bundled
|
|
57
|
+
binary, falling back to a system PHP on PATH.
|
|
58
|
+
ini_settings: Optional PHP ini overrides (e.g. ``memory_limit``)
|
|
59
|
+
passed as ``-d name=value`` on every invocation. Default
|
|
60
|
+
None inherits the binary's environment.
|
|
61
|
+
|
|
62
|
+
Raises:
|
|
63
|
+
RunnerError: If no usable PHP binary can be located.
|
|
64
|
+
"""
|
|
65
|
+
self._vendor_dir = ensure_php_parser_extracted()
|
|
66
|
+
self._php = php if php is not None else resolve_php()
|
|
67
|
+
self._ini_settings: Mapping[str, str] = {
|
|
68
|
+
**_BASE_INI,
|
|
69
|
+
**(ini_settings or {}),
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
def execute(self, script: str, stdin: str = "") -> str:
|
|
73
|
+
"""Execute PHP script with optional stdin.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
script: PHP script code to execute.
|
|
77
|
+
stdin: Optional input to pass to script's stdin.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
Script's stdout output.
|
|
81
|
+
|
|
82
|
+
Raises:
|
|
83
|
+
RunnerError: If PHP execution fails.
|
|
84
|
+
"""
|
|
85
|
+
return self._run_php(
|
|
86
|
+
lambda: self._php.eval(script, input=stdin, ini=self._ini_settings)
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def _run_php(self, invoke: Callable[[], ExecutionResult]) -> str:
|
|
90
|
+
"""Run one PHP invocation and return its stdout.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
invoke: Zero-argument call into the PHP wrapper (``run`` or
|
|
94
|
+
``eval`` with its arguments bound).
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
Process stdout.
|
|
98
|
+
|
|
99
|
+
Raises:
|
|
100
|
+
RunnerError: If PHP execution fails.
|
|
101
|
+
"""
|
|
102
|
+
try:
|
|
103
|
+
result = invoke()
|
|
104
|
+
except (ExecutionError, FileNotFoundError) as e:
|
|
105
|
+
raise RunnerError(
|
|
106
|
+
f"Failed to execute PHP script: {e}", stderr=str(e), exit_code=1
|
|
107
|
+
) from e
|
|
108
|
+
|
|
109
|
+
if not result.success:
|
|
110
|
+
raise RunnerError(
|
|
111
|
+
_failure_message(result),
|
|
112
|
+
stderr=result.stderr,
|
|
113
|
+
exit_code=result.return_code,
|
|
114
|
+
)
|
|
115
|
+
return result.stdout
|
|
116
|
+
|
|
117
|
+
def parse(self, code: str) -> list[dict[str, Any]]:
|
|
118
|
+
"""Invoke PHP-Parser parse + JsonSerializer.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
code: PHP source code to parse.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
Parsed statements as a list of PHP-Parser JSON node dicts.
|
|
125
|
+
|
|
126
|
+
Raises:
|
|
127
|
+
ParseError: If PHP-Parser reports syntax error.
|
|
128
|
+
RunnerError: If PHP execution fails or emits a malformed payload.
|
|
129
|
+
"""
|
|
130
|
+
stdin = self._build_parse_stdin(code)
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
output = self._execute_script_file("parse.php", stdin)
|
|
134
|
+
# Restore raw bytes and non-finite floats parse.php marker-wrapped.
|
|
135
|
+
result = decode_values(json.loads(output))
|
|
136
|
+
except json.JSONDecodeError as e:
|
|
137
|
+
raise RunnerError(
|
|
138
|
+
f"Failed to decode PHP-Parser JSON output: {e}",
|
|
139
|
+
stderr=str(e),
|
|
140
|
+
exit_code=1,
|
|
141
|
+
) from e
|
|
142
|
+
except RecursionError as e:
|
|
143
|
+
raise RunnerError(
|
|
144
|
+
"AST is nested too deeply to decode: Python's recursion limit "
|
|
145
|
+
"was reached while reading the PHP-Parser JSON output",
|
|
146
|
+
stderr=str(e),
|
|
147
|
+
exit_code=1,
|
|
148
|
+
) from e
|
|
149
|
+
|
|
150
|
+
return self._normalize_payload(result)
|
|
151
|
+
|
|
152
|
+
@staticmethod
|
|
153
|
+
def _normalize_payload(result: object) -> list[dict[str, Any]]:
|
|
154
|
+
"""Validate the decoded parse payload at the channel boundary.
|
|
155
|
+
|
|
156
|
+
An errors envelope is only ever emitted on syntax failure; one with
|
|
157
|
+
no entries is a malformed tool result, not an AST payload. A lone
|
|
158
|
+
node dict is wrapped so callers always receive a statement list.
|
|
159
|
+
|
|
160
|
+
Raises:
|
|
161
|
+
ParseError: Built from the first entry of the errors payload.
|
|
162
|
+
RunnerError: On an empty envelope or an unexpected payload shape
|
|
163
|
+
(fail-fast; no silent ``[]``).
|
|
164
|
+
"""
|
|
165
|
+
if isinstance(result, dict) and "errors" in result:
|
|
166
|
+
errors = result["errors"]
|
|
167
|
+
if not errors:
|
|
168
|
+
raise RunnerError(
|
|
169
|
+
"PHP-Parser returned an error envelope with no error details",
|
|
170
|
+
exit_code=1,
|
|
171
|
+
)
|
|
172
|
+
raise ParseError(
|
|
173
|
+
errors[0].get("message", "Unknown parse error"),
|
|
174
|
+
errors[0].get("line"),
|
|
175
|
+
)
|
|
176
|
+
if isinstance(result, list):
|
|
177
|
+
return result
|
|
178
|
+
if isinstance(result, dict):
|
|
179
|
+
return [result]
|
|
180
|
+
raise RunnerError(
|
|
181
|
+
"PHP-Parser produced a malformed payload: expected list or dict, "
|
|
182
|
+
f"got {type(result).__name__}"
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
def print(self, request: PrintRequest) -> str:
|
|
186
|
+
"""Render and run the print script for one file.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
request: Print inputs (printer class, mode, statements, origin).
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
Generated PHP source code.
|
|
193
|
+
|
|
194
|
+
Raises:
|
|
195
|
+
RunnerError: If PHP execution fails.
|
|
196
|
+
"""
|
|
197
|
+
stdin = self._build_print_stdin(request)
|
|
198
|
+
output = self._execute_script_file("print.php", stdin)
|
|
199
|
+
# print.php base64-encodes its output so non-UTF-8 source bytes survive
|
|
200
|
+
# the text stdout channel; recover the exact bytes back to a str.
|
|
201
|
+
return base64.b64decode(output).decode("utf-8", "surrogateescape")
|
|
202
|
+
|
|
203
|
+
def _build_parse_stdin(self, code: str) -> str:
|
|
204
|
+
"""Pack the parse inputs into a single JSON stdin payload.
|
|
205
|
+
|
|
206
|
+
The code field travels as base64: PHP source may legally hold raw
|
|
207
|
+
non-UTF-8 bytes (carried here as surrogate escapes), and JSON can
|
|
208
|
+
transport neither those bytes nor lone-surrogate escapes.
|
|
209
|
+
"""
|
|
210
|
+
raw = code.encode("utf-8", "surrogateescape")
|
|
211
|
+
payload = {
|
|
212
|
+
"phar_path": str(self._phar_path()),
|
|
213
|
+
"code": base64.b64encode(raw).decode("ascii"),
|
|
214
|
+
}
|
|
215
|
+
return json.dumps(payload)
|
|
216
|
+
|
|
217
|
+
def _build_print_stdin(self, request: PrintRequest) -> str:
|
|
218
|
+
"""Pack the print inputs into a single JSON stdin payload.
|
|
219
|
+
|
|
220
|
+
The printer class name travels as the ``printer_class`` field; it is
|
|
221
|
+
read as data by ``print.php`` and never interpolated into PHP source.
|
|
222
|
+
"""
|
|
223
|
+
payload: dict[str, str] = {
|
|
224
|
+
"phar_path": str(self._phar_path()),
|
|
225
|
+
"extended_path": str(get_resources_path() / "ExtendedPrinter.php"),
|
|
226
|
+
"printer_class": request.php_class,
|
|
227
|
+
"mode": request.mode.value,
|
|
228
|
+
"stmts": request.stmts_json,
|
|
229
|
+
}
|
|
230
|
+
if request.mode is PrintMode.FORMAT_PRESERVING:
|
|
231
|
+
# Original source may hold non-UTF-8 bytes (e.g. Latin-1 literals).
|
|
232
|
+
# JSON is UTF-8 only, so transport the raw bytes as base64 rather
|
|
233
|
+
# than letting an invalid sequence break PHP's json_decode.
|
|
234
|
+
raw = cast(str, request.orig_source).encode("utf-8", "surrogateescape")
|
|
235
|
+
payload["orig_source"] = base64.b64encode(raw).decode("ascii")
|
|
236
|
+
return json.dumps(payload)
|
|
237
|
+
|
|
238
|
+
def _phar_path(self) -> Path:
|
|
239
|
+
"""Absolute path to the bundled php-parser PHAR."""
|
|
240
|
+
return self._vendor_dir / "php-parser.phar"
|
|
241
|
+
|
|
242
|
+
def _execute_script_file(self, script_name: str, stdin: str) -> str:
|
|
243
|
+
"""Run a static ``.php`` resource file with a JSON stdin payload.
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
script_name: File name under ``_resources`` (e.g. ``parse.php``).
|
|
247
|
+
stdin: Single JSON payload piped to the script.
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
Script stdout.
|
|
251
|
+
|
|
252
|
+
Raises:
|
|
253
|
+
RunnerError: If PHP execution fails.
|
|
254
|
+
"""
|
|
255
|
+
script_path = get_resources_path() / script_name
|
|
256
|
+
return self._run_php(
|
|
257
|
+
lambda: self._php.run(script_path, input=stdin, ini=self._ini_settings)
|
|
258
|
+
)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Byte-safe value codec for the PHP JSON channel.
|
|
2
|
+
|
|
3
|
+
JSON is UTF-8 only and rejects non-finite floats, but PHP source and
|
|
4
|
+
string values are raw bytes (carried on the Python side as
|
|
5
|
+
surrogate-escaped ``str``) and PHP float values may be INF or NAN. A
|
|
6
|
+
value that cannot travel as strict JSON is wrapped in a NUL-prefixed
|
|
7
|
+
marker string; ``_resources/transport.php`` applies the same codec on
|
|
8
|
+
the PHP side.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import base64
|
|
14
|
+
import math
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
|
|
17
|
+
# NUL-prefixed so no printable value collides by accident; a legitimate
|
|
18
|
+
# string that does start with a marker prefix is escaped by wrapping it
|
|
19
|
+
# in the string marker too.
|
|
20
|
+
_STR_MARKER = "\x00pppy\x00"
|
|
21
|
+
_FLOAT_MARKER = "\x00pppyf\x00"
|
|
22
|
+
_MARKER_PREFIX = "\x00pppy" # common prefix of both markers
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def encode_values(value: object) -> object:
|
|
26
|
+
"""Wrap channel-unsafe values for the Python -> PHP channel.
|
|
27
|
+
|
|
28
|
+
Returns a new tree; the input is never mutated.
|
|
29
|
+
"""
|
|
30
|
+
return _transform_tree(value, _encode_value)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def decode_values(value: object) -> object:
|
|
34
|
+
"""Restore marker-wrapped values from the PHP -> Python channel.
|
|
35
|
+
|
|
36
|
+
Returns a new tree; the input is never mutated.
|
|
37
|
+
"""
|
|
38
|
+
return _transform_tree(value, _decode_value)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _transform_tree(value: object, transform: Callable[[object], object]) -> object:
|
|
42
|
+
# Iterative walk: a recursive one would halve the nesting depth the
|
|
43
|
+
# channel can carry, failing ASTs json.loads itself handles fine.
|
|
44
|
+
# A new tree is built because the input may hold live graph-owned
|
|
45
|
+
# containers (serialize passes node property values by reference).
|
|
46
|
+
stack: list[tuple[object, object]] = []
|
|
47
|
+
result = _transform_item(value, transform, stack)
|
|
48
|
+
while stack:
|
|
49
|
+
source, target = stack.pop()
|
|
50
|
+
if isinstance(source, list) and isinstance(target, list):
|
|
51
|
+
target.extend(_transform_item(item, transform, stack) for item in source)
|
|
52
|
+
elif isinstance(source, dict) and isinstance(target, dict):
|
|
53
|
+
for key, item in source.items():
|
|
54
|
+
target[key] = _transform_item(item, transform, stack)
|
|
55
|
+
else:
|
|
56
|
+
raise TypeError(f"Mismatched walk pair: {type(source)}, {type(target)}")
|
|
57
|
+
return result
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _transform_item(
|
|
61
|
+
item: object,
|
|
62
|
+
transform: Callable[[object], object],
|
|
63
|
+
stack: list[tuple[object, object]],
|
|
64
|
+
) -> object:
|
|
65
|
+
# Transform leaf values; defer containers onto the walk stack, paired
|
|
66
|
+
# with the empty shell that receives their transformed items.
|
|
67
|
+
if isinstance(item, (list, dict)):
|
|
68
|
+
shell: list[object] | dict[str, object] = [] if isinstance(item, list) else {}
|
|
69
|
+
stack.append((item, shell))
|
|
70
|
+
return shell
|
|
71
|
+
return transform(item)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _encode_value(value: object) -> object:
|
|
75
|
+
if isinstance(value, str):
|
|
76
|
+
return _encode_str(value)
|
|
77
|
+
# bool is an int subtype, not a float, so it never matches here.
|
|
78
|
+
if isinstance(value, float) and not math.isfinite(value):
|
|
79
|
+
return _FLOAT_MARKER + repr(value)
|
|
80
|
+
return value
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _decode_value(value: object) -> object:
|
|
84
|
+
if not isinstance(value, str) or not value.startswith(_MARKER_PREFIX):
|
|
85
|
+
return value
|
|
86
|
+
if value.startswith(_STR_MARKER):
|
|
87
|
+
raw = base64.b64decode(value[len(_STR_MARKER) :])
|
|
88
|
+
return raw.decode("utf-8", "surrogateescape")
|
|
89
|
+
if value.startswith(_FLOAT_MARKER):
|
|
90
|
+
return float(value[len(_FLOAT_MARKER) :])
|
|
91
|
+
return value
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _encode_str(value: str) -> str:
|
|
95
|
+
# Surrogate escapes would serialize as invalid lone-surrogate JSON;
|
|
96
|
+
# a marker-prefixed string would be mis-decoded on the other side.
|
|
97
|
+
if not value.startswith(_MARKER_PREFIX):
|
|
98
|
+
try:
|
|
99
|
+
value.encode("utf-8")
|
|
100
|
+
return value
|
|
101
|
+
except UnicodeEncodeError:
|
|
102
|
+
pass
|
|
103
|
+
raw = value.encode("utf-8", "surrogateescape")
|
|
104
|
+
return _STR_MARKER + base64.b64encode(raw).decode("ascii")
|