php-parser-py 1.2.0__tar.gz → 1.2.1__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: php-parser-py
3
- Version: 1.2.0
3
+ Version: 1.2.1
4
4
  Summary: Python wrapper for PHP-Parser using cpg2py
5
5
  Author: samhsu-dev
6
6
  Author-email: samhsu-dev <yxu166@jh.edu>
@@ -11,6 +11,8 @@ Description-Content-Type: text/markdown
11
11
 
12
12
  # php-parser-py
13
13
 
14
+ [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/jhu-seclab-beluga/php-parser-py)
15
+
14
16
  Python wrapper for [nikic/PHP-Parser](https://github.com/nikic/PHP-Parser) built on the [cpg2py](https://github.com/jhu-seclab/cpg2py) graph framework.
15
17
 
16
18
  - **Automated**: Uses [static-php-cli](https://github.com/static-php-cli/static-php-cli) binaries (no local PHP required).
@@ -1,5 +1,7 @@
1
1
  # php-parser-py
2
2
 
3
+ [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/jhu-seclab-beluga/php-parser-py)
4
+
3
5
  Python wrapper for [nikic/PHP-Parser](https://github.com/nikic/PHP-Parser) built on the [cpg2py](https://github.com/jhu-seclab/cpg2py) graph framework.
4
6
 
5
7
  - **Automated**: Uses [static-php-cli](https://github.com/static-php-cli/static-php-cli) binaries (no local PHP required).
@@ -0,0 +1,63 @@
1
+ [project]
2
+ name = "php-parser-py"
3
+ version = "1.2.1"
4
+ description = "Python wrapper for PHP-Parser using cpg2py"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "samhsu-dev", email = "yxu166@jh.edu" }
8
+ ]
9
+ requires-python = ">=3.11"
10
+ dependencies = [
11
+ "cpg2py>=1.2.0",
12
+ "static-php-py",
13
+ ]
14
+
15
+ [build-system]
16
+ requires = ["uv_build>=0.9.26,<0.10.0"]
17
+ build-backend = "uv_build"
18
+
19
+ [tool.uv]
20
+ package = true
21
+
22
+ [tool.uv.build-backend]
23
+ # Include resources directory in package data
24
+ source-include = ["resources/*.zip"]
25
+
26
+ [dependency-groups]
27
+ dev = [
28
+ "black>=26.1.0",
29
+ "isort>=7.0.0",
30
+ "mypy>=1.13.0",
31
+ "pylint>=4.0.4",
32
+ "pytest>=9.0.2",
33
+ ]
34
+
35
+ [tool.pylint.messages_control]
36
+ disable = [
37
+ "wrong-import-position",
38
+ "too-many-arguments",
39
+ "too-many-positional-arguments",
40
+ "too-many-locals",
41
+ "too-many-branches",
42
+ "too-many-statements",
43
+ "invalid-name",
44
+ ]
45
+
46
+ [tool.mypy]
47
+ python_version = "3.11"
48
+ strict = true
49
+ warn_return_any = true
50
+ warn_unused_ignores = true
51
+ allow_subclassing_any = true
52
+
53
+ [[tool.mypy.overrides]]
54
+ module = ["cpg2py", "cpg2py.*", "static_php_py", "static_php_py.*"]
55
+ ignore_missing_imports = true
56
+
57
+ [tool.black]
58
+ line-length = 88
59
+ target-version = ["py311"]
60
+
61
+ [tool.isort]
62
+ profile = "black"
63
+ line_length = 88
@@ -5,11 +5,8 @@ This package provides a Python interface to PHP-Parser, enabling AST parsing
5
5
  and manipulation of PHP code.
6
6
  """
7
7
 
8
- from typing import TYPE_CHECKING
9
-
10
- if TYPE_CHECKING:
11
- from pathlib import Path
12
- from typing import Callable
8
+ from pathlib import Path
9
+ from typing import Callable
13
10
 
14
11
  from php_parser_py._parser import Parser as _Parser
15
12
  from php_parser_py._printer import PrettyPrinter as _PrettyPrinter
@@ -21,7 +18,7 @@ ensure_php_parser_extracted()
21
18
  # Import and expose public classes directly
22
19
  from php_parser_py._ast import AST
23
20
  from php_parser_py._edge import Edge
24
- from php_parser_py._exceptions import ParseError, RunnerError
21
+ from php_parser_py._exceptions import NodeNotInFileError, ParseError, RunnerError
25
22
  from php_parser_py._node import Node
26
23
 
27
24
 
@@ -79,9 +76,13 @@ def parse_file(path: str) -> AST:
79
76
  return parser.parse_file(path)
80
77
 
81
78
 
79
+ def _default_file_filter(path: Path) -> bool:
80
+ return path.suffix == ".php"
81
+
82
+
82
83
  def parse_project(
83
84
  project_path: str,
84
- file_filter: "Callable[[Path], bool]" = lambda p: p.suffix == ".php", # type: ignore
85
+ file_filter: Callable[[Path], bool] | None = None,
85
86
  ) -> AST:
86
87
  """Parse all PHP files in a project directory into an AST.
87
88
 
@@ -101,6 +102,8 @@ def parse_project(
101
102
  FileNotFoundError: If project directory does not exist.
102
103
  ValueError: If project_path is not a directory.
103
104
  """
105
+ if file_filter is None:
106
+ file_filter = _default_file_filter
104
107
  parser = Parser()
105
108
  return parser.parse_project(project_path, file_filter=file_filter)
106
109
 
@@ -114,6 +117,7 @@ __all__ = [
114
117
  "PrettyPrinter",
115
118
  "ParseError",
116
119
  "RunnerError",
120
+ "NodeNotInFileError",
117
121
  "parse_code",
118
122
  "parse_file",
119
123
  "parse_project",
@@ -0,0 +1,341 @@
1
+ """AST representation for PHP code using cpg2py's graph framework."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ from typing import Any
8
+
9
+ from cpg2py import AbcGraphQuerier, Storage
10
+
11
+ from php_parser_py._edge import Edge
12
+ from php_parser_py._exceptions import NodeNotInFileError
13
+ from php_parser_py._node import Node
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class AST(AbcGraphQuerier[Node, Edge]):
19
+ """Represents a PHP Abstract Syntax Tree.
20
+
21
+ This class extends cpg2py's AbcGraphQuerier with generic type parameters
22
+ specifying that it works with Node and Edge types.
23
+
24
+ The AST structure follows a hierarchy:
25
+ - Project node (ID: "project") - root of the AST
26
+ - File nodes (ID: hex hash) - children of project
27
+ - Statement nodes (ID: {hex}_{counter}) - children of files
28
+
29
+ Type Parameters:
30
+ Node: The concrete node type for PHP AST nodes
31
+ Edge: The concrete edge type for parent-child relationships
32
+
33
+ Provides graph traversal methods, JSON reconstruction for code generation,
34
+ and AST merging capabilities. Uses cpg2py graph API (nodes, edges, succ,
35
+ prev, ancestors, descendants); storage is only used where the graph API
36
+ does not provide an alternative (node/edge existence and Node/Edge construction).
37
+
38
+ Attributes:
39
+ _root_node_id: ID of the root project node (always "project").
40
+ """
41
+
42
+ def __init__(self, storage: Storage, root_node_id: str = "project") -> None:
43
+ """Initialize AST with populated storage.
44
+
45
+ Args:
46
+ storage: cpg2py Storage containing AST nodes and edges.
47
+ root_node_id: ID of the root node. Defaults to "project".
48
+ """
49
+ super().__init__(storage)
50
+ self._root_node_id = root_node_id
51
+
52
+ def node(self, whose_id_is: str) -> Node:
53
+ """Return node wrapper by ID.
54
+
55
+ Args:
56
+ whose_id_is: Node ID string.
57
+
58
+ Returns:
59
+ Node instance for the given ID.
60
+
61
+ Raises:
62
+ KeyError: If the node ID is not in the graph.
63
+ """
64
+ if not self.storage.contains_node(whose_id_is):
65
+ raise KeyError(f"Node not found: {whose_id_is!r}")
66
+ return Node(self.storage, whose_id_is)
67
+
68
+ def edge(self, fid: str, tid: str, eid: str) -> Edge:
69
+ """Return edge wrapper by IDs.
70
+
71
+ Args:
72
+ fid: From node ID.
73
+ tid: To node ID.
74
+ eid: Edge type.
75
+
76
+ Returns:
77
+ Edge instance for the given (from, to, type).
78
+
79
+ Raises:
80
+ KeyError: If the edge is not in the graph.
81
+ """
82
+ edge_id = (fid, tid, eid)
83
+ if not self.storage.contains_edge(edge_id):
84
+ raise KeyError(f"Edge not found: {edge_id!r}")
85
+ return Edge(self.storage, fid, tid, eid)
86
+
87
+ def project_node(self) -> Node:
88
+ """Return the project node (root of the AST).
89
+
90
+ The project node has ID "project" and is always present when the AST
91
+ was created by parse_file or parse_project.
92
+
93
+ Returns:
94
+ Project Node instance.
95
+
96
+ Raises:
97
+ KeyError: If the root node is not in the graph.
98
+ """
99
+ if not self.storage.contains_node(self._root_node_id):
100
+ raise KeyError(f"Project node not found: {self._root_node_id!r}")
101
+ return Node(self.storage, self._root_node_id)
102
+
103
+ def file_nodes(self) -> list[Node]:
104
+ """Return all file nodes in the project.
105
+
106
+ Returns:
107
+ List of File Node instances.
108
+ """
109
+ try:
110
+ project = self.project_node()
111
+ except KeyError:
112
+ return []
113
+
114
+ file_nodes = [n for n in self.succ(project) if n.node_type == "File"]
115
+ return sorted(file_nodes, key=lambda n: n.get("absolutePath", ""))
116
+
117
+ def get_file_node(self, node_id: str) -> Node:
118
+ """Get the file node that contains the given node.
119
+
120
+ Uses the AST ID convention: file node ID is the file hash (e.g. 8-char hex);
121
+ nodes inside that file have ID ``file_hash + "_" + increment`` (e.g. ``a1b2c3d4_1``).
122
+ Resolves by structure first, then falls back to ancestors() traversal.
123
+
124
+ Args:
125
+ node_id: ID of any node in the AST.
126
+
127
+ Returns:
128
+ File Node instance containing the given node.
129
+
130
+ Raises:
131
+ KeyError: If the node ID is not in the graph.
132
+ NodeNotInFileError: If the node is not under any file (e.g. project node).
133
+ """
134
+ node = self.node(node_id)
135
+ if node.node_type == "File":
136
+ return node
137
+
138
+ if node_id == self._root_node_id:
139
+ raise NodeNotInFileError(node_id, "Project node has no containing file.")
140
+
141
+ result = self._try_file_by_id_prefix(node_id)
142
+ if result is not None:
143
+ return result
144
+
145
+ return self._find_file_ancestor(node)
146
+
147
+ def _try_file_by_id_prefix(self, node_id: str) -> Node | None:
148
+ """Try to find file node by ID prefix convention.
149
+
150
+ If node_id is like "hash_123", attempts to get file node with ID "hash".
151
+ Returns None if ID doesn't match convention or file not found.
152
+ """
153
+ if "_" not in node_id:
154
+ return None
155
+
156
+ prefix, rest = node_id.split("_", 1)
157
+ if not rest.isdigit():
158
+ return None
159
+
160
+ try:
161
+ candidate = self.node(prefix)
162
+ if candidate.node_type == "File":
163
+ return candidate
164
+ except KeyError:
165
+ pass
166
+
167
+ return None
168
+
169
+ def _find_file_ancestor(self, node: Node) -> Node:
170
+ """Find first File ancestor of a node via traversal.
171
+
172
+ Raises NodeNotInFileError if no File ancestor exists.
173
+ """
174
+ for ancestor in self.ancestors(node):
175
+ if ancestor.node_type == "File":
176
+ result: Node = ancestor
177
+ return result
178
+
179
+ raise NodeNotInFileError(node.id, "No File node among ancestors.")
180
+
181
+ def to_json(self, file_hash: str | None = None) -> str:
182
+ """Reconstruct PHP-Parser JSON from Storage for code generation.
183
+
184
+ If file_hash is provided, exports only that file's statements.
185
+ Otherwise, exports all statements from all files, or root nodes if no file structure exists.
186
+
187
+ Args:
188
+ file_hash: Optional file hash to export only that file.
189
+
190
+ Returns:
191
+ JSON string compatible with PHP-Parser's JsonDecoder.
192
+ """
193
+ if file_hash:
194
+ # Export single file (node() raises KeyError if file_hash not in graph)
195
+ self.node(file_hash)
196
+ top_level_nodes = self._get_file_statements(file_hash)
197
+ else:
198
+ # Try to export from file structure first
199
+ file_nodes = self.file_nodes()
200
+ if file_nodes:
201
+ # Export all files (flattened)
202
+ top_level_nodes = []
203
+ for file_node in file_nodes:
204
+ top_level_nodes.extend(self._get_file_statements(file_node.id))
205
+ else:
206
+ # No file structure - find root nodes via graph API (no incoming PARENT_OF)
207
+ all_nodes = {n.id for n in self.nodes()}
208
+ nodes_with_parents = {
209
+ e.to_nid for e in self.edges() if e.type == "PARENT_OF"
210
+ }
211
+ root_nodes = all_nodes - nodes_with_parents
212
+ root_nodes.discard(self._root_node_id)
213
+ top_level_nodes = sorted(root_nodes)
214
+
215
+ result = [self._reconstruct_node(nid) for nid in top_level_nodes]
216
+ return json.dumps(result)
217
+
218
+ def _get_file_statements(self, file_hash: str) -> list[str]:
219
+ """Get top-level statement node IDs for a file (direct children with edge field \"stmts\").
220
+
221
+ Uses succ() traversal; statement IDs follow the convention file_hash_1, file_hash_2, ...
222
+ """
223
+ file_node = self.node(file_hash)
224
+ stmts_with_index = []
225
+ for child in self.succ(file_node):
226
+ edge = self.edge(file_hash, child.id, "PARENT_OF")
227
+ if edge.get("field") == "stmts":
228
+ idx = edge.get("index")
229
+ stmts_with_index.append((999999 if idx is None else idx, child.id))
230
+ stmts_with_index.sort(key=lambda t: t[0])
231
+ return [nid for _, nid in stmts_with_index]
232
+
233
+ def _reconstruct_node(self, nid: str) -> dict[str, Any]:
234
+ """Recursively reconstruct JSON object for a node.
235
+
236
+ Args:
237
+ nid: Node ID to reconstruct.
238
+
239
+ Returns:
240
+ Dictionary representing the node in PHP-Parser JSON format.
241
+ """
242
+ node = self.node(nid)
243
+ props = node.all_properties
244
+ result: dict[str, Any] = {"nodeType": node.node_type}
245
+
246
+ attributes = self._extract_attributes(props)
247
+ if attributes:
248
+ result["attributes"] = attributes
249
+
250
+ self._add_non_attribute_props(result, props)
251
+ self._reconstruct_child_fields(result, nid)
252
+ self._add_default_attrs(result, node.node_type)
253
+
254
+ return result
255
+
256
+ def _extract_attributes(self, props: dict[str, Any]) -> dict[str, Any]:
257
+ """Extract metadata attributes from node properties.
258
+
259
+ Returns dict with position and comment metadata.
260
+ """
261
+ attr_keys = {
262
+ "startLine",
263
+ "endLine",
264
+ "startFilePos",
265
+ "endFilePos",
266
+ "startTokenPos",
267
+ "endTokenPos",
268
+ "kind",
269
+ "comments",
270
+ }
271
+ return {k: v for k, v in props.items() if k in attr_keys}
272
+
273
+ def _add_non_attribute_props(
274
+ self, result: dict[str, Any], props: dict[str, Any]
275
+ ) -> None:
276
+ """Add non-attribute, non-nodeType properties to result."""
277
+ attr_keys = {
278
+ "nodeType",
279
+ "startLine",
280
+ "endLine",
281
+ "startFilePos",
282
+ "endFilePos",
283
+ "startTokenPos",
284
+ "endTokenPos",
285
+ "kind",
286
+ "comments",
287
+ }
288
+ for key, value in props.items():
289
+ if key not in attr_keys:
290
+ result[key] = value
291
+
292
+ def _reconstruct_child_fields(self, result: dict[str, Any], nid: str) -> None:
293
+ """Reconstruct and add child fields to result.
294
+
295
+ Child nodes are fetched via succ() and indexed by edge properties.
296
+ """
297
+ node = self.node(nid)
298
+ child_fields: dict[str, dict[int, Any]] = {}
299
+
300
+ for child in self.succ(node):
301
+ try:
302
+ edge = self.edge(nid, child.id, "PARENT_OF")
303
+ except KeyError:
304
+ continue
305
+
306
+ field_name = edge.get("field")
307
+ if field_name is None:
308
+ continue
309
+
310
+ child_json = self._reconstruct_node(child.id)
311
+ index = edge.get("index")
312
+
313
+ if field_name not in child_fields:
314
+ child_fields[field_name] = {}
315
+
316
+ if index is not None:
317
+ child_fields[field_name][index] = child_json
318
+ else:
319
+ result[field_name] = child_json
320
+
321
+ for field_name, indexed_children in child_fields.items():
322
+ if indexed_children:
323
+ max_index = max(indexed_children.keys())
324
+ array = [None] * (max_index + 1)
325
+ for idx, child in indexed_children.items():
326
+ array[idx] = child
327
+ result[field_name] = array
328
+
329
+ def _add_default_attrs(self, result: dict[str, Any], node_type: str) -> None:
330
+ """Add default attrGroups if not already present.
331
+
332
+ PHP-Parser expects attrGroups on certain node types.
333
+ """
334
+ if "attrGroups" in result:
335
+ return
336
+
337
+ if any(
338
+ node_type.startswith(prefix)
339
+ for prefix in ("Stmt_", "Expr_Closure", "Expr_ArrowFunction")
340
+ ) or node_type == "Param":
341
+ result["attrGroups"] = []
@@ -6,14 +6,14 @@ from cpg2py import AbcEdgeQuerier, Storage
6
6
 
7
7
 
8
8
  class Edge(AbcEdgeQuerier):
9
- """Represents an edge between AST nodes.
9
+ """Represents an edge between AST nodes with generic property access.
10
10
 
11
- Extends cpg2py's AbcEdgeQuerier to represent parent-child relationships
12
- in the PHP AST. Edges store the relationship type and optional metadata
13
- like field names and array indices for ordered children.
14
-
15
- Note: PHP-Parser doesn't have explicit Edge objects. This is our abstraction
16
- for representing the graph structure in cpg2py Storage.
11
+ Extends cpg2py's AbcEdgeQuerier. Edge type is fixed (e.g. "PARENT_OF");
12
+ other data is stored as arbitrary properties via Storage. For PARENT_OF
13
+ edges in our PHP AST mapping, we use properties "field" and "index" to
14
+ represent the PHP-Parser subnode key and array position—access them via
15
+ edge.get("field"), edge.get("index") or edge["field"], edge["index"];
16
+ they are not special-cased on this class.
17
17
 
18
18
  Attributes:
19
19
  _storage: cpg2py Storage instance containing edge data.
@@ -39,45 +39,11 @@ class Edge(AbcEdgeQuerier):
39
39
 
40
40
  @property
41
41
  def type(self) -> str:
42
- """Return the edge type.
43
-
44
- Returns:
45
- Edge type string (e.g., "PARENT_OF").
46
- """
42
+ """Return the edge type (e.g. "PARENT_OF")."""
47
43
  return self._edge_id[2]
48
44
 
49
45
  @property
50
- def field(self) -> str | None:
51
- """Return the field name for this edge.
52
-
53
- The field name indicates which subnode property this edge represents
54
- (e.g., "name", "params", "stmts").
55
-
56
- Returns:
57
- Field name string or None.
58
- """
59
- props = self._storage.get_edge_props(self._edge_id)
60
- if props is None:
61
- return None
62
- return props.get("field")
63
-
64
- @property
65
- def index(self) -> int | None:
66
- """Return the array index for ordered child relationships.
67
-
68
- For edges representing array elements in PHP-Parser JSON, this property
69
- contains the index position to maintain ordering.
70
-
71
- Returns:
72
- Integer index or None if not an array element.
73
- """
74
- props = self._storage.get_edge_props(self._edge_id)
75
- if props is None:
76
- return None
77
- return props.get("index")
78
-
79
- @property
80
- def all_properties(self) -> dict:
46
+ def all_properties(self) -> dict[str, Any]:
81
47
  """Return all edge properties.
82
48
 
83
49
  Returns:
@@ -21,6 +21,18 @@ class ParseError(Exception):
21
21
  self.line = line
22
22
 
23
23
 
24
+ class NodeNotInFileError(Exception):
25
+ """Raised when a node has no containing file (e.g. project node or orphan).
26
+
27
+ Use when get_file(node_id) is called and the node is not under any file node.
28
+ """
29
+
30
+ def __init__(self, node_id: str, message: str | None = None) -> None:
31
+ self.node_id = node_id
32
+ self.message = message or f"Node {node_id!r} is not under any file node."
33
+ super().__init__(self.message)
34
+
35
+
24
36
  class RunnerError(Exception):
25
37
  """Raised when PHP process execution fails.
26
38