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.
@@ -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")