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

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. 

14 

15"""Display a circuit as html.""" 

16 

17import json 

18import os 

19import tempfile 

20import time 

21import uuid 

22import webbrowser 

23from dataclasses import dataclass, field 

24from typing import Any, Literal, cast 

25 

26from jinja2 import Environment, FileSystemLoader, PrefixLoader, nodes 

27from jinja2.ext import Extension 

28from jinja2.parser import Parser 

29from jinja2.utils import markupsafe 

30 

31from pytket.circuit import Circuit 

32from pytket.config import PytketExtConfig 

33 

34 

35# js scripts to be loaded must not be parsed as template files. 

36class IncludeRawExtension(Extension): 

37 tags = {"include_raw"} 

38 

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) 

44 

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("") 

51 

52 

53# Set up jinja to access our templates 

54dirname = os.path.dirname(__file__) 

55 

56# Define the base loaders. 

57html_loader = FileSystemLoader(searchpath=os.path.join(dirname, "static")) 

58js_loader = FileSystemLoader(searchpath=os.path.join(dirname, "js")) 

59 

60loader = PrefixLoader( 

61 { 

62 "html": html_loader, 

63 "js": js_loader, 

64 } 

65) 

66 

67jinja_env = Environment(loader=loader, extensions=[IncludeRawExtension]) 

68 

69RenderCircuit = dict[str, str | float | dict] | Circuit 

70Orientation = Literal["row"] | Literal["column"] 

71 

72 

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 ) 

86 

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 } 

99 

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. 

105 

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 } 

115 

116 

117@dataclass(kw_only=True) 

118class CircuitDisplayConfig(PytketExtConfig): 

119 ext_dict_key = "circuit_display" 

120 

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) 

126 

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 ) 

139 

140 

141class CircuitRenderer: 

142 """Class to manage circuit rendering within a given jinja2 environment.""" 

143 

144 config: CircuitDisplayConfig 

145 

146 def __init__(self, env: Environment, config: CircuitDisplayConfig): 

147 self.env = env 

148 self.config = config 

149 

150 def set_render_options(self, **kwargs: bool | str) -> None: 

151 """ 

152 Set rendering defaults. 

153 

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) 

174 

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. 

180 

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) 

186 

187 def save_render_options(self) -> None: 

188 """Save the current render options to pytket config.""" 

189 self.config.update_default_config_file() 

190 

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. 

199 

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 ) 

221 

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 

242 

243 display(HTML(html)) 

244 return None 

245 return html 

246 

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. 

253 

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) 

258 

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. 

266 

267 Waits for some time for browser to load then deletes tempfile. 

268 

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. 

273 

274 """ 

275 

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() 

282 

283 webbrowser.open("file://" + os.path.realpath(fp.name), new=browser_new) 

284 

285 # give browser enough time to open before deleting file 

286 time.sleep(sleep) 

287 finally: 

288 os.remove(fp.name) 

289 

290 

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() 

298 

299 return CircuitRenderer(jinja_env, config) 

300 

301 

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