php-parser-py 1.2.6__py3-none-win_amd64.whl → 2.0.3__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 CHANGED
@@ -6,13 +6,16 @@ and manipulation of PHP code.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
- __version__ = "1.2.2"
9
+ from importlib.metadata import version as _distribution_version
10
+
11
+ __version__ = _distribution_version("php-parser-py")
10
12
  __all__ = [
11
13
  "AST",
12
14
  "Edge",
13
15
  "Modifier",
14
16
  "Node",
15
17
  "Parser",
18
+ "PHPVersion",
16
19
  "Printer",
17
20
  "StandardPrinter",
18
21
  "ExtendedPrinter",
@@ -31,3 +34,4 @@ from ._graph import AST, Edge, Modifier, Node
31
34
  from ._parser import Parser, parse_code, parse_file, parse_project
32
35
  from ._printer import ExtendedPrinter, Printer, StandardPrinter
33
36
  from ._types import PrintMode, PrintRequest
37
+ from ._version import PHPVersion
@@ -8,6 +8,7 @@ from cpg2py import AbcGraphQuerier, Storage
8
8
 
9
9
  from .._exceptions import NodeNotInFileError
10
10
  from .._types import PARENT_OF_EDGE, PROJECT_ROOT_ID
11
+ from .._version import PHPVersion
11
12
  from ._edge import Edge
12
13
  from ._node import Node
13
14
  from ._node_id import NodeId
@@ -31,17 +32,27 @@ class AST(AbcGraphQuerier[Node, Edge]):
31
32
 
32
33
  Attributes:
33
34
  _root_node_id: ID of the root project node (always "project").
35
+ _target_php: Target PHP version the parser parsed the source under;
36
+ None when no parser produced this graph.
34
37
  """
35
38
 
36
- def __init__(self, storage: Storage, root_node_id: str = PROJECT_ROOT_ID) -> None:
39
+ def __init__(
40
+ self,
41
+ storage: Storage,
42
+ root_node_id: str = PROJECT_ROOT_ID,
43
+ target_php: PHPVersion | None = None,
44
+ ) -> None:
37
45
  """Initialize AST with populated storage.
38
46
 
39
47
  Args:
40
48
  storage: cpg2py Storage containing AST nodes and edges.
41
49
  root_node_id: ID of the root node. Defaults to "project".
50
+ target_php: Target PHP version of the parse that produced the
51
+ graph. Default None marks the provenance unknown.
42
52
  """
43
53
  super().__init__(storage)
44
54
  self._root_node_id = root_node_id
55
+ self._target_php = target_php
45
56
 
46
57
  @property
47
58
  def storage(self) -> Storage:
@@ -68,13 +79,24 @@ class AST(AbcGraphQuerier[Node, Edge]):
68
79
  """Return the root node ID of this AST."""
69
80
  return self._root_node_id
70
81
 
82
+ @property
83
+ def target_php(self) -> PHPVersion | None:
84
+ """Return the target PHP version the source was parsed under.
85
+
86
+ Parse provenance, not a PHP node field: serialization and printing
87
+ never emit it as part of the PHP-Parser JSON. Always known on a graph
88
+ a parser produced; None on a graph built without a parse.
89
+ """
90
+ return self._target_php
91
+
71
92
  def copy(self) -> AST:
72
93
  """Return a new AST backed by a deep copy of this graph.
73
94
 
74
95
  Node IDs, the edge set, and all node/edge properties are duplicated
75
- verbatim, preserving the ``{file_hash}_{counter}`` ID namespace and the
76
- root node ID. The returned AST shares no mutable state with this one:
77
- mutating either graph leaves the other unchanged.
96
+ verbatim, preserving the ``{file_hash}_{counter}`` ID namespace, the
97
+ root node ID and the target PHP version. The returned AST shares no
98
+ mutable state with this one: mutating either graph leaves the other
99
+ unchanged.
78
100
 
79
101
  Returns:
80
102
  A new AST whose storage is an independent copy of this graph.
@@ -82,7 +104,9 @@ class AST(AbcGraphQuerier[Node, Edge]):
82
104
  storage = Storage()
83
105
  self._copy_nodes_into(storage)
84
106
  self._copy_edges_into(storage)
85
- return AST(storage, root_node_id=self._root_node_id)
107
+ return AST(
108
+ storage, root_node_id=self._root_node_id, target_php=self._target_php
109
+ )
86
110
 
87
111
  def _copy_nodes_into(self, storage: Storage) -> None:
88
112
  """Duplicate every node and its properties into ``storage``."""
@@ -12,6 +12,7 @@ from cpg2py import Storage
12
12
 
13
13
  from .._graph import AST, Modifier, NodeId
14
14
  from .._types import PROJECT_ROOT_ID, FileRecord, ProjectLayout
15
+ from .._version import PHPVersion
15
16
  from ._contract import (
16
17
  ATTRIBUTE_KEYS_PROPERTY,
17
18
  LIST_LENGTHS_PROPERTY,
@@ -26,7 +27,11 @@ _FILE_END_DEFAULTS: dict[str, int] = {"endLine": 1, "endFilePos": 0, "endTokenPo
26
27
  _CODE_ROOT_ID = "__code_root__"
27
28
 
28
29
 
29
- def build(json_trees: list[dict[str, object]], layout: ProjectLayout) -> AST:
30
+ def build(
31
+ json_trees: list[dict[str, object]],
32
+ layout: ProjectLayout,
33
+ target_php: PHPVersion,
34
+ ) -> AST:
30
35
  """Populate a fresh ``AST`` from PHP-Parser JSON trees.
31
36
 
32
37
  Forward entry of the mapping package; inverse of ``serialize``.
@@ -34,6 +39,8 @@ def build(json_trees: list[dict[str, object]], layout: ProjectLayout) -> AST:
34
39
  Args:
35
40
  json_trees: Normalized list of root node dicts (code-only mode).
36
41
  layout: Project/file structure and per-file hashes.
42
+ target_php: Target PHP version the trees were parsed under; recorded
43
+ as the AST's provenance, never as a node field.
37
44
 
38
45
  Returns:
39
46
  AST with project -> file -> statement hierarchy, or a flat statement
@@ -42,7 +49,7 @@ def build(json_trees: list[dict[str, object]], layout: ProjectLayout) -> AST:
42
49
  Raises:
43
50
  ValueError: From ``Modifier.add_node`` on a duplicate ID.
44
51
  """
45
- builder = _Builder()
52
+ builder = _Builder(target_php)
46
53
  if layout.project_path is None:
47
54
  return builder.build_code(json_trees)
48
55
  return builder.build_project(layout)
@@ -52,18 +59,22 @@ class _Builder:
52
59
  """Stateful JSON->graph builder with a per-file ID allocator.
53
60
 
54
61
  Attributes:
62
+ _target_php: Target PHP version stamped on the built AST.
55
63
  _next_index: Stateful per-file ID allocator counter.
56
64
  _prefix: Current file-hash prefix; empty string for code-only parsing.
57
65
  """
58
66
 
59
- def __init__(self) -> None:
67
+ def __init__(self, target_php: PHPVersion) -> None:
60
68
  """Initialize a builder with a fresh allocator state."""
69
+ self._target_php = target_php
61
70
  self._next_index = 1
62
71
  self._prefix = ""
63
72
 
64
73
  def build_code(self, json_trees: list[dict[str, object]]) -> AST:
65
74
  """Build statements anchored under a synthetic code-root node."""
66
- modifier = Modifier(AST(Storage(), root_node_id=_CODE_ROOT_ID))
75
+ modifier = Modifier(
76
+ AST(Storage(), root_node_id=_CODE_ROOT_ID, target_php=self._target_php)
77
+ )
67
78
  # A real anchor node makes top-level membership explicit: serialize
68
79
  # exports the anchor's stmts edges. Inferring roots from missing
69
80
  # parent edges would promote subtrees orphaned by remove_node.
@@ -78,7 +89,7 @@ class _Builder:
78
89
  project_path = layout.project_path
79
90
  if project_path is None:
80
91
  raise ValueError("ProjectLayout.project_path is required for build")
81
- modifier = Modifier(AST(Storage()))
92
+ modifier = Modifier(AST(Storage(), target_php=self._target_php))
82
93
  modifier.add_node(
83
94
  PROJECT_ROOT_ID,
84
95
  "Project",
@@ -139,6 +139,10 @@ def _node_result(ast: AST, nid: str) -> dict[str, Any]:
139
139
  result: dict[str, Any] = {"nodeType": node.node_type}
140
140
  result["attributes"] = {k: props[k] for k in attr_keys}
141
141
  _add_scalar_props(result, props, attr_keys)
142
+ # A recorded attributes bundle marks an original JSON node; only
143
+ # nodes created without that record need declaration defaults.
144
+ if ATTRIBUTE_KEYS_PROPERTY not in props:
145
+ _add_default_attrs(result, node.node_type)
142
146
  return result
143
147
 
144
148
 
@@ -183,7 +187,6 @@ def _reconstruct_children(
183
187
  else:
184
188
  arrays.setdefault(field_name, {})[index] = child_json
185
189
  _fill_arrays(result, arrays, node.all_properties.get(LIST_LENGTHS_PROPERTY, {}))
186
- _add_default_attrs(result, node.node_type)
187
190
  return descend
188
191
 
189
192
 
php_parser_py/_parser.py CHANGED
@@ -15,6 +15,7 @@ from ._graph import AST, Node
15
15
  from ._mapping import build, statement_ids
16
16
  from ._runtime import Runner
17
17
  from ._types import FileRecord, ProjectLayout
18
+ from ._version import PHPVersion
18
19
 
19
20
  logger = logging.getLogger(__name__)
20
21
 
@@ -67,21 +68,35 @@ class Parser:
67
68
 
68
69
  Attributes:
69
70
  _runner: Runner instance for PHP-Parser invocation.
71
+ _target_php: PHP version every parse targets.
70
72
  """
71
73
 
72
74
  def __init__(
73
75
  self,
74
76
  php: PHP | None = None,
75
77
  ini_settings: Mapping[str, str] | None = None,
78
+ *,
79
+ target_php: PHPVersion,
76
80
  ) -> None:
77
81
  """Initialize Parser with Runner.
78
82
 
79
83
  Args:
80
84
  php: Optional PHP instance. If not provided, uses bundled PHP.
85
+ It runs PHP-Parser and is independent of ``target_php``.
81
86
  ini_settings: Optional PHP ini overrides forwarded to the Runner.
82
87
  Default None inherits the binary's environment.
88
+ target_php: PHP version the parsed source targets; selects the
89
+ PHP-Parser grammar and is recorded on every AST produced.
90
+
91
+ Raises:
92
+ TypeError: If ``target_php`` is not a ``PHPVersion``.
83
93
  """
94
+ if not isinstance(target_php, PHPVersion):
95
+ raise TypeError(
96
+ f"target_php must be a PHPVersion, got {type(target_php).__name__}"
97
+ )
84
98
  self._runner = Runner(php=php, ini_settings=ini_settings)
99
+ self._target_php = target_php
85
100
 
86
101
  def parse_code(self, code: str) -> list[Node]:
87
102
  """Parse PHP code string into a list of top-level statement nodes.
@@ -100,8 +115,9 @@ class Parser:
100
115
  ParseError: If code has syntax errors.
101
116
  RunnerError: If PHP execution fails.
102
117
  """
103
- node_list = self._runner.parse(code)
104
- ast = build(node_list, ProjectLayout(project_path=None))
118
+ node_list = self._runner.parse(code, self._target_php)
119
+ layout = ProjectLayout(project_path=None)
120
+ ast = build(node_list, layout, self._target_php)
105
121
  return [ast.node(nid) for nid in statement_ids(ast, ast.project_root_id)]
106
122
 
107
123
  def parse_file(self, path: str, encoding: str | None = None) -> AST:
@@ -136,7 +152,7 @@ class Parser:
136
152
 
137
153
  record = self._read_file(file_path, encoding)
138
154
  layout = ProjectLayout(project_path=file_path.parent, files=[record])
139
- return build([], layout)
155
+ return build([], layout, self._target_php)
140
156
 
141
157
  def parse_project(
142
158
  self,
@@ -185,7 +201,7 @@ class Parser:
185
201
 
186
202
  records = self._read_files(php_files, encoding)
187
203
  layout = ProjectLayout(project_path=project_path_obj, files=records)
188
- return build([], layout)
204
+ return build([], layout, self._target_php)
189
205
 
190
206
  # -- Internal helpers --
191
207
 
@@ -219,11 +235,11 @@ class Parser:
219
235
  code = path.read_bytes().decode("utf-8", "surrogateescape")
220
236
  else:
221
237
  code = path.read_bytes().decode(encoding)
222
- statements = self._runner.parse(code)
238
+ statements = self._runner.parse(code, self._target_php)
223
239
  return FileRecord(path=path, file_hash=_file_id(path), statements=statements)
224
240
 
225
241
 
226
- def parse_code(code: str) -> list[Node]:
242
+ def parse_code(code: str, *, target_php: PHPVersion) -> list[Node]:
227
243
  """Parse PHP code string into a list of top-level statement nodes.
228
244
 
229
245
  Does not create file nodes; statements are anchored under a synthetic
@@ -231,18 +247,22 @@ def parse_code(code: str) -> list[Node]:
231
247
 
232
248
  Args:
233
249
  code: PHP source code string.
250
+ target_php: PHP version the source targets.
234
251
 
235
252
  Returns:
236
253
  List of Node instances representing top-level statements.
237
254
 
238
255
  Raises:
239
256
  ParseError: If PHP-Parser reports syntax error.
257
+ TypeError: If ``target_php`` is not a ``PHPVersion``.
240
258
  """
241
- parser = Parser()
259
+ parser = Parser(target_php=target_php)
242
260
  return parser.parse_code(code)
243
261
 
244
262
 
245
- def parse_file(path: str, encoding: str | None = None) -> AST:
263
+ def parse_file(
264
+ path: str, encoding: str | None = None, *, target_php: PHPVersion
265
+ ) -> AST:
246
266
  """Read and parse a single PHP file into an AST.
247
267
 
248
268
  Creates a project node and a file node. The file node contains
@@ -253,6 +273,7 @@ def parse_file(path: str, encoding: str | None = None) -> AST:
253
273
  encoding: Optional Python codec name declaring the file's byte
254
274
  encoding; the file is transcoded before parsing. Default None
255
275
  passes source bytes through unchanged.
276
+ target_php: PHP version the source targets; recorded on the AST.
256
277
 
257
278
  Returns:
258
279
  AST instance with project -> file -> statements hierarchy.
@@ -264,8 +285,9 @@ def parse_file(path: str, encoding: str | None = None) -> AST:
264
285
  LookupError: If ``encoding`` names no Python codec.
265
286
  UnicodeDecodeError: If the file's bytes do not decode under the
266
287
  declared ``encoding``.
288
+ TypeError: If ``target_php`` is not a ``PHPVersion``.
267
289
  """
268
- parser = Parser()
290
+ parser = Parser(target_php=target_php)
269
291
  return parser.parse_file(path, encoding=encoding)
270
292
 
271
293
 
@@ -274,6 +296,8 @@ def parse_project(
274
296
  file_filter: Callable[[Path], bool] | None = None,
275
297
  ini_settings: Mapping[str, str] | None = None,
276
298
  encoding: str | None = None,
299
+ *,
300
+ target_php: PHPVersion,
277
301
  ) -> AST:
278
302
  """Parse all PHP files in a project directory into an AST.
279
303
 
@@ -293,6 +317,7 @@ def parse_project(
293
317
  encoding; each file is transcoded before parsing. A file that
294
318
  does not decode is skipped with a warning. Default None passes
295
319
  source bytes through unchanged.
320
+ target_php: PHP version every file targets; recorded on the AST.
296
321
 
297
322
  Returns:
298
323
  AST instance with project -> files -> statements hierarchy.
@@ -302,10 +327,11 @@ def parse_project(
302
327
  ValueError: If project_path is not a directory, or if two files
303
328
  hash to the same file ID.
304
329
  LookupError: If ``encoding`` names no Python codec.
330
+ TypeError: If ``target_php`` is not a ``PHPVersion``.
305
331
  """
306
332
  if file_filter is None:
307
333
  file_filter = _default_file_filter
308
- parser = Parser(ini_settings=ini_settings)
334
+ parser = Parser(ini_settings=ini_settings, target_php=target_php)
309
335
  return parser.parse_project(
310
336
  project_path, file_filter=file_filter, encoding=encoding
311
337
  )
php_parser_py/_printer.py CHANGED
@@ -12,6 +12,7 @@ from ._graph import AST, Node
12
12
  from ._mapping import serialize
13
13
  from ._runtime import Runner
14
14
  from ._types import PrintMode, PrintRequest
15
+ from ._version import PHPVersion
15
16
 
16
17
  logger = logging.getLogger(__name__)
17
18
 
@@ -35,6 +36,7 @@ class Printer(Protocol):
35
36
 
36
37
  Raises:
37
38
  RunnerError: On PHP-Parser failure.
39
+ ValueError: If the AST has no known parse target version.
38
40
  """
39
41
 
40
42
  def print_preserving(
@@ -52,6 +54,7 @@ class Printer(Protocol):
52
54
  Raises:
53
55
  RunnerError: On PHP-Parser failure.
54
56
  KeyError: If an AST file key is absent from origin_source.
57
+ ValueError: If the AST has no known parse target version.
55
58
  """
56
59
 
57
60
 
@@ -76,12 +79,14 @@ class _PrinterBase:
76
79
 
77
80
  def print(self, ast: AST) -> dict[str, str]:
78
81
  """Full-rewrite print of every file in the AST."""
82
+ target = self._target_of(ast)
79
83
  return {
80
84
  key: self._runner.print(
81
85
  PrintRequest(
82
86
  php_class=self._php_class,
83
87
  mode=PrintMode.FULL_REWRITE,
84
88
  stmts_json=json_str,
89
+ target_php=target,
85
90
  )
86
91
  )
87
92
  for key, json_str in self._file_statements(ast).items()
@@ -91,6 +96,7 @@ class _PrinterBase:
91
96
  self, ast: AST, origin_source: Mapping[str, str]
92
97
  ) -> dict[str, str]:
93
98
  """Format-preserving print; reprint only changed or new nodes."""
99
+ target = self._target_of(ast)
94
100
  result: dict[str, str] = {}
95
101
  for key, json_str in self._file_statements(ast).items():
96
102
  if key not in origin_source:
@@ -100,11 +106,22 @@ class _PrinterBase:
100
106
  php_class=self._php_class,
101
107
  mode=PrintMode.FORMAT_PRESERVING,
102
108
  stmts_json=json_str,
109
+ target_php=target,
103
110
  orig_source=origin_source[key],
104
111
  )
105
112
  )
106
113
  return result
107
114
 
115
+ @staticmethod
116
+ def _target_of(ast: AST) -> PHPVersion:
117
+ """Return the AST's parse target, rejecting unknown provenance."""
118
+ if ast.target_php is None:
119
+ raise ValueError(
120
+ "Cannot print an AST of unknown parse provenance: "
121
+ "target_php is None."
122
+ )
123
+ return ast.target_php
124
+
108
125
  @staticmethod
109
126
  def _file_statements(ast: AST) -> dict[str, str]:
110
127
  """Map each file key to its statement JSON.
@@ -5,10 +5,12 @@ declare(strict_types=1);
5
5
  /**
6
6
  * Parse PHP source into a JSON AST.
7
7
  *
8
- * Reads one JSON object from stdin: {"phar_path": string, "code": string}.
8
+ * Reads one JSON object from stdin:
9
+ * {"phar_path": string, "code": string, "target_major": int, "target_minor": int}.
9
10
  * The code field is base64: JSON cannot carry the raw non-UTF-8 bytes a
10
- * PHP source file may legally contain. Emits {"errors": [...]} with exit 0
11
- * on syntax errors, the serialized statements on success, or
11
+ * PHP source file may legally contain. The target version selects the
12
+ * PHP-Parser grammar the code is accepted under. Emits {"errors": [...]}
13
+ * with exit 0 on syntax errors, the serialized statements on success, or
12
14
  * {"error": ...} with exit 1 on any other failure.
13
15
  */
14
16
 
@@ -90,9 +92,9 @@ if ($code === false) {
90
92
  exit(2);
91
93
  }
92
94
  $errorHandler = new Collecting();
93
- $parser = (new ParserFactory())->createForNewestSupportedVersion();
94
95
 
95
96
  try {
97
+ $parser = (new ParserFactory())->createForVersion(pppy_target_version($payload));
96
98
  $stmts = $parser->parse($code, $errorHandler);
97
99
  if ($errorHandler->hasErrors()) {
98
100
  $errors = array_map(fn($e) => [
@@ -12,11 +12,15 @@ declare(strict_types=1);
12
12
  * "printer_class": string, // FQCN to instantiate (read as data)
13
13
  * "mode": string, // "full_rewrite" | "format_preserving"
14
14
  * "stmts": string, // JSON of the (modified) statements
15
+ * "target_major": int, // target PHP version, read as data
16
+ * "target_minor": int,
15
17
  * "orig_source": string // original source (format_preserving only)
16
18
  * }
17
19
  *
18
20
  * The printer class name is read from the payload and instantiated via a
19
- * variable class expression; it is never spliced into source. Emits the
21
+ * variable class expression; it is never spliced into source. The target
22
+ * version sets the printer's phpVersion option and the version the original
23
+ * source is reparsed under. Emits the
20
24
  * generated PHP on success, or {"error": ...} with exit 1 on failure.
21
25
  */
22
26
 
@@ -44,13 +48,14 @@ try {
44
48
 
45
49
  // Normalize the FQCN and instantiate by name (class name travels as data).
46
50
  $printerClass = '\\' . ltrim($payload['printer_class'], '\\');
47
- $printer = new $printerClass();
51
+ $version = pppy_target_version($payload);
52
+ $printer = new $printerClass(['phpVersion' => $version]);
48
53
 
49
54
  // Output is base64-encoded so non-UTF-8 source bytes survive the text
50
55
  // stdout channel back to the caller (mirrors the base64 orig_source input).
51
56
  if ($payload['mode'] === 'format_preserving') {
52
57
  $origSource = base64_decode($payload['orig_source']);
53
- echo base64_encode(render_format_preserving($printer, $stmts, $origSource));
58
+ echo base64_encode(render_format_preserving($printer, $stmts, $origSource, $version));
54
59
  } else {
55
60
  echo base64_encode($printer->prettyPrintFile($stmts));
56
61
  }
@@ -93,9 +98,13 @@ function pppy_decode_stmts(string $stmtsJson): array
93
98
  * Re-parse the original source, link unchanged decoded nodes to their original
94
99
  * objects by token position, then reprint preserving original formatting.
95
100
  */
96
- function render_format_preserving($printer, array $stmts, string $origSource): string
97
- {
98
- $parser = (new ParserFactory())->createForNewestSupportedVersion();
101
+ function render_format_preserving(
102
+ $printer,
103
+ array $stmts,
104
+ string $origSource,
105
+ PhpParser\PhpVersion $version
106
+ ): string {
107
+ $parser = (new ParserFactory())->createForVersion($version);
99
108
  $origStmts = $parser->parse($origSource);
100
109
  $origTokens = $parser->getTokens();
101
110
 
@@ -10,7 +10,8 @@ declare(strict_types=1);
10
10
  * value that cannot travel as strict JSON is wrapped in a NUL-prefixed
11
11
  * marker string; the Python side (_transport.py) applies the same
12
12
  * codec. parse.php encodes before emitting; print.php decodes after
13
- * JsonDecoder.
13
+ * JsonDecoder. Both scripts also read the target PHP version from the
14
+ * payload through pppy_target_version.
14
15
  */
15
16
 
16
17
  // json_encode/json_decode default to a 512-level nesting cap, rejecting
@@ -193,3 +194,20 @@ function pppy_decode_comment(PhpParser\Comment $comment)
193
194
  $comment->getEndTokenPos()
194
195
  );
195
196
  }
197
+
198
+ /**
199
+ * Read the target PHP version a payload carries as two integer fields,
200
+ * target_major and target_minor. Python validates the series before it
201
+ * sends the payload, so a missing or mistyped field is a channel fault.
202
+ */
203
+ function pppy_target_version(array $payload): PhpParser\PhpVersion
204
+ {
205
+ $major = $payload['target_major'] ?? null;
206
+ $minor = $payload['target_minor'] ?? null;
207
+ if (!is_int($major) || !is_int($minor)) {
208
+ throw new RuntimeException(
209
+ 'payload target_major and target_minor must be integers'
210
+ );
211
+ }
212
+ return PhpParser\PhpVersion::fromComponents($major, $minor);
213
+ }
@@ -13,6 +13,7 @@ from static_php_py import PHP, ExecutionError, ExecutionResult
13
13
  from .._exceptions import ParseError, RunnerError
14
14
  from .._transport import decode_values
15
15
  from .._types import PrintMode, PrintRequest
16
+ from .._version import PHPVersion
16
17
  from ._binary import resolve_php
17
18
  from ._bundle import ensure_php_parser_extracted, get_resources_path
18
19
 
@@ -21,6 +22,11 @@ from ._bundle import ensure_php_parser_extracted, get_resources_path
21
22
  _BASE_INI = {"display_errors": "stderr"}
22
23
 
23
24
 
25
+ def _target_fields(target_php: PHPVersion) -> dict[str, int]:
26
+ # The target travels as two integers read as data by the PHP scripts.
27
+ return {"target_major": target_php.major, "target_minor": target_php.minor}
28
+
29
+
24
30
  def _failure_message(result: ExecutionResult) -> str:
25
31
  # Assemble the RunnerError message carrying the process's full output.
26
32
  message = f"PHP execution failed with exit code {result.return_code}"
@@ -114,11 +120,12 @@ class Runner:
114
120
  )
115
121
  return result.stdout
116
122
 
117
- def parse(self, code: str) -> list[dict[str, Any]]:
123
+ def parse(self, code: str, target_php: PHPVersion) -> list[dict[str, Any]]:
118
124
  """Invoke PHP-Parser parse + JsonSerializer.
119
125
 
120
126
  Args:
121
127
  code: PHP source code to parse.
128
+ target_php: PHP version whose grammar the parser accepts.
122
129
 
123
130
  Returns:
124
131
  Parsed statements as a list of PHP-Parser JSON node dicts.
@@ -127,7 +134,7 @@ class Runner:
127
134
  ParseError: If PHP-Parser reports syntax error.
128
135
  RunnerError: If PHP execution fails or emits a malformed payload.
129
136
  """
130
- stdin = self._build_parse_stdin(code)
137
+ stdin = self._build_parse_stdin(code, target_php)
131
138
 
132
139
  try:
133
140
  output = self._execute_script_file("parse.php", stdin)
@@ -186,7 +193,8 @@ class Runner:
186
193
  """Render and run the print script for one file.
187
194
 
188
195
  Args:
189
- request: Print inputs (printer class, mode, statements, origin).
196
+ request: Print inputs (printer class, mode, statements, target
197
+ version, origin).
190
198
 
191
199
  Returns:
192
200
  Generated PHP source code.
@@ -200,7 +208,7 @@ class Runner:
200
208
  # the text stdout channel; recover the exact bytes back to a str.
201
209
  return base64.b64decode(output).decode("utf-8", "surrogateescape")
202
210
 
203
- def _build_parse_stdin(self, code: str) -> str:
211
+ def _build_parse_stdin(self, code: str, target_php: PHPVersion) -> str:
204
212
  """Pack the parse inputs into a single JSON stdin payload.
205
213
 
206
214
  The code field travels as base64: PHP source may legally hold raw
@@ -211,6 +219,7 @@ class Runner:
211
219
  payload = {
212
220
  "phar_path": str(self._phar_path()),
213
221
  "code": base64.b64encode(raw).decode("ascii"),
222
+ **_target_fields(target_php),
214
223
  }
215
224
  return json.dumps(payload)
216
225
 
@@ -219,13 +228,15 @@ class Runner:
219
228
 
220
229
  The printer class name travels as the ``printer_class`` field; it is
221
230
  read as data by ``print.php`` and never interpolated into PHP source.
231
+ The target version travels as two integers.
222
232
  """
223
- payload: dict[str, str] = {
233
+ payload: dict[str, str | int] = {
224
234
  "phar_path": str(self._phar_path()),
225
235
  "extended_path": str(get_resources_path() / "ExtendedPrinter.php"),
226
236
  "printer_class": request.php_class,
227
237
  "mode": request.mode.value,
228
238
  "stmts": request.stmts_json,
239
+ **_target_fields(request.target_php),
229
240
  }
230
241
  if request.mode is PrintMode.FORMAT_PRESERVING:
231
242
  # Original source may hold non-UTF-8 bytes (e.g. Latin-1 literals).
php_parser_py/_types.py CHANGED
@@ -6,6 +6,8 @@ from dataclasses import dataclass, field
6
6
  from enum import Enum
7
7
  from pathlib import Path
8
8
 
9
+ from ._version import PHPVersion
10
+
9
11
  # The AST hierarchy edge type; fixed by the JSON<->graph mapping contract.
10
12
  PARENT_OF_EDGE = "PARENT_OF"
11
13
 
@@ -36,6 +38,8 @@ class PrintRequest:
36
38
  php_class: Fully-qualified printer class to instantiate.
37
39
  mode: Entry-point selector.
38
40
  stmts_json: JSON of the (modified) statements.
41
+ target_php: PHP version the printer options and the original-source
42
+ reparse follow; the parse provenance of the printed AST.
39
43
  orig_source: Original PHP source, re-parsed in-process to recover
40
44
  original objects and tokens. Required when ``mode`` is
41
45
  FORMAT_PRESERVING; None otherwise.
@@ -48,6 +52,7 @@ class PrintRequest:
48
52
  php_class: str
49
53
  mode: PrintMode
50
54
  stmts_json: str
55
+ target_php: PHPVersion
51
56
  orig_source: str | None = None
52
57
 
53
58
  def __post_init__(self) -> None:
@@ -0,0 +1,51 @@
1
+ """PHP target version: the (major, minor) series a parse or print targets."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ # Newest minor accepted per supported major series. Project policy, not an
8
+ # upstream boundary: PHP-Parser takes arbitrary components and treats the
9
+ # target as best-effort.
10
+ _NEWEST_MINOR_BY_MAJOR = {7: 4, 8: 5}
11
+ _SUPPORTED_SERIES = " and ".join(
12
+ f"{major}.0-{major}.{newest}" for major, newest in _NEWEST_MINOR_BY_MAJOR.items()
13
+ )
14
+
15
+
16
+ def _require_int(name: str, value: object) -> None:
17
+ # bool is an int subtype but never a version component.
18
+ if isinstance(value, bool) or not isinstance(value, int):
19
+ raise TypeError(
20
+ f"PHPVersion {name} must be an int, got {type(value).__name__}: {value!r}"
21
+ )
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class PHPVersion:
26
+ """A target PHP series: major and minor version, without a patch level.
27
+
28
+ Accepted series are 7.0 to 7.4 and 8.0 to 8.5. The patch level is not
29
+ part of the target: PHP-Parser distinguishes series only.
30
+
31
+ Attributes:
32
+ major: PHP major version, 7 or 8.
33
+ minor: PHP minor version within the major series.
34
+
35
+ Raises:
36
+ TypeError: If a component is not an ``int`` (``bool`` included).
37
+ ValueError: If the pair is not an accepted series.
38
+ """
39
+
40
+ major: int
41
+ minor: int
42
+
43
+ def __post_init__(self) -> None:
44
+ _require_int("major", self.major)
45
+ _require_int("minor", self.minor)
46
+ newest = _NEWEST_MINOR_BY_MAJOR.get(self.major)
47
+ if newest is None or not 0 <= self.minor <= newest:
48
+ raise ValueError(
49
+ f"Unsupported target PHP version {self.major}.{self.minor}; "
50
+ f"supported series are {_SUPPORTED_SERIES}"
51
+ )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: php-parser-py
3
- Version: 1.2.6
3
+ Version: 2.0.3
4
4
  Summary: Python wrapper for PHP-Parser using cpg2py
5
5
  License-Expression: GPL-2.0-only
6
6
  License-File: LICENSE
@@ -37,10 +37,10 @@ Requires Python >= 3.10.
37
37
  ## Usage
38
38
 
39
39
  ```python
40
- from php_parser_py import parse_file, Modifier
40
+ from php_parser_py import parse_file, Modifier, PHPVersion
41
41
 
42
- # Parse
43
- ast = parse_file("src/User.php")
42
+ # Parse (the target PHP version is required)
43
+ ast = parse_file("src/User.php", target_php=PHPVersion(8, 3))
44
44
 
45
45
  # Query
46
46
  functions = ast.nodes(lambda n: n.node_type == "Stmt_Function")
@@ -58,9 +58,11 @@ modified = modifier.ast
58
58
 
59
59
  | Function / Class | Signature | Description |
60
60
  |-----------------|-----------|-------------|
61
- | `parse_code` | `(code: str) -> list[Node]` | Parse PHP string into statement nodes |
62
- | `parse_file` | `(path: str) -> AST` | Parse single file with project/file structure |
63
- | `parse_project` | `(project_path: str, file_filter?) -> AST` | Parse all PHP files in a directory |
61
+ | `PHPVersion` | `(major: int, minor: int)` | Target PHP version, 7.0 to 7.4 and 8.0 to 8.5 |
62
+ | `parse_code` | `(code: str, *, target_php: PHPVersion) -> list[Node]` | Parse PHP string into statement nodes |
63
+ | `parse_file` | `(path: str, *, target_php: PHPVersion) -> AST` | Parse single file with project/file structure |
64
+ | `parse_project` | `(project_path: str, file_filter?, *, target_php: PHPVersion) -> AST` | Parse all PHP files in a directory |
65
+ | `AST.target_php` | `-> PHPVersion \| None` | Version the graph was parsed under (read-only) |
64
66
  | `Modifier` | `(ast: AST)` | Copy-on-write graph mutation; read result from `modifier.ast` |
65
67
  | `AST.nodes` | `(predicate?) -> Generator[Node]` | Iterate/filter nodes |
66
68
  | `AST.succ` | `(node) -> Generator[Node]` | Get children |
@@ -1,35 +1,35 @@
1
- php_parser_py/__init__.py,sha256=THb6r0h4R4e3E40EB_gPMfKbOTgRix6dtazlPaqG-_I,821
1
+ php_parser_py/__init__.py,sha256=BkOXmqdi1q3YG3MZljPP9Aa43VoMUtc8gdyVyFVta-Q,968
2
2
  php_parser_py/_exceptions.py,sha256=lWGngHWCrOEGL3ILwPMFnU0mlha6e5U6SYxz7n1oLY8,1824
3
3
  php_parser_py/_graph/__init__.py,sha256=GAOtPcGGMQCbif86vdQgk9GgTkOJPee6_NA-wewN3g8,396
4
- php_parser_py/_graph/_ast.py,sha256=SCOVdQzIJ2lI6yzoyazRpzT3EYQrfYM4kKEPeT8kCMo,7959
4
+ php_parser_py/_graph/_ast.py,sha256=HHlqm7-Et1T2aMRtILTrlXElPGo_15FB4DS--x_NEyg,8856
5
5
  php_parser_py/_graph/_edge.py,sha256=WzimZf6WB6inHACrFHsQOyFgrhdzyfGhv8UboEPOQjE,2995
6
6
  php_parser_py/_graph/_modifier.py,sha256=VLJruL0260_VbXoXYWZdGmrXO-oXgiWIWS6v3MRmsaM,4829
7
7
  php_parser_py/_graph/_node.py,sha256=hA4eRQx4PWAakHkehC5SbpS12FvtlGDf2UnT4RuFZQ4,8276
8
8
  php_parser_py/_graph/_node_id.py,sha256=YvtpkOO1DiT-yHyOED0pSsDqzuYFUiNRGtGvCNKvArY,1597
9
9
  php_parser_py/_mapping/__init__.py,sha256=XzQuME5jXu_GBiwFpXZyhM9EevRJaNyrH_vAu7jFNos,469
10
- php_parser_py/_mapping/_build.py,sha256=5iVycpqHb_spJXnx42ezcYKsfxdVoG5o7vGmFATtIg8,8843
10
+ php_parser_py/_mapping/_build.py,sha256=C9gCBJKS3q5VYVNbqWdrI5gC3DD5X9dSJBBfOUDyVes,9271
11
11
  php_parser_py/_mapping/_contract.py,sha256=Czfspg4FbU_FZOJLV0AtAmEl6-Pjuo_RLOyOBH8NtYw,2451
12
- php_parser_py/_mapping/_serialize.py,sha256=_iGvXi-8LqV9zERTao3pXT_TpfTFzWcSX6BuwJ9wg4A,8485
13
- php_parser_py/_parser.py,sha256=lZ51OQc76Amd1U1Bc8QLdwyPRdvqRkdmaS7j49RRazY,11703
14
- php_parser_py/_printer.py,sha256=CUV4CmUNT0yX6qTFM6WlnKNliPvoY3yxhTWNdEYZuck,4561
12
+ php_parser_py/_mapping/_serialize.py,sha256=EP3A5_wXD72a0-drb62j_N-hJ-JGJoUZ6qqFNWdYZOs,8670
13
+ php_parser_py/_parser.py,sha256=juoRV9sxQJi7odimmm3s27s3l4aHUbBZ4toODEKc3PM,13012
14
+ php_parser_py/_printer.py,sha256=SEr1ivOJnoDUxzDaKY0Hkiu5kNQOfAuqiwH10QtH6tI,5241
15
15
  php_parser_py/_resources/ExtendedPrinter.php,sha256=hv2wKJ7QzK6epkevlwmlWrOiFncClTetiqR8kriKsCY,6333
16
- php_parser_py/_resources/parse.php,sha256=EuCajqyBg8H7DlMApJp5dOLk-Sa9Kib6fcA3XbJs4GI,3624
17
- php_parser_py/_resources/php,sha256=vvXi2Cj0AtYVxcNp-Iaa2u-A8jR-znyvIZhDUM_-0bw,8918288
16
+ php_parser_py/_resources/parse.php,sha256=g0pDrC1h1Lv85nqIbhOdZcuGgByjz4lJTEivNZAu6NY,3768
18
17
  php_parser_py/_resources/php-parser-5.7.0.tar.gz,sha256=CeU5G4YHMA__oybtkqM3dNW07VlcSLOUl2eQWpLklFs,143662
19
- php_parser_py/_resources/print.php,sha256=11Tp5NEeVxag7m7RmjivGiPfu6gqmkMnPgMmSpc3ElA,5649
20
- php_parser_py/_resources/transport.php,sha256=Ps7oE1Fv_zM39rslnpRTc1QZVV1DSJLII3kPN0ahkg8,6048
18
+ php_parser_py/_resources/print.php,sha256=2YDlnK3XPJkqulabrpItAA3HlUDXMgjiO8G3vcrLT7k,5987
19
+ php_parser_py/_resources/transport.php,sha256=WTkEf85Lx8rQ9gojqWC1aGo7tp3XR148M1o8B1OR2K4,6767
21
20
  php_parser_py/_runtime/__init__.py,sha256=gKauz8DvQxNUQyNMLfPgy08tEoyIxXsmWb_96bHheJE,223
22
21
  php_parser_py/_runtime/_binary.py,sha256=OrldT-rcPjA7P8PeDPNd717BPNfN2hXgwoUe63YWzl0,4145
23
22
  php_parser_py/_runtime/_bundle.py,sha256=tIWRCmDuIVGmmSYjtUD-i04rIFJcBIzIjpRM5dpIu7Y,5567
24
- php_parser_py/_runtime/_runner.py,sha256=-srSLadX2eR13BqOfvXkY0iJ68XH50TX0YBVD1PyAbs,9480
23
+ php_parser_py/_runtime/_runner.py,sha256=MDvHIrBfeCq5Lw4qlYx2_W1Yj9mzG5vxUunNvqtVtNI,10044
25
24
  php_parser_py/_transport.py,sha256=J4qo0c8gBEGv05Vww59QnkDZ1R0Bjzrjtr8mV2xbCjo,3873
26
- php_parser_py/_types.py,sha256=E_eNJqyWN8guIlNGmSs7qILmT9xa289IdjiwaeGMzSA,2719
25
+ php_parser_py/_types.py,sha256=70aTz0AbMsJjQb_5w_t8yVYVqg8GZonXi_2HHGDoF5o,2925
26
+ php_parser_py/_version.py,sha256=ZFkpcL_cP2MZyPOwMy4QX68MTVNhYw77F3MNa41dXuo,1760
27
27
  php_parser_py/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
28
28
  php_parser_py/_resources/php.exe,sha256=yv_KdAxqo8Pc_xrwnCjdBA2_jn24PL1V4TB1Aey34vY,9529856
29
- php_parser_py-1.2.6.dist-info/METADATA,sha256=pO-QeApnKgXthtQees0JvDRi9BNe84r-DR-ViNukLhE,3544
30
- php_parser_py-1.2.6.dist-info/WHEEL,sha256=eOph205RIaepfK4qdVVjtNnzanETS7NC0N2C0Ks5Yc8,104
31
- php_parser_py-1.2.6.dist-info/licenses/LICENSE,sha256=gXf5dRMhNSbfLPYYTY_5hsZ1r7UU1OaKQEAQUhuIBkM,18092
32
- php_parser_py-1.2.6.dist-info/licenses/PHP-LICENSE,sha256=tC5N9eUObs2hBH1QPW2R1xAy0J7RAnuh7ynu0m-JDFo,3204
33
- php_parser_py-1.2.6.dist-info/licenses/PHP-Parser-LICENSE,sha256=3YWeEH-sn4ekQTq4w8lvDxYRoX9Zai21YqWk9JBMHpc,1520
34
- php_parser_py-1.2.6.dist-info/licenses/Zend-Engine-LICENSE,sha256=UJrrO-ZOQo1-AATOIkYgqwG_lK2qzvu0SWEZOd9WvFs,2803
35
- php_parser_py-1.2.6.dist-info/RECORD,,
29
+ php_parser_py-2.0.3.dist-info/METADATA,sha256=JC0zDNsthV9OCG-fCpYYG3KQYJiADU7-6jRNKt5Lxp8,3893
30
+ php_parser_py-2.0.3.dist-info/WHEEL,sha256=eOph205RIaepfK4qdVVjtNnzanETS7NC0N2C0Ks5Yc8,104
31
+ php_parser_py-2.0.3.dist-info/licenses/LICENSE,sha256=gXf5dRMhNSbfLPYYTY_5hsZ1r7UU1OaKQEAQUhuIBkM,18092
32
+ php_parser_py-2.0.3.dist-info/licenses/PHP-LICENSE,sha256=tC5N9eUObs2hBH1QPW2R1xAy0J7RAnuh7ynu0m-JDFo,3204
33
+ php_parser_py-2.0.3.dist-info/licenses/PHP-Parser-LICENSE,sha256=3YWeEH-sn4ekQTq4w8lvDxYRoX9Zai21YqWk9JBMHpc,1520
34
+ php_parser_py-2.0.3.dist-info/licenses/Zend-Engine-LICENSE,sha256=UJrrO-ZOQo1-AATOIkYgqwG_lK2qzvu0SWEZOd9WvFs,2803
35
+ php_parser_py-2.0.3.dist-info/RECORD,,
Binary file