Coverage for /home/runner/work/tket/tket/pytket/pytket/circuit/display/__init__.py: 84%
104 statements
« prev ^ index » next coverage.py v7.6.12, created at 2025-03-14 11:30 +0000
« prev ^ index » next coverage.py v7.6.12, created at 2025-03-14 11:30 +0000
1# Copyright Quantinuum
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
15"""Display a circuit as html."""
17import json
18import os
19import tempfile
20import time
21import uuid
22import webbrowser
23from dataclasses import dataclass, field
24from typing import Any, Literal, cast
26from jinja2 import Environment, FileSystemLoader, PrefixLoader, nodes
27from jinja2.ext import Extension
28from jinja2.parser import Parser
29from jinja2.utils import markupsafe
31from pytket.circuit import Circuit
32from pytket.config import PytketExtConfig
35# js scripts to be loaded must not be parsed as template files.
36class IncludeRawExtension(Extension):
37 tags = {"include_raw"}
39 def parse(self, parser: Parser) -> nodes.Output:
40 lineno = parser.stream.expect("name:include_raw").lineno
41 template = parser.parse_expression()
42 result = self.call_method("_render", [template], lineno=lineno)
43 return nodes.Output([result], lineno=lineno)
45 def _render(self, filename: str) -> markupsafe.Markup:
46 if self.environment.loader is not None: 46 ↛ 50line 46 didn't jump to line 50 because the condition on line 46 was always true
47 return markupsafe.Markup(
48 self.environment.loader.get_source(self.environment, filename)[0]
49 )
50 return markupsafe.Markup("")
53# Set up jinja to access our templates
54dirname = os.path.dirname(__file__)
56# Define the base loaders.
57html_loader = FileSystemLoader(searchpath=os.path.join(dirname, "static"))
58js_loader = FileSystemLoader(searchpath=os.path.join(dirname, "js"))
60loader = PrefixLoader(
61 {
62 "html": html_loader,
63 "js": js_loader,
64 }
65)
67jinja_env = Environment(loader=loader, extensions=[IncludeRawExtension])
69RenderCircuit = dict[str, str | float | dict] | Circuit
70Orientation = Literal["row"] | Literal["column"]
73@dataclass(kw_only=True)
74class RenderOptions:
75 zx_style: bool | None = None # display zx style gates where possible.
76 condense_c_bits: bool | None = None # collapse classical bits into a single wire.
77 recursive: bool | None = None # display nested circuits inline.
78 condensed: bool | None = None # display circuit on one line only.
79 dark_theme: bool | None = None # use dark mode.
80 system_theme: bool | None = None # use the system theme mode (overrides dark mode).
81 transparent_bg: bool | None = None # transparent circuit background.
82 crop_params: bool | None = None # shorten parameter expressions for display.
83 interpret_math: bool | None = (
84 None # try to display parameters and box names as math.
85 )
87 def __post_init__(self) -> None:
88 self.ALLOWED_RENDER_OPTIONS = {
89 "zx_style": "zxStyle",
90 "condense_c_bits": "condenseCBits",
91 "recursive": "recursive",
92 "condensed": "condensed",
93 "dark_theme": "darkTheme",
94 "system_theme": "systemTheme",
95 "transparent_bg": "transparentBg",
96 "crop_params": "cropParams",
97 "interpret_math": "interpretMath",
98 }
100 def get_render_options(
101 self, full: bool = False, _for_js: bool = False
102 ) -> dict[str, bool]:
103 """
104 Get a dict of the current render options.
106 :param full: whether to list all available options, even if not set.
107 :param _for_js: Whether to convert options to js-compatible format,
108 for internal use only.
109 """
110 return {
111 (js_key if _for_js else key): self.__getattribute__(key)
112 for key, js_key in self.ALLOWED_RENDER_OPTIONS.items()
113 if full or self.__getattribute__(key) is not None
114 }
117@dataclass(kw_only=True)
118class CircuitDisplayConfig(PytketExtConfig):
119 ext_dict_key = "circuit_display"
121 # Layout options
122 min_height: str = "400px"
123 min_width: str = "500px"
124 orient: Orientation | None = None
125 render_options: RenderOptions = field(default_factory=RenderOptions)
127 @classmethod
128 def from_extension_dict(cls, ext_dict: dict[str, Any]) -> "CircuitDisplayConfig":
129 min_h = ext_dict.get("min_height")
130 min_w = ext_dict.get("min_width")
131 return CircuitDisplayConfig(
132 min_height=str(min_h) if min_h is not None else "400px",
133 min_width=str(min_w) if min_w is not None else "500px",
134 orient=ext_dict.get("orient"),
135 render_options=RenderOptions(
136 **(ext_dict["render_options"] if "render_options" in ext_dict else {})
137 ),
138 )
141class CircuitRenderer:
142 """Class to manage circuit rendering within a given jinja2 environment."""
144 config: CircuitDisplayConfig
146 def __init__(self, env: Environment, config: CircuitDisplayConfig):
147 self.env = env
148 self.config = config
150 def set_render_options(self, **kwargs: bool | str) -> None:
151 """
152 Set rendering defaults.
154 :param min_height: str, initial height of circuit display.
155 :param min_width: str, initial width of circuit display.
156 :param orient: 'row' | 'column', stacking direction for multi-circuit display.
157 :param zx_style: bool, display zx style gates where possible.
158 :param condense_c_bits: bool, collapse classical bits into a single wire.
159 :param recursive: bool, display nested circuits inline.
160 :param condensed: bool, display circuit on one line only.
161 :param dark_theme: bool, use dark mode.
162 :param system_theme: bool, use the system theme mode.
163 :param transparent_bg: bool, remove the circuit background.
164 :param crop_params: bool, shorten parameter expressions for display.
165 :param interpret_math: bool, try to render params and box names as math.
166 """
167 for key, val in kwargs.items():
168 if key in self.config.render_options.ALLOWED_RENDER_OPTIONS and ( 168 ↛ 172line 168 didn't jump to line 172 because the condition on line 168 was always true
169 isinstance(val, bool) or val is None
170 ):
171 self.config.render_options.__setattr__(key, val)
172 if key in ["min_height", "min_width", "orient"] and isinstance(val, str): 172 ↛ 173line 172 didn't jump to line 173 because the condition on line 172 was never true
173 self.config.__setattr__(key, val)
175 def get_render_options(
176 self, full: bool = False, _for_js: bool = False
177 ) -> dict[str, bool]:
178 """
179 Get a dict of the current render options.
181 :param full: whether to list all available options, even if not set.
182 :param _for_js: Whether to convert options to js-compatible format,
183 for internal use only.
184 """
185 return self.config.render_options.get_render_options(full, _for_js)
187 def save_render_options(self) -> None:
188 """Save the current render options to pytket config."""
189 self.config.update_default_config_file()
191 def render_circuit_as_html(
192 self,
193 circuit: RenderCircuit | list[RenderCircuit],
194 jupyter: bool = False,
195 orient: Orientation | None = None,
196 ) -> str | None:
197 """
198 Render a circuit as HTML for inline display.
200 :param circuit: the circuit(s) to render.
201 :param jupyter: set to true to render generated HTML in cell output.
202 :param orient: the direction in which to stack circuits if multiple are present.
203 One of 'row' or 'column'.
204 """
205 circuit_dict: dict | list[dict]
206 if isinstance(circuit, list):
207 circuit_dict = [
208 (
209 circ.to_dict()
210 if isinstance(circ, Circuit)
211 else Circuit.from_dict(circ).to_dict()
212 )
213 for circ in circuit
214 ]
215 else:
216 circuit_dict = (
217 circuit.to_dict()
218 if isinstance(circuit, Circuit)
219 else Circuit.from_dict(circuit).to_dict()
220 )
222 uid = uuid.uuid4()
223 html_template = self.env.get_template("html/circuit.html")
224 html = html_template.render(
225 {
226 "circuit_json": json.dumps(circuit_dict),
227 "uid": uid,
228 "jupyter": jupyter,
229 "display_options": json.dumps(self.get_render_options(_for_js=True)),
230 "min_height": self.config.min_height,
231 "min_width": self.config.min_width,
232 "view_format": orient or self.config.orient,
233 }
234 )
235 if jupyter: 235 ↛ 238line 235 didn't jump to line 238 because the condition on line 235 was never true
236 # If we are in a notebook, we can tell jupyter to display the html.
237 # We don't import at the top in case we are not in a notebook environment.
238 from IPython.display import (
239 HTML,
240 display,
241 ) # pylint: disable=C0415
243 display(HTML(html))
244 return None
245 return html
247 def render_circuit_jupyter(
248 self,
249 circuit: RenderCircuit | list[RenderCircuit],
250 orient: Orientation | None = None,
251 ) -> None:
252 """Render a circuit as jupyter cell output.
254 :param circuit: the circuit(s) to render.
255 :param orient: the direction in which to stack circuits if multiple are present.
256 """
257 self.render_circuit_as_html(circuit, True, orient=orient)
259 def view_browser(
260 self,
261 circuit: RenderCircuit | list[RenderCircuit],
262 browser_new: int = 2,
263 sleep: int = 5,
264 ) -> None:
265 """Write circuit render html to a tempfile and open in browser.
267 Waits for some time for browser to load then deletes tempfile.
269 :param circuit: the Circuit(s) or serialized Circuit(s) to render.
270 Either a single circuit or a list of circuits to compare.
271 :param browser_new: ``new`` parameter to ``webbrowser.open``, default 2.
272 :param sleep: Number of seconds to sleep before deleting file, default 5.
274 """
276 fp = tempfile.NamedTemporaryFile(
277 mode="w", suffix=".html", delete=False, dir=os.getcwd()
278 )
279 try:
280 fp.write(cast(str, self.render_circuit_as_html(circuit)))
281 fp.close()
283 webbrowser.open("file://" + os.path.realpath(fp.name), new=browser_new)
285 # give browser enough time to open before deleting file
286 time.sleep(sleep)
287 finally:
288 os.remove(fp.name)
291def get_circuit_renderer(config: CircuitDisplayConfig | None = None) -> CircuitRenderer:
292 """
293 Get a configurable instance of the circuit renderer.
294 :param config: CircuitDisplayConfig to control the default render options.
295 """
296 if config is None: 296 ↛ 299line 296 didn't jump to line 299 because the condition on line 296 was always true
297 config = CircuitDisplayConfig.from_default_config_file()
299 return CircuitRenderer(jinja_env, config)
302# Export the render functions scoped to the default jinja environment.
303_default_circuit_renderer = get_circuit_renderer()
304render_circuit_as_html = _default_circuit_renderer.render_circuit_as_html
305render_circuit_jupyter = _default_circuit_renderer.render_circuit_jupyter
306view_browser = _default_circuit_renderer.view_browser