openqml 0.3.0a8__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.

Potentially problematic release.


This version of openqml might be problematic. Click here for more details.

Files changed (54) hide show
  1. openqml/__init__.py +161 -0
  2. openqml/__main__.py +3 -0
  3. openqml/_native_verification.py +260 -0
  4. openqml/_qasm.py +176 -0
  5. openqml/_scheduling.py +261 -0
  6. openqml/_serialization.py +124 -0
  7. openqml/_validation.py +124 -0
  8. openqml/adapters/__init__.py +1 -0
  9. openqml/adapters/_cirq_control.py +217 -0
  10. openqml/adapters/_common.py +106 -0
  11. openqml/adapters/cirq.py +406 -0
  12. openqml/adapters/openqasm.py +552 -0
  13. openqml/adapters/pennylane.py +242 -0
  14. openqml/adapters/qiskit.py +519 -0
  15. openqml/adjoint.py +484 -0
  16. openqml/aer.py +290 -0
  17. openqml/backends.py +266 -0
  18. openqml/batch.py +388 -0
  19. openqml/cli.py +169 -0
  20. openqml/compiler.py +433 -0
  21. openqml/conversion.py +692 -0
  22. openqml/density.py +503 -0
  23. openqml/errors.py +46 -0
  24. openqml/execution.py +765 -0
  25. openqml/frontends.py +102 -0
  26. openqml/gradients.py +226 -0
  27. openqml/interfaces/__init__.py +1 -0
  28. openqml/interfaces/_jax_ad.py +248 -0
  29. openqml/interfaces/_spec.py +411 -0
  30. openqml/interfaces/_torch_ad.py +140 -0
  31. openqml/interfaces/flax.py +154 -0
  32. openqml/interfaces/jax.py +308 -0
  33. openqml/interfaces/torch.py +403 -0
  34. openqml/ir.py +697 -0
  35. openqml/layers.py +516 -0
  36. openqml/measurement.py +255 -0
  37. openqml/observables.py +35 -0
  38. openqml/optimization.py +237 -0
  39. openqml/parameters.py +362 -0
  40. openqml/pauli.py +510 -0
  41. openqml/planning.py +232 -0
  42. openqml/program.py +225 -0
  43. openqml/providers.py +291 -0
  44. openqml/py.typed +0 -0
  45. openqml/readout.py +160 -0
  46. openqml/simulator.py +787 -0
  47. openqml/templates.py +56 -0
  48. openqml/verification.py +382 -0
  49. openqml-0.3.0a8.dist-info/METADATA +116 -0
  50. openqml-0.3.0a8.dist-info/RECORD +54 -0
  51. openqml-0.3.0a8.dist-info/WHEEL +5 -0
  52. openqml-0.3.0a8.dist-info/entry_points.txt +2 -0
  53. openqml-0.3.0a8.dist-info/licenses/LICENSE +202 -0
  54. openqml-0.3.0a8.dist-info/top_level.txt +1 -0
openqml/__init__.py ADDED
@@ -0,0 +1,161 @@
1
+ """Hybrid quantum learning and explicit circuit interoperability.
2
+
3
+ Import once as ``import openqml as oq``. Optional integrations are available
4
+ through ``oq.torch``, ``oq.jax`` and ``oq.flax`` and load only when accessed.
5
+ """
6
+
7
+ from importlib import import_module as _import_module
8
+
9
+ from ._scheduling import ExecutionBatch, ReadoutRoute
10
+ from .adjoint import AdjointPlan, adjoint_jvp, adjoint_vjp
11
+ from .aer import AerBackend
12
+ from .backends import (
13
+ BackendCapabilities,
14
+ DensityMatrixBackend,
15
+ ExecutionOptions,
16
+ ExpectationBackend,
17
+ SamplingBackend,
18
+ StatevectorBackend,
19
+ get_backend,
20
+ list_backends,
21
+ register_backend,
22
+ unregister_backend,
23
+ )
24
+ from .batch import execute_batch, expectations_batch
25
+ from .compiler import CircuitStats, circuit_stats, decompose, inverse, simplify
26
+ from .conversion import ConversionPlan, ConversionResult, convert, diagnose, plan_conversion
27
+ from .density import density_matrix
28
+ from .errors import (
29
+ BackendError,
30
+ ConversionError,
31
+ Diagnostic,
32
+ MissingDependencyError,
33
+ OpenQMLError,
34
+ ResourceError,
35
+ ValidationError,
36
+ )
37
+ from .execution import ExecutionGroup, ExpectationCheck, ExpectationPlan
38
+ from .frontends import FrameworkAdapter, list_formats, register_adapter, unregister_adapter
39
+ from .gradients import (
40
+ parameter_shift,
41
+ parameter_shift_jacobian,
42
+ parameter_shift_jvp,
43
+ parameter_shift_vjp,
44
+ )
45
+ from .ir import Circuit, ClassicalCondition, Operation
46
+ from .layers import LayerCheckpoint, LayerSpec
47
+ from .measurement import EstimateResult, MeasurementPlan, ShotBackend, estimate, measurement_plan
48
+ from .observables import Hamiltonian, PauliString, z_observables
49
+ from .optimization import OptimizationResult, optimize
50
+ from .parameters import Expression, Parameter
51
+ from .pauli import PauliPlan
52
+ from .planning import DensityPlan, plan_density
53
+ from .program import QuantumProgram, as_circuit
54
+ from .providers import CirqDensityBackend, QiskitEstimatorBackend, QiskitSamplerBackend
55
+ from .readout import ReadoutPlan, plan_readout
56
+ from .simulator import ExecutionResult, execute, expectation, expectations, sample, statevector
57
+ from .templates import data_reuploading, hardware_efficient
58
+ from .verification import ConversionCheck
59
+
60
+ _INTERFACE_NAMES = frozenset({"torch", "jax", "flax"})
61
+
62
+
63
+ def __getattr__(name):
64
+ if name in _INTERFACE_NAMES:
65
+ module = _import_module(f".interfaces.{name}", __name__)
66
+ globals()[name] = module
67
+ return module
68
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
69
+
70
+
71
+ def __dir__():
72
+ return sorted(set(globals()) | _INTERFACE_NAMES)
73
+
74
+
75
+ __version__ = "0.3.0a8"
76
+
77
+ __all__ = [
78
+ "ReadoutRoute",
79
+ "ConversionCheck",
80
+ "ExecutionBatch",
81
+ "ExpectationCheck",
82
+ "ExpectationPlan",
83
+ "ExecutionGroup",
84
+ "PauliPlan",
85
+ "ReadoutPlan",
86
+ "plan_readout",
87
+ "AdjointPlan",
88
+ "adjoint_vjp",
89
+ "adjoint_jvp",
90
+ "ConversionPlan",
91
+ "plan_conversion",
92
+ "data_reuploading",
93
+ "LayerCheckpoint",
94
+ "OptimizationResult",
95
+ "optimize",
96
+ "DensityPlan",
97
+ "plan_density",
98
+ "Circuit",
99
+ "ClassicalCondition",
100
+ "density_matrix",
101
+ "DensityMatrixBackend",
102
+ "Operation",
103
+ "Parameter",
104
+ "Expression",
105
+ "PauliString",
106
+ "Hamiltonian",
107
+ "statevector",
108
+ "expectation",
109
+ "expectations",
110
+ "execute",
111
+ "sample",
112
+ "parameter_shift",
113
+ "convert",
114
+ "diagnose",
115
+ "hardware_efficient",
116
+ "z_observables",
117
+ "LayerSpec",
118
+ "ConversionResult",
119
+ "ExecutionResult",
120
+ "Diagnostic",
121
+ "OpenQMLError",
122
+ "ValidationError",
123
+ "ConversionError",
124
+ "ResourceError",
125
+ "MissingDependencyError",
126
+ "BackendError",
127
+ "BackendCapabilities",
128
+ "ExecutionOptions",
129
+ "StatevectorBackend",
130
+ "ExpectationBackend",
131
+ "SamplingBackend",
132
+ "register_backend",
133
+ "unregister_backend",
134
+ "list_backends",
135
+ "get_backend",
136
+ "CircuitStats",
137
+ "circuit_stats",
138
+ "decompose",
139
+ "inverse",
140
+ "simplify",
141
+ "QuantumProgram",
142
+ "as_circuit",
143
+ "execute_batch",
144
+ "expectations_batch",
145
+ "parameter_shift_jacobian",
146
+ "parameter_shift_vjp",
147
+ "parameter_shift_jvp",
148
+ "estimate",
149
+ "measurement_plan",
150
+ "MeasurementPlan",
151
+ "EstimateResult",
152
+ "ShotBackend",
153
+ "AerBackend",
154
+ "CirqDensityBackend",
155
+ "QiskitEstimatorBackend",
156
+ "QiskitSamplerBackend",
157
+ "FrameworkAdapter",
158
+ "list_formats",
159
+ "register_adapter",
160
+ "unregister_adapter",
161
+ ]
openqml/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,260 @@
1
+ """Independent execution of exported artifacts on complete spanning inputs.
2
+
3
+ Native SDK state evolution is deliberately separate from IR reimport. Channels
4
+ use physical pure-state probes spanning the operator space, so SDKs need not
5
+ accept nonphysical matrix units. No sampling or phase alignment is used.
6
+
7
+ SDK entry points:
8
+ https://quantum.cloud.ibm.com/docs/en/api/qiskit/qiskit.quantum_info.DensityMatrix
9
+ https://quantumai.google/reference/python/cirq/DensityMatrixSimulator
10
+ https://docs.pennylane.ai/en/stable/code/api/pennylane.StatePrep.html
11
+ """
12
+
13
+ from itertools import islice
14
+
15
+ import numpy as np
16
+
17
+ from .errors import (
18
+ ConversionError,
19
+ MissingDependencyError,
20
+ ResourceError,
21
+ ValidationError,
22
+ conversion_error,
23
+ )
24
+
25
+
26
+ def _unsupported(message):
27
+ return conversion_error(
28
+ "NATIVE_VERIFICATION_UNSUPPORTED",
29
+ message,
30
+ hint="Use roundtrip verification for retained measurement records and feedback.",
31
+ )
32
+
33
+
34
+ def _native_scope(circuit, target, source_options):
35
+ if source_options:
36
+ raise ValidationError("source_options apply to roundtrip verification only.")
37
+ if target not in {"qiskit", "cirq", "pennylane", "openqasm2", "openqasm3"}:
38
+ raise _unsupported("Native verification requires a supported SDK or OpenQASM target.")
39
+ if any(op.name in {"measure", "if_else"} for _, op in circuit.walk_operations()):
40
+ raise _unsupported(
41
+ "Native complete-input checks do not support recorded measurement or feedback."
42
+ )
43
+
44
+
45
+ def _probes(width, channel):
46
+ dimension = 1 << width
47
+ for index in range(dimension):
48
+ vector = np.zeros(dimension, dtype=complex)
49
+ vector[index] = 1
50
+ yield vector
51
+ if channel:
52
+ for first in range(dimension):
53
+ for second in range(first + 1, dimension):
54
+ for phase in (1, 1j):
55
+ vector = np.zeros(dimension, dtype=complex)
56
+ vector[first], vector[second] = 1 / np.sqrt(2), phase / np.sqrt(2)
57
+ yield vector
58
+
59
+
60
+ def _profile(arities, width, instrument, max_operations):
61
+ work = 1
62
+ for count, arity in enumerate(arities, 1):
63
+ if count > max_operations:
64
+ raise ResourceError("Native verification exceeds max_operations.")
65
+ if not 0 <= arity <= width:
66
+ raise ValidationError("Native artifact uses an unexpected quantum register.")
67
+ work += 16 * (1 << (2 * arity)) if instrument else 1 << arity
68
+ return work
69
+
70
+
71
+ def _qiskit(value, circuit, instrument, max_operations):
72
+ from qiskit.circuit import Gate
73
+ from qiskit.quantum_info import DensityMatrix, Statevector
74
+
75
+ if (value.num_qubits, value.num_clbits) != (circuit.num_qubits, circuit.num_clbits):
76
+ raise ValidationError("Native artifact changed quantum or classical register width.")
77
+ if value.parameters:
78
+ raise ValidationError("Native artifact contains unbound parameters.")
79
+
80
+ def arities():
81
+ for item in value.data:
82
+ if item.operation.name == "measure" or getattr(item.operation, "blocks", ()):
83
+ raise _unsupported("Native artifact contains recorded measurement or control flow.")
84
+ if item.clbits:
85
+ raise _unsupported("Native artifact contains a classical instruction.")
86
+ if (
87
+ not instrument
88
+ and not isinstance(item.operation, Gate)
89
+ and item.operation.name != "barrier"
90
+ ):
91
+ raise _unsupported("Native artifact is not unitary.")
92
+ yield len(item.qubits)
93
+
94
+ work = _profile(arities(), circuit.num_qubits, instrument, max_operations)
95
+ width = circuit.num_qubits
96
+ permutation = (
97
+ np.arange(1 << width)
98
+ .reshape((2,) * width)
99
+ .transpose(tuple(reversed(range(width))))
100
+ .reshape(-1)
101
+ )
102
+
103
+ def run(probes):
104
+ outputs = []
105
+ for vector in probes:
106
+ state = (DensityMatrix if instrument else Statevector)(vector[permutation]).evolve(
107
+ value
108
+ )
109
+ output = np.asarray(state.data)
110
+ outputs.append(
111
+ output[np.ix_(permutation, permutation)].reshape(-1)
112
+ if instrument
113
+ else output[permutation]
114
+ )
115
+ return np.stack(outputs)
116
+
117
+ return run, work
118
+
119
+
120
+ def _cirq(value, circuit, instrument, max_operations):
121
+ import cirq
122
+
123
+ order = tuple(cirq.LineQubit.range(circuit.num_qubits))
124
+ if value.all_qubits() != frozenset(order):
125
+ raise ValidationError("Native artifact changed canonical quantum wires.")
126
+
127
+ def arities():
128
+ for op in value.all_operations():
129
+ if (
130
+ isinstance(op, cirq.CircuitOperation)
131
+ or cirq.is_measurement(op)
132
+ or op.classical_controls
133
+ ):
134
+ raise _unsupported("Native artifact contains measurement or nested control flow.")
135
+ if cirq.is_parameterized(op):
136
+ raise ValidationError("Native artifact contains unbound parameters.")
137
+ if not instrument and not cirq.has_unitary(op):
138
+ raise _unsupported(
139
+ "Native artifact is not unitary; stochastic verification is refused."
140
+ )
141
+ yield len(op.qubits)
142
+
143
+ work = _profile(arities(), circuit.num_qubits, instrument, max_operations)
144
+ simulator = (
145
+ cirq.DensityMatrixSimulator(dtype=np.complex128, split_untangled_states=False)
146
+ if instrument
147
+ else cirq.Simulator(dtype=np.complex128)
148
+ )
149
+
150
+ def run(probes):
151
+ outputs = []
152
+ for vector in probes:
153
+ result = simulator.simulate(value, initial_state=vector, qubit_order=order)
154
+ output = result.final_density_matrix if instrument else result.final_state_vector
155
+ outputs.append(np.asarray(output).reshape(-1))
156
+ return np.stack(outputs)
157
+
158
+ return run, work
159
+
160
+
161
+ def _pennylane(value, circuit, instrument, max_operations):
162
+ import pennylane as qml
163
+
164
+ if value.measurements:
165
+ raise _unsupported("Native PennyLane verification requires an operation-only tape.")
166
+ order = tuple(range(circuit.num_qubits))
167
+
168
+ def arities():
169
+ for op in value.operations:
170
+ if not set(op.wires).issubset(order):
171
+ raise ValidationError("Native artifact changed canonical quantum wires.")
172
+ if not op.has_matrix and not isinstance(op, qml.operation.Channel):
173
+ raise _unsupported("Native tape contains an unsupported dynamic operation.")
174
+ if not instrument and isinstance(op, qml.operation.Channel):
175
+ raise _unsupported("Native artifact is not unitary.")
176
+ yield len(op.wires)
177
+
178
+ work = _profile(arities(), circuit.num_qubits, instrument, max_operations)
179
+ device = qml.device("default.mixed" if instrument else "default.qubit", wires=order)
180
+
181
+ @qml.qnode(device, interface=None, diff_method=None)
182
+ def program(vector):
183
+ qml.StatePrep(vector, wires=order)
184
+ for op in value.operations:
185
+ qml.apply(op)
186
+ return qml.density_matrix(wires=order) if instrument else qml.state()
187
+
188
+ def run(probes):
189
+ return np.stack([np.asarray(program(vector)).reshape(-1) for vector in probes])
190
+
191
+ return run, work
192
+
193
+
194
+ def _verify_native(
195
+ plan, value, instrument, records, peak, size, work, max_work, budget, chunk, atol, rtol
196
+ ):
197
+ from .verification import _basis_capacity, _Comparison, _evolve_basis
198
+
199
+ circuit, target = plan.circuit, plan.target_format
200
+ backend = "qiskit" if target.startswith("openqasm") else target
201
+ try:
202
+ if target.startswith("openqasm"):
203
+ from .program import QuantumProgram
204
+
205
+ value = QuantumProgram.from_qasm(
206
+ value,
207
+ version=int(target[-1]),
208
+ max_operations=plan.max_operations,
209
+ max_depth=plan.max_depth,
210
+ max_register_bits=circuit.num_qubits + circuit.num_clbits,
211
+ max_matrix_qubits=circuit.num_qubits,
212
+ ).native
213
+ runner, native_work = {"qiskit": _qiskit, "cirq": _cirq, "pennylane": _pennylane}[backend](
214
+ value, circuit, instrument, plan.max_operations
215
+ )
216
+ except ImportError as exc:
217
+ extra = "qasm" if target.startswith("openqasm") else backend
218
+ raise MissingDependencyError(f"Install openqml[{extra}] for native verification.") from exc
219
+ except (ConversionError, ResourceError, ValidationError):
220
+ raise
221
+ except Exception as exc:
222
+ raise conversion_error(
223
+ "NATIVE_VERIFICATION_PREPARATION", f"{backend} artifact preparation failed: {exc}"
224
+ ) from exc
225
+ if work + size * size * native_work > max_work:
226
+ raise ResourceError("Native verification exceeds max_work.")
227
+ capacity = _basis_capacity((circuit,), (peak, 1), size, budget)
228
+ chunk, probes, comparison = (
229
+ min(chunk, capacity, size),
230
+ _probes(circuit.num_qubits, instrument),
231
+ _Comparison(),
232
+ )
233
+ for start in range(0, size, chunk):
234
+ vectors = np.stack(tuple(islice(probes, min(chunk, size - start))))
235
+ basis = (
236
+ (vectors[..., :, None] * vectors[..., None, :].conj()).reshape(len(vectors), -1)
237
+ if instrument
238
+ else vectors
239
+ )
240
+ expected = _evolve_basis(circuit, basis, instrument, records)
241
+ try:
242
+ actual = {frozenset(): runner(vectors)}
243
+ except (ConversionError, ResourceError, ValidationError, MissingDependencyError):
244
+ raise
245
+ except Exception as exc:
246
+ raise conversion_error(
247
+ "NATIVE_VERIFICATION_EXECUTION", f"{backend} execution failed: {exc}"
248
+ ) from exc
249
+ comparison.add(expected, actual, start, atol, rtol)
250
+ del vectors, basis, expected, actual
251
+ return comparison.result(
252
+ circuit,
253
+ target,
254
+ "quantum_channel" if instrument else "unitary_including_global_phase",
255
+ size,
256
+ atol,
257
+ rtol,
258
+ "native",
259
+ backend,
260
+ )
openqml/_qasm.py ADDED
@@ -0,0 +1,176 @@
1
+ """Frontend-independent source limits and comment handling."""
2
+
3
+ import re
4
+
5
+ from .errors import conversion_error
6
+ from .ir import integer
7
+
8
+
9
+ def clean_source(text, max_source_bytes=2_000_000):
10
+ integer(max_source_bytes, "max_source_bytes", 1)
11
+ if (
12
+ not isinstance(text, str)
13
+ or len(text) > max_source_bytes
14
+ or len(text.encode("utf-8")) > max_source_bytes
15
+ ):
16
+ raise conversion_error("SOURCE_LIMIT", "OpenQASM source exceeds the configured byte limit.")
17
+ # Remove comments without changing strings, line numbers or character offsets.
18
+ output, index = [], 0
19
+ while index < len(text):
20
+ if text[index] == '"':
21
+ start = index
22
+ index += 1
23
+ while index < len(text):
24
+ if text[index] == "\\":
25
+ index += 2
26
+ elif text[index] == '"':
27
+ index += 1
28
+ break
29
+ else:
30
+ index += 1
31
+ else:
32
+ raise conversion_error("SYNTAX", "Unterminated source string.")
33
+ output.append(text[start:index])
34
+ elif text.startswith("//", index):
35
+ end = text.find("\n", index)
36
+ end = len(text) if end < 0 else end
37
+ output.append(" " * (end - index))
38
+ index = end
39
+ elif text.startswith("/*", index):
40
+ end = text.find("*/", index + 2)
41
+ if end < 0:
42
+ raise conversion_error("SYNTAX", "Unterminated block comment.")
43
+ end += 2
44
+ output.append("".join("\n" if c == "\n" else " " for c in text[index:end]))
45
+ index = end
46
+ else:
47
+ output.append(text[index])
48
+ index += 1
49
+ return "".join(output)
50
+
51
+
52
+ def validate_header(text, version=None):
53
+ header = re.match(r"\s*OPENQASM\s+([23])(?:\.0)?\s*;", text)
54
+ if not header:
55
+ raise conversion_error("QASM_VERSION", "Expected an OpenQASM 2.0 or 3.0 header.")
56
+ actual = int(header.group(1))
57
+ if version is not None and (type(version) is not int or version != actual):
58
+ raise conversion_error(
59
+ "QASM_VERSION", "Source header differs from requested source format."
60
+ )
61
+ return actual
62
+
63
+
64
+ def check_literal_registers(text, max_register_bits):
65
+ """Reject oversized literal declarations before an SDK constructs their storage.
66
+
67
+ SDK-resolved constant/expression sizes also receive a post-parse width check.
68
+ This preflight does not claim a process-level SDK parsing memory limit.
69
+ """
70
+ integer(max_register_bits, "max_register_bits", 1)
71
+ code = re.sub(r'"(?:\\.|[^"\\])*"', '""', text)
72
+ declarations = re.finditer(
73
+ r"\b(?:qreg|creg)\s+[A-Za-z_]\w*\s*\[\s*(\d+)\s*\]"
74
+ r"|\b(?:qubit|bit)\s*(?:\[\s*(\d+)\s*\])?\s+[A-Za-z_]\w*",
75
+ code,
76
+ )
77
+ total = 0
78
+ for declaration in declarations:
79
+ total += int(declaration.group(1) or declaration.group(2) or "1")
80
+ if total > max_register_bits:
81
+ raise conversion_error("REGISTER_LIMIT", "Register size exceeds the configured limit.")
82
+
83
+
84
+ def expand_includes(text, include_paths, version, max_source_bytes, max_include_depth):
85
+ """Inline custom files from explicit roots with cumulative read/expansion limits."""
86
+ from pathlib import Path
87
+
88
+ integer(max_include_depth, "max_include_depth")
89
+ if isinstance(include_paths, (str, bytes)):
90
+ raise conversion_error("INCLUDE_PATH", "include_paths must be a sequence of directories.")
91
+ try:
92
+ roots = tuple(Path(p).resolve(strict=True) for p in include_paths)
93
+ except (TypeError, OSError, ValueError, RuntimeError) as exc:
94
+ raise conversion_error(
95
+ "INCLUDE_PATH", "Include roots must be existing directories."
96
+ ) from exc
97
+ if any(not p.is_dir() for p in roots):
98
+ raise conversion_error("INCLUDE_PATH", "Include roots must be directories.")
99
+ remaining = max_source_bytes - len(text.encode("utf-8"))
100
+ directive = re.compile(r'"(?:\\.|[^"\\])*"|\binclude\b(?:\s*"([^"\n]+)"\s*;)?')
101
+ standard = "qelib1.inc" if version == 2 else "stdgates.inc"
102
+
103
+ def expand(code, parent, stack):
104
+ nonlocal remaining
105
+ pieces, end = [], 0
106
+ for match in directive.finditer(code):
107
+ filename = match.group(1)
108
+ if filename is None:
109
+ if not match.group().startswith('"'):
110
+ raise conversion_error("SYNTAX", "Malformed include directive.")
111
+ continue
112
+ if filename == standard:
113
+ continue
114
+ if not roots:
115
+ raise conversion_error(
116
+ "INCLUDE_PATH", "Custom includes require explicit include_paths."
117
+ )
118
+ if len(stack) >= max_include_depth:
119
+ raise conversion_error(
120
+ "INCLUDE_DEPTH_LIMIT", "Custom include nesting exceeds max_include_depth."
121
+ )
122
+ target = Path(filename)
123
+ searches = ((parent,) if parent is not None else ()) + roots
124
+ candidates = (target,) if target.is_absolute() else tuple(p / target for p in searches)
125
+ resolved = None
126
+ for candidate in candidates:
127
+ try:
128
+ candidate = candidate.resolve()
129
+ except (OSError, ValueError, RuntimeError):
130
+ continue
131
+ if any(candidate.is_relative_to(root) for root in roots) and candidate.is_file():
132
+ resolved = candidate
133
+ break
134
+ if resolved is None:
135
+ raise conversion_error(
136
+ "INCLUDE_PATH", "Include file is missing or outside the explicit roots."
137
+ )
138
+ if resolved in stack:
139
+ raise conversion_error("INCLUDE_CYCLE", "Custom include files contain a cycle.")
140
+ try:
141
+ with resolved.open("rb") as stream:
142
+ payload = stream.read(remaining + 1)
143
+ except OSError as exc:
144
+ raise conversion_error(
145
+ "INCLUDE_PATH", "Cannot read the requested include file."
146
+ ) from exc
147
+ if len(payload) > remaining:
148
+ raise conversion_error(
149
+ "SOURCE_LIMIT", "Source and included files exceed max_source_bytes."
150
+ )
151
+ remaining -= len(payload)
152
+ try:
153
+ child = clean_source(payload.decode("utf-8"), max_source_bytes)
154
+ except UnicodeError as exc:
155
+ raise conversion_error("INCLUDE_ENCODING", "Include files must use UTF-8.") from exc
156
+ pieces.extend(
157
+ (
158
+ code[end : match.start()],
159
+ "\n",
160
+ expand(child, resolved.parent, stack + (resolved,)),
161
+ "\n",
162
+ )
163
+ )
164
+ end = match.end()
165
+ pieces.append(code[end:])
166
+ return "".join(pieces)
167
+
168
+ try:
169
+ output = expand(text, None, ())
170
+ except RecursionError as exc:
171
+ raise conversion_error(
172
+ "INCLUDE_DEPTH_LIMIT", "Include nesting exceeds the Python recursion limit."
173
+ ) from exc
174
+ if len(output.encode("utf-8")) > max_source_bytes:
175
+ raise conversion_error("SOURCE_LIMIT", "Expanded source exceeds max_source_bytes.")
176
+ return output