php-parser-py 0.1.1__py3-none-any.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.
@@ -0,0 +1,207 @@
1
+ """Parser class for PHP code parsing."""
2
+
3
+ import hashlib
4
+ import logging
5
+ from pathlib import Path
6
+ from typing import Any, Optional
7
+
8
+ from cpg2py import Storage
9
+
10
+ from php_parser_py._ast import AST
11
+ from php_parser_py._runner import Runner
12
+ from php_parser_py.exceptions import ParseError, RunnerError
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class Parser:
18
+ """Parses PHP source code using PHP-Parser.
19
+
20
+ Invokes PHP-Parser to parse PHP code and converts the resulting JSON
21
+ to cpg2py Storage format for graph-based analysis.
22
+
23
+ Attributes:
24
+ _runner: Runner instance for PHP-Parser invocation.
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ php_binary_path: Optional[Path] = None,
30
+ php_binary_url: Optional[str] = None,
31
+ ) -> None:
32
+ """Initialize Parser with Runner.
33
+
34
+ Args:
35
+ php_binary_path: Optional path to local PHP binary.
36
+ php_binary_url: Optional URL to download PHP binary from.
37
+ """
38
+ self._runner = Runner(
39
+ php_binary_path=php_binary_path,
40
+ php_binary_url=php_binary_url,
41
+ )
42
+ self._node_counter = 0
43
+
44
+ def parse(self, code: str, prefix: str = "") -> AST:
45
+ """Parse PHP code into an AST.
46
+
47
+ Args:
48
+ code: PHP source code to parse.
49
+ prefix: Optional prefix for node IDs. Useful for distinguishing
50
+ nodes from different files when merging multiple ASTs.
51
+
52
+ Returns:
53
+ AST instance containing parsed code.
54
+
55
+ Raises:
56
+ ParseError: If code has syntax errors.
57
+ RunnerError: If PHP execution fails.
58
+ """
59
+ # Reset node counter for each parse
60
+ self._node_counter = 0
61
+ self._node_id_prefix = prefix
62
+
63
+ try:
64
+ json_data = self._runner.parse(code)
65
+ except RunnerError as e:
66
+ # Check if it's a parse error
67
+ if "Syntax error" in str(e):
68
+ raise ParseError("Syntax error in PHP code", line=1) from e
69
+ raise
70
+
71
+ storage = self._json_to_storage(json_data)
72
+ return AST(storage)
73
+
74
+ def parse_file(self, path: str, prefix: str | None = None) -> AST:
75
+ """Read and parse PHP file.
76
+
77
+ Args:
78
+ path: File path string.
79
+ prefix: Optional prefix for node IDs. If not provided,
80
+ uses a hash of the file path (first 6 chars).
81
+
82
+ Returns:
83
+ AST instance containing parsed code structure.
84
+
85
+ Raises:
86
+ ParseError: If PHP-Parser reports syntax error.
87
+ FileNotFoundError: If file does not exist.
88
+ """
89
+ file_path = Path(path)
90
+ if not file_path.exists():
91
+ raise FileNotFoundError(f"File not found: {path}")
92
+
93
+ # Use file path hash as default prefix if none provided
94
+ if prefix is None:
95
+ # Generate a short hash from the file path
96
+ path_hash = hashlib.md5(str(file_path).encode()).hexdigest()[:6]
97
+ prefix = path_hash
98
+
99
+ code = file_path.read_text(encoding="utf-8")
100
+ return self.parse(code, prefix=prefix)
101
+
102
+ def _json_to_storage(self, json_data: Any) -> Storage:
103
+ """Convert PHP-Parser JSON to cpg2py Storage.
104
+
105
+ Args:
106
+ json_data: JSON data from PHP-Parser.
107
+
108
+ Returns:
109
+ Storage instance containing the AST graph.
110
+ """
111
+ storage = Storage()
112
+
113
+ # Handle both single node and array of nodes
114
+ if isinstance(json_data, list):
115
+ for item in json_data:
116
+ self._process_node(storage, item, None, None, None)
117
+ else:
118
+ self._process_node(storage, json_data, None, None, None)
119
+
120
+ return storage
121
+
122
+ def _process_node(
123
+ self,
124
+ storage: Storage,
125
+ node_data: Any,
126
+ parent_id: str | None,
127
+ field_name: str | None,
128
+ index: int | None,
129
+ ) -> str | None:
130
+ """Process a single node and its children recursively.
131
+
132
+ Args:
133
+ storage: Storage to populate.
134
+ node_data: JSON node data.
135
+ parent_id: Parent node ID if this is a child.
136
+ field_name: Field name in parent if this is a child.
137
+ index: Array index if this is an array element.
138
+
139
+ Returns:
140
+ Node ID if a node was created, None otherwise.
141
+ """
142
+ if node_data is None:
143
+ return None
144
+
145
+ if not isinstance(node_data, dict):
146
+ return None
147
+
148
+ # Only create nodes for objects with nodeType
149
+ if "nodeType" not in node_data:
150
+ return None
151
+
152
+ # Generate unique node ID
153
+ node_id = f"node_{self._node_counter}"
154
+ self._node_counter += 1
155
+
156
+ # Collect node properties
157
+ properties: dict[str, Any] = {}
158
+ child_fields: list[tuple[str, Any]] = []
159
+
160
+ for key, value in node_data.items():
161
+ if key == "attributes":
162
+ # Flatten attributes to node properties
163
+ if isinstance(value, dict):
164
+ properties.update(value)
165
+ elif isinstance(value, dict):
166
+ # Nested object - will create child node
167
+ child_fields.append((key, value))
168
+ elif isinstance(value, list):
169
+ # Check if it's an array of objects or scalars
170
+ if value and isinstance(value[0], dict):
171
+ # Array of objects - create child nodes
172
+ child_fields.append((key, value))
173
+ else:
174
+ # Array of scalars (strings, numbers, etc.) - save as property
175
+ properties[key] = value
176
+ else:
177
+ # Scalar property
178
+ properties[key] = value
179
+
180
+ # Add node to storage (cpg2py API: add_node takes only nid)
181
+ storage.add_node(node_id)
182
+ # Set properties separately
183
+ if properties:
184
+ storage.set_node_props(node_id, properties)
185
+
186
+ # Create edge from parent if applicable
187
+ if parent_id is not None and field_name is not None:
188
+ edge_props = {"field": field_name}
189
+ if index is not None:
190
+ edge_props["index"] = index
191
+ # add_edge takes edge_id as a tuple
192
+ edge_id = (parent_id, node_id, "PARENT_OF")
193
+ storage.add_edge(edge_id)
194
+ if edge_props:
195
+ storage.set_edge_props(edge_id, edge_props)
196
+
197
+ # Process child fields (only object arrays now)
198
+ for field_name, field_value in child_fields:
199
+ if isinstance(field_value, list):
200
+ # Array of child objects
201
+ for idx, item in enumerate(field_value):
202
+ self._process_node(storage, item, node_id, field_name, idx)
203
+ else:
204
+ # Single child object
205
+ self._process_node(storage, field_value, node_id, field_name, None)
206
+
207
+ return node_id
@@ -0,0 +1,52 @@
1
+ """PrettyPrinter class for PHP code generation."""
2
+
3
+ import logging
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ from php_parser_py._ast import AST
8
+ from php_parser_py._runner import Runner
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class PrettyPrinter:
14
+ """Converts AST to PHP code using PHP-Parser's PrettyPrinter.
15
+
16
+ Reconstructs JSON from AST graph and invokes PHP-Parser to generate
17
+ formatted PHP source code.
18
+
19
+ Attributes:
20
+ _runner: Runner instance for PHP-Parser invocation.
21
+ """
22
+
23
+ def __init__(
24
+ self,
25
+ php_binary_path: Optional[Path] = None,
26
+ php_binary_url: Optional[str] = None,
27
+ ) -> None:
28
+ """Initialize PrettyPrinter with Runner.
29
+
30
+ Args:
31
+ php_binary_path: Optional path to local PHP binary.
32
+ php_binary_url: Optional URL to download PHP binary from.
33
+ """
34
+ self._runner = Runner(
35
+ php_binary_path=php_binary_path,
36
+ php_binary_url=php_binary_url,
37
+ )
38
+
39
+ def print(self, ast: AST) -> str:
40
+ """Generate PHP code from AST.
41
+
42
+ Args:
43
+ ast: AST instance to convert to code.
44
+
45
+ Returns:
46
+ Generated PHP source code string.
47
+
48
+ Raises:
49
+ RunnerError: If PHP-Parser execution fails.
50
+ """
51
+ json_str = ast.to_json()
52
+ return self._runner.print(json_str)
@@ -0,0 +1,157 @@
1
+ """
2
+ Resource management for php-parser-py.
3
+
4
+ Handles extraction of bundled PHP-Parser zip file on first import.
5
+ """
6
+
7
+ import hashlib
8
+ import threading
9
+ import zipfile
10
+ from pathlib import Path
11
+ from typing import Optional
12
+
13
+ # Global lock for thread-safe extraction
14
+ _extraction_lock = threading.Lock()
15
+ _extraction_done = False
16
+
17
+
18
+ def get_vendor_path() -> Path:
19
+ """Get the path where PHP-Parser should be extracted."""
20
+ return Path(__file__).parent / "vendor"
21
+
22
+
23
+ def get_resources_path() -> Path:
24
+ """Get the path to the resources directory."""
25
+ # Resources are inside the package directory
26
+ return Path(__file__).parent / "resources"
27
+
28
+
29
+ def get_marker_file() -> Path:
30
+ """Get the path to the extraction marker file."""
31
+ return get_vendor_path() / ".extracted"
32
+
33
+
34
+ def calculate_zip_hash(zip_path: Path) -> str:
35
+ """Calculate SHA256 hash of the zip file."""
36
+ sha256_hash = hashlib.sha256()
37
+ with open(zip_path, "rb") as f:
38
+ for byte_block in iter(lambda: f.read(4096), b""):
39
+ sha256_hash.update(byte_block)
40
+ return sha256_hash.hexdigest()
41
+
42
+
43
+ def is_already_extracted(zip_path: Path) -> bool:
44
+ """
45
+ Check if PHP-Parser has already been extracted.
46
+
47
+ Returns True if the marker file exists and the zip hash matches.
48
+ """
49
+ marker_file = get_marker_file()
50
+ if not marker_file.exists():
51
+ return False
52
+
53
+ # Check if hash matches
54
+ try:
55
+ stored_hash = marker_file.read_text().strip()
56
+ current_hash = calculate_zip_hash(zip_path)
57
+ return stored_hash == current_hash
58
+ except Exception:
59
+ # If we can't read the marker or calculate hash, re-extract
60
+ return False
61
+
62
+
63
+ def extract_php_parser(zip_path: Path, vendor_path: Path) -> None:
64
+ """
65
+ Extract PHP-Parser zip file to vendor directory.
66
+
67
+ Args:
68
+ zip_path: Path to the php-parser zip file
69
+ vendor_path: Path where files should be extracted
70
+ """
71
+ # Create vendor directory if it doesn't exist
72
+ vendor_path.mkdir(parents=True, exist_ok=True)
73
+
74
+ # Extract the zip file
75
+ with zipfile.ZipFile(zip_path, "r") as zip_ref:
76
+ zip_ref.extractall(vendor_path)
77
+
78
+ # Create marker file with zip hash
79
+ zip_hash = calculate_zip_hash(zip_path)
80
+ marker_file = get_marker_file()
81
+ marker_file.write_text(zip_hash)
82
+
83
+
84
+ def ensure_php_parser_extracted() -> Path:
85
+ """
86
+ Ensure PHP-Parser is extracted and ready to use.
87
+
88
+ This function is called on package import. It checks if PHP-Parser
89
+ has already been extracted, and if not, extracts it in a thread-safe manner.
90
+
91
+ Returns:
92
+ Path to the vendor directory containing PHP-Parser
93
+
94
+ Raises:
95
+ FileNotFoundError: If the php-parser zip file is not found
96
+ RuntimeError: If extraction fails
97
+ """
98
+ global _extraction_done
99
+
100
+ # Fast path: already extracted in this process
101
+ if _extraction_done:
102
+ return get_vendor_path()
103
+
104
+ vendor_path = get_vendor_path()
105
+ resources_path = get_resources_path()
106
+
107
+ # Find the php-parser zip file
108
+ zip_files = list(resources_path.glob("php-parser-*.zip"))
109
+ if not zip_files:
110
+ raise FileNotFoundError(
111
+ f"PHP-Parser zip file not found in {resources_path}. "
112
+ f"Expected a file matching pattern 'php-parser-*.zip'"
113
+ )
114
+
115
+ zip_path = zip_files[0]
116
+
117
+ # Check if already extracted (with marker file)
118
+ if is_already_extracted(zip_path):
119
+ _extraction_done = True
120
+ return vendor_path
121
+
122
+ # Thread-safe extraction
123
+ with _extraction_lock:
124
+ # Double-check after acquiring lock (another thread might have extracted)
125
+ if is_already_extracted(zip_path):
126
+ _extraction_done = True
127
+ return vendor_path
128
+
129
+ try:
130
+ # Extract PHP-Parser
131
+ extract_php_parser(zip_path, vendor_path)
132
+ _extraction_done = True
133
+ return vendor_path
134
+
135
+ except Exception as e:
136
+ raise RuntimeError(
137
+ f"Failed to extract PHP-Parser from {zip_path}: {e}"
138
+ ) from e
139
+
140
+
141
+ def get_php_parser_path() -> Optional[Path]:
142
+ """
143
+ Get the path to the extracted PHP-Parser directory.
144
+
145
+ Returns:
146
+ Path to PHP-Parser, or None if not yet extracted
147
+ """
148
+ vendor_path = get_vendor_path()
149
+ if not vendor_path.exists():
150
+ return None
151
+
152
+ # Look for the php-parser directory inside vendor
153
+ php_parser_dirs = list(vendor_path.glob("php-parser-*"))
154
+ if php_parser_dirs:
155
+ return php_parser_dirs[0]
156
+
157
+ return None
@@ -0,0 +1,239 @@
1
+ """Runner class for PHP-Parser invocation."""
2
+
3
+ import json
4
+ import logging
5
+ import subprocess
6
+ from pathlib import Path
7
+ from typing import Any, Optional
8
+
9
+ from static_php_py import PHP
10
+ from static_php_py.exceptions import BinaryNotFoundError, DownloadError
11
+
12
+ from php_parser_py._resources import ensure_php_parser_extracted
13
+ from php_parser_py.exceptions import ParseError, RunnerError
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class Runner:
19
+ """Manages PHP-Parser invocation via PHP binary.
20
+
21
+ Handles execution of PHP scripts for parsing and code generation,
22
+ communicating with PHP-Parser through subprocess stdin/stdout.
23
+
24
+ Attributes:
25
+ _php_binary: Path to PHP binary.
26
+ _vendor_dir: Path to directory containing PHP-Parser PHAR.
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ php_binary_path: Optional[Path] = None,
32
+ php_binary_url: Optional[str] = None,
33
+ ) -> None:
34
+ """Initialize Runner with PHP binary and PHP-Parser paths.
35
+
36
+ Args:
37
+ php_binary_path: Optional path to local PHP binary.
38
+ php_binary_url: Optional URL to download PHP binary from.
39
+
40
+ Raises:
41
+ RunnerError: If PHP binary or PHP-Parser cannot be located.
42
+ """
43
+ self._vendor_dir = ensure_php_parser_extracted()
44
+
45
+ # Determine PHP binary source with priority: custom path > custom URL > built-in
46
+ try:
47
+ if php_binary_path is not None:
48
+ # Use custom local PHP binary
49
+ php = PHP.local(php_binary_path)
50
+ elif php_binary_url is not None:
51
+ # Download PHP binary from custom URL
52
+ php = PHP.remote(php_binary_url)
53
+ else:
54
+ # Use built-in PHP binary from static-php-py
55
+ php = PHP.builtin()
56
+
57
+ self._php_binary = php.path()
58
+
59
+ except BinaryNotFoundError as e:
60
+ raise RunnerError(
61
+ f"PHP binary not found: {e}. "
62
+ "The built-in PHP binary may not be available for your platform. "
63
+ "Please provide a custom PHP binary path or URL.",
64
+ exit_code=1,
65
+ ) from e
66
+ except DownloadError as e:
67
+ raise RunnerError(
68
+ f"Failed to download PHP binary: {e}",
69
+ exit_code=1,
70
+ ) from e
71
+
72
+ if not self._php_binary.exists():
73
+ raise RunnerError(
74
+ f"PHP binary not found at {self._php_binary}", exit_code=1
75
+ )
76
+
77
+ def execute(self, script: str, stdin: str = "") -> str:
78
+ """Execute PHP script with optional stdin.
79
+
80
+ Args:
81
+ script: PHP script code to execute.
82
+ stdin: Optional input to pass to script's stdin.
83
+
84
+ Returns:
85
+ Script's stdout output.
86
+
87
+ Raises:
88
+ RunnerError: If PHP execution fails.
89
+ """
90
+ try:
91
+ result = subprocess.run(
92
+ [str(self._php_binary), "-r", script],
93
+ input=stdin,
94
+ capture_output=True,
95
+ text=True,
96
+ check=False,
97
+ )
98
+
99
+ if result.returncode != 0:
100
+ # Log complete error information
101
+ error_msg = f"PHP execution failed with exit code {result.returncode}"
102
+ if result.stderr:
103
+ logger.error(f"PHP stderr: {result.stderr}")
104
+ error_msg += f"\nStderr: {result.stderr}"
105
+ if result.stdout:
106
+ logger.error(f"PHP stdout: {result.stdout}")
107
+ error_msg += f"\nStdout: {result.stdout}"
108
+
109
+ raise RunnerError(
110
+ error_msg,
111
+ stderr=result.stderr,
112
+ exit_code=result.returncode,
113
+ )
114
+
115
+ return result.stdout
116
+
117
+ except FileNotFoundError as e:
118
+ error_msg = f"PHP binary not found: {self._php_binary}"
119
+ logger.error(error_msg)
120
+ raise RunnerError(
121
+ error_msg, stderr=str(e), exit_code=1
122
+ ) from e
123
+ except Exception as e:
124
+ error_msg = f"Failed to execute PHP script: {e}"
125
+ logger.error(error_msg)
126
+ # Don't wrap the error if it's already a RunnerError
127
+ if isinstance(e, RunnerError):
128
+ raise
129
+ raise RunnerError(
130
+ error_msg, stderr=str(e), exit_code=1
131
+ ) from e
132
+
133
+ def parse(self, code: str) -> dict[str, Any]:
134
+ """Invoke PHP-Parser parse + JsonSerializer.
135
+
136
+ Args:
137
+ code: PHP source code to parse.
138
+
139
+ Returns:
140
+ Parsed JSON as dict.
141
+
142
+ Raises:
143
+ ParseError: If PHP-Parser reports syntax error.
144
+ RunnerError: If PHP execution fails.
145
+ """
146
+ parse_script = self._build_parse_script()
147
+
148
+ try:
149
+ output = self.execute(parse_script, code)
150
+ result = json.loads(output)
151
+
152
+ # Check for parse errors
153
+ if isinstance(result, dict) and "errors" in result:
154
+ errors = result["errors"]
155
+ if errors:
156
+ first_error = errors[0]
157
+ raise ParseError(
158
+ first_error.get("message", "Unknown parse error"),
159
+ first_error.get("line"),
160
+ )
161
+
162
+ return result
163
+
164
+ except json.JSONDecodeError as e:
165
+ raise RunnerError(
166
+ f"Failed to decode PHP-Parser JSON output: {e}",
167
+ stderr=str(e),
168
+ exit_code=1,
169
+ ) from e
170
+
171
+ def print(self, ast_json: str) -> str:
172
+ """Invoke PHP-Parser JsonDecoder + PrettyPrinter.
173
+
174
+ Args:
175
+ ast_json: AST JSON string from PHP-Parser format.
176
+
177
+ Returns:
178
+ Generated PHP source code.
179
+
180
+ Raises:
181
+ RunnerError: If PHP execution fails.
182
+ """
183
+ print_script = self._build_print_script()
184
+ return self.execute(print_script, ast_json)
185
+
186
+ def _build_parse_script(self) -> str:
187
+ """Build PHP script for parsing."""
188
+ phar_path = self._vendor_dir / "php-parser.phar"
189
+ return f"""
190
+ error_reporting(E_ALL & ~E_DEPRECATED);
191
+ require_once 'phar://{phar_path}/vendor/autoload.php';
192
+
193
+ use PhpParser\\ParserFactory;
194
+ use PhpParser\\ErrorHandler\\Collecting;
195
+
196
+ $code = file_get_contents('php://stdin');
197
+ $errorHandler = new Collecting();
198
+ $parser = (new ParserFactory())->createForNewestSupportedVersion();
199
+
200
+ try {{
201
+ $stmts = $parser->parse($code, $errorHandler);
202
+ if ($errorHandler->hasErrors()) {{
203
+ $errors = array_map(fn($e) => [
204
+ 'message' => $e->getMessage(),
205
+ 'line' => $e->getStartLine()
206
+ ], $errorHandler->getErrors());
207
+ echo json_encode(['errors' => $errors]);
208
+ exit(1);
209
+ }}
210
+ // PHP-Parser nodes implement JsonSerializable, so we can encode them directly
211
+ echo json_encode($stmts);
212
+ }} catch (Exception $e) {{
213
+ echo json_encode(['error' => $e->getMessage()]);
214
+ exit(1);
215
+ }}
216
+ """
217
+
218
+ def _build_print_script(self) -> str:
219
+ """Build PHP script for code generation."""
220
+ phar_path = self._vendor_dir / "php-parser.phar"
221
+ return f"""
222
+ error_reporting(E_ALL & ~E_DEPRECATED);
223
+ require_once 'phar://{phar_path}/vendor/autoload.php';
224
+
225
+ use PhpParser\\JsonDecoder;
226
+ use PhpParser\\PrettyPrinter\\Standard;
227
+
228
+ $json = file_get_contents('php://stdin');
229
+
230
+ try {{
231
+ $decoder = new JsonDecoder();
232
+ $stmts = $decoder->decode($json);
233
+ $printer = new Standard();
234
+ echo $printer->prettyPrintFile($stmts);
235
+ }} catch (Exception $e) {{
236
+ echo json_encode(['error' => $e->getMessage()]);
237
+ exit(1);
238
+ }}
239
+ """
@@ -0,0 +1,44 @@
1
+ """Exception classes for php-parser-py."""
2
+
3
+
4
+ class ParseError(Exception):
5
+ """Raised when PHP-Parser reports a syntax error.
6
+
7
+ Attributes:
8
+ message: Error message from PHP-Parser.
9
+ line: Line number where the error occurred, if available.
10
+ """
11
+
12
+ def __init__(self, message: str, line: int | None = None) -> None:
13
+ """Initialize ParseError.
14
+
15
+ Args:
16
+ message: Error message from PHP-Parser.
17
+ line: Line number where the error occurred, if available.
18
+ """
19
+ super().__init__(message)
20
+ self.message = message
21
+ self.line = line
22
+
23
+
24
+ class RunnerError(Exception):
25
+ """Raised when PHP process execution fails.
26
+
27
+ Attributes:
28
+ message: Error message describing the failure.
29
+ stderr: Standard error output from the PHP process.
30
+ exit_code: Exit code returned by the PHP process.
31
+ """
32
+
33
+ def __init__(self, message: str, stderr: str = "", exit_code: int = 1) -> None:
34
+ """Initialize RunnerError.
35
+
36
+ Args:
37
+ message: Error message describing the failure.
38
+ stderr: Standard error output from the PHP process.
39
+ exit_code: Exit code returned by the PHP process.
40
+ """
41
+ super().__init__(message)
42
+ self.message = message
43
+ self.stderr = stderr
44
+ self.exit_code = exit_code
@@ -0,0 +1 @@
1
+ 343eb3b32948b6de28c0ca109eafb789dfd270c01711f5721f3cd18b4c0ab614