1from __future__ import annotations
2
3import argparse
4import html
5import json
6import xml.sax.saxutils as xml
7
8from abc import abstractmethod
9from collections.abc import Mapping, Sequence
10from markdown_it.token import Token
11from typing import Any, Generic, Optional
12from urllib.parse import quote
13
14
15from . import md
16from . import parallel
17from .asciidoc import AsciiDocRenderer, asciidoc_escape
18from .commonmark import CommonMarkRenderer
19from .docbook import DocBookRenderer, make_xml_id
20from .html import HTMLRenderer
21from .manpage import ManpageRenderer, man_escape
22from .manual_structure import XrefTarget
23from .md import Converter, md_escape, md_make_code
24from .types import OptionLoc, Option, RenderedOption
25
26def option_is(option: Option, key: str, typ: str) -> Optional[dict[str, str]]:
27 if key not in option:
28 return None
29 if type(option[key]) != dict:
30 return None
31 if option[key].get('_type') != typ: # type: ignore[union-attr]
32 return None
33 return option[key] # type: ignore[return-value]
34
35class BaseConverter(Converter[md.TR], Generic[md.TR]):
36 __option_block_separator__: str
37
38 _options: dict[str, RenderedOption]
39
40 def __init__(self, revision: str):
41 super().__init__()
42 self._options = {}
43 self._revision = revision
44
45 def _sorted_options(self) -> list[tuple[str, RenderedOption]]:
46 keys = list(self._options.keys())
47 keys.sort(key=lambda opt: [ (0 if p.startswith("enable") else 1 if p.startswith("package") else 2, p)
48 for p in self._options[opt].loc ])
49 return [ (k, self._options[k]) for k in keys ]
50
51 def _format_decl_def_loc(self, loc: OptionLoc) -> tuple[Optional[str], str]:
52 # locations can be either plain strings (specific to nixpkgs), or attrsets
53 # { name = "foo/bar.nix"; url = "https://github.com/....."; }
54 if isinstance(loc, str):
55 # Hyperlink the filename either to the NixOS github
56 # repository (if it’s a module and we have a revision number),
57 # or to the local filesystem.
58 if not loc.startswith('/'):
59 if self._revision == 'local':
60 href = f"https://github.com/NixOS/nixpkgs/blob/master/{loc}"
61 else:
62 href = f"https://github.com/NixOS/nixpkgs/blob/{self._revision}/{loc}"
63 else:
64 href = f"file://{loc}"
65 # Print the filename and make it user-friendly by replacing the
66 # /nix/store/<hash> prefix by the default location of nixos
67 # sources.
68 if not loc.startswith('/'):
69 name = f"<nixpkgs/{loc}>"
70 elif 'nixops' in loc and '/nix/' in loc:
71 name = f"<nixops/{loc[loc.find('/nix/') + 5:]}>"
72 else:
73 name = loc
74 return (href, name)
75 else:
76 return (loc['url'] if 'url' in loc else None, loc['name'])
77
78 @abstractmethod
79 def _decl_def_header(self, header: str) -> list[str]: raise NotImplementedError()
80
81 @abstractmethod
82 def _decl_def_entry(self, href: Optional[str], name: str) -> list[str]: raise NotImplementedError()
83
84 @abstractmethod
85 def _decl_def_footer(self) -> list[str]: raise NotImplementedError()
86
87 def _render_decl_def(self, header: str, locs: list[OptionLoc]) -> list[str]:
88 result = []
89 result += self._decl_def_header(header)
90 for loc in locs:
91 href, name = self._format_decl_def_loc(loc)
92 result += self._decl_def_entry(href, name)
93 result += self._decl_def_footer()
94 return result
95
96 def _render_code(self, option: Option, key: str) -> list[str]:
97 if lit := option_is(option, key, 'literalMD'):
98 return [ self._render(f"*{key.capitalize()}:*\n{lit['text']}") ]
99 elif lit := option_is(option, key, 'literalExpression'):
100 code = md_make_code(lit['text'])
101 return [ self._render(f"*{key.capitalize()}:*\n{code}") ]
102 elif key in option:
103 raise Exception(f"{key} has unrecognized type", option[key])
104 else:
105 return []
106
107 def _render_description(self, desc: str | dict[str, str]) -> list[str]:
108 if isinstance(desc, str):
109 return [ self._render(desc) ] if desc else []
110 elif isinstance(desc, dict) and desc.get('_type') == 'mdDoc':
111 return [ self._render(desc['text']) ] if desc['text'] else []
112 else:
113 raise Exception("description has unrecognized type", desc)
114
115 @abstractmethod
116 def _related_packages_header(self) -> list[str]: raise NotImplementedError()
117
118 def _convert_one(self, option: dict[str, Any]) -> list[str]:
119 blocks: list[list[str]] = []
120
121 if desc := option.get('description'):
122 blocks.append(self._render_description(desc))
123 if typ := option.get('type'):
124 ro = " *(read only)*" if option.get('readOnly', False) else ""
125 blocks.append([ self._render(f"*Type:*\n{md_escape(typ)}{ro}") ])
126
127 if option.get('default'):
128 blocks.append(self._render_code(option, 'default'))
129 if option.get('example'):
130 blocks.append(self._render_code(option, 'example'))
131
132 if related := option.get('relatedPackages'):
133 blocks.append(self._related_packages_header())
134 blocks[-1].append(self._render(related))
135 if decl := option.get('declarations'):
136 blocks.append(self._render_decl_def("Declared by", decl))
137 if defs := option.get('definitions'):
138 blocks.append(self._render_decl_def("Defined by", defs))
139
140 for part in [ p for p in blocks[0:-1] if p ]:
141 part.append(self.__option_block_separator__)
142
143 return [ l for part in blocks for l in part ]
144
145 # this could return a TState parameter, but that does not allow dependent types and
146 # will cause headaches when using BaseConverter as a type bound anywhere. Any is the
147 # next best thing we can use, and since this is internal it will be mostly safe.
148 @abstractmethod
149 def _parallel_render_prepare(self) -> Any: raise NotImplementedError()
150 # this should return python 3.11's Self instead to ensure that a prepare+finish
151 # round-trip ends up with an object of the same type. for now we'll use BaseConverter
152 # since it's good enough so far.
153 @classmethod
154 @abstractmethod
155 def _parallel_render_init_worker(cls, a: Any) -> BaseConverter[md.TR]: raise NotImplementedError()
156
157 def _render_option(self, name: str, option: dict[str, Any]) -> RenderedOption:
158 try:
159 return RenderedOption(option['loc'], self._convert_one(option))
160 except Exception as e:
161 raise Exception(f"Failed to render option {name}") from e
162
163 @classmethod
164 def _parallel_render_step(cls, s: BaseConverter[md.TR], a: Any) -> RenderedOption:
165 return s._render_option(*a)
166
167 def add_options(self, options: dict[str, Any]) -> None:
168 mapped = parallel.map(self._parallel_render_step, options.items(), 100,
169 self._parallel_render_init_worker, self._parallel_render_prepare())
170 for (name, option) in zip(options.keys(), mapped):
171 self._options[name] = option
172
173 @abstractmethod
174 def finalize(self) -> str: raise NotImplementedError()
175
176class OptionDocsRestrictions:
177 def heading_open(self, token: Token, tokens: Sequence[Token], i: int) -> str:
178 raise RuntimeError("md token not supported in options doc", token)
179 def heading_close(self, token: Token, tokens: Sequence[Token], i: int) -> str:
180 raise RuntimeError("md token not supported in options doc", token)
181 def attr_span_begin(self, token: Token, tokens: Sequence[Token], i: int) -> str:
182 raise RuntimeError("md token not supported in options doc", token)
183 def example_open(self, token: Token, tokens: Sequence[Token], i: int) -> str:
184 raise RuntimeError("md token not supported in options doc", token)
185
186class OptionsDocBookRenderer(OptionDocsRestrictions, DocBookRenderer):
187 # TODO keep optionsDocBook diff small. remove soon if rendering is still good.
188 def ordered_list_open(self, token: Token, tokens: Sequence[Token], i: int) -> str:
189 token.meta['compact'] = False
190 return super().ordered_list_open(token, tokens, i)
191 def bullet_list_open(self, token: Token, tokens: Sequence[Token], i: int) -> str:
192 token.meta['compact'] = False
193 return super().bullet_list_open(token, tokens, i)
194
195class DocBookConverter(BaseConverter[OptionsDocBookRenderer]):
196 __option_block_separator__ = ""
197
198 def __init__(self, manpage_urls: Mapping[str, str],
199 revision: str,
200 document_type: str,
201 varlist_id: str,
202 id_prefix: str):
203 super().__init__(revision)
204 self._renderer = OptionsDocBookRenderer(manpage_urls)
205 self._document_type = document_type
206 self._varlist_id = varlist_id
207 self._id_prefix = id_prefix
208
209 def _parallel_render_prepare(self) -> Any:
210 return (self._renderer._manpage_urls, self._revision, self._document_type,
211 self._varlist_id, self._id_prefix)
212 @classmethod
213 def _parallel_render_init_worker(cls, a: Any) -> DocBookConverter:
214 return cls(*a)
215
216 def _related_packages_header(self) -> list[str]:
217 return [
218 "<para>",
219 " <emphasis>Related packages:</emphasis>",
220 "</para>",
221 ]
222
223 def _decl_def_header(self, header: str) -> list[str]:
224 return [
225 f"<para><emphasis>{header}:</emphasis></para>",
226 "<simplelist>"
227 ]
228
229 def _decl_def_entry(self, href: Optional[str], name: str) -> list[str]:
230 if href is not None:
231 href = " xlink:href=" + xml.quoteattr(href)
232 return [
233 f"<member><filename{href}>",
234 xml.escape(name),
235 "</filename></member>"
236 ]
237
238 def _decl_def_footer(self) -> list[str]:
239 return [ "</simplelist>" ]
240
241 def finalize(self, *, fragment: bool = False) -> str:
242 result = []
243
244 if not fragment:
245 result.append('<?xml version="1.0" encoding="UTF-8"?>')
246 if self._document_type == 'appendix':
247 result += [
248 '<appendix xmlns="http://docbook.org/ns/docbook"',
249 ' xml:id="appendix-configuration-options">',
250 ' <title>Configuration Options</title>',
251 ]
252 result += [
253 '<variablelist xmlns:xlink="http://www.w3.org/1999/xlink"',
254 ' xmlns:nixos="tag:nixos.org"',
255 ' xmlns="http://docbook.org/ns/docbook"',
256 f' xml:id="{self._varlist_id}">',
257 ]
258
259 for (name, opt) in self._sorted_options():
260 id = make_xml_id(self._id_prefix + name)
261 result += [
262 "<varlistentry>",
263 # NOTE adding extra spaces here introduces spaces into xref link expansions
264 (f"<term xlink:href={xml.quoteattr('#' + id)} xml:id={xml.quoteattr(id)}>" +
265 f"<option>{xml.escape(name)}</option></term>"),
266 "<listitem>"
267 ]
268 result += opt.lines
269 result += [
270 "</listitem>",
271 "</varlistentry>"
272 ]
273
274 result.append("</variablelist>")
275 if self._document_type == 'appendix':
276 result.append("</appendix>")
277
278 return "\n".join(result)
279
280class OptionsManpageRenderer(OptionDocsRestrictions, ManpageRenderer):
281 pass
282
283class ManpageConverter(BaseConverter[OptionsManpageRenderer]):
284 __option_block_separator__ = ".sp"
285
286 _options_by_id: dict[str, str]
287 _links_in_last_description: Optional[list[str]] = None
288
289 def __init__(self, revision: str,
290 *,
291 # only for parallel rendering
292 _options_by_id: Optional[dict[str, str]] = None):
293 super().__init__(revision)
294 self._options_by_id = _options_by_id or {}
295 self._renderer = OptionsManpageRenderer({}, self._options_by_id)
296
297 def _parallel_render_prepare(self) -> Any:
298 return (self._revision, { '_options_by_id': self._options_by_id })
299 @classmethod
300 def _parallel_render_init_worker(cls, a: Any) -> ManpageConverter:
301 return cls(a[0], **a[1])
302
303 def _render_option(self, name: str, option: dict[str, Any]) -> RenderedOption:
304 links = self._renderer.link_footnotes = []
305 result = super()._render_option(name, option)
306 self._renderer.link_footnotes = None
307 return result._replace(links=links)
308
309 def add_options(self, options: dict[str, Any]) -> None:
310 for (k, v) in options.items():
311 self._options_by_id[f'#{make_xml_id(f"opt-{k}")}'] = k
312 return super().add_options(options)
313
314 def _render_code(self, option: dict[str, Any], key: str) -> list[str]:
315 try:
316 self._renderer.inline_code_is_quoted = False
317 return super()._render_code(option, key)
318 finally:
319 self._renderer.inline_code_is_quoted = True
320
321 def _related_packages_header(self) -> list[str]:
322 return [
323 '\\fIRelated packages:\\fP',
324 '.sp',
325 ]
326
327 def _decl_def_header(self, header: str) -> list[str]:
328 return [
329 f'\\fI{man_escape(header)}:\\fP',
330 ]
331
332 def _decl_def_entry(self, href: Optional[str], name: str) -> list[str]:
333 return [
334 '.RS 4',
335 f'\\fB{man_escape(name)}\\fP',
336 '.RE'
337 ]
338
339 def _decl_def_footer(self) -> list[str]:
340 return []
341
342 def finalize(self) -> str:
343 result = []
344
345 result += [
346 r'''.TH "CONFIGURATION\&.NIX" "5" "01/01/1980" "NixOS" "NixOS Reference Pages"''',
347 r'''.\" disable hyphenation''',
348 r'''.nh''',
349 r'''.\" disable justification (adjust text to left margin only)''',
350 r'''.ad l''',
351 r'''.\" enable line breaks after slashes''',
352 r'''.cflags 4 /''',
353 r'''.SH "NAME"''',
354 self._render('{file}`configuration.nix` - NixOS system configuration specification'),
355 r'''.SH "DESCRIPTION"''',
356 r'''.PP''',
357 self._render('The file {file}`/etc/nixos/configuration.nix` contains the '
358 'declarative specification of your NixOS system configuration. '
359 'The command {command}`nixos-rebuild` takes this file and '
360 'realises the system configuration specified therein.'),
361 r'''.SH "OPTIONS"''',
362 r'''.PP''',
363 self._render('You can use the following options in {file}`configuration.nix`.'),
364 ]
365
366 for (name, opt) in self._sorted_options():
367 result += [
368 ".PP",
369 f"\\fB{man_escape(name)}\\fR",
370 ".RS 4",
371 ]
372 result += opt.lines
373 if links := opt.links:
374 result.append(self.__option_block_separator__)
375 md_links = ""
376 for i in range(0, len(links)):
377 md_links += "\n" if i > 0 else ""
378 if links[i].startswith('#opt-'):
379 md_links += f"{i+1}. see the {{option}}`{self._options_by_id[links[i]]}` option"
380 else:
381 md_links += f"{i+1}. " + md_escape(links[i])
382 result.append(self._render(md_links))
383
384 result.append(".RE")
385
386 result += [
387 r'''.SH "AUTHORS"''',
388 r'''.PP''',
389 r'''Eelco Dolstra and the Nixpkgs/NixOS contributors''',
390 ]
391
392 return "\n".join(result)
393
394class OptionsCommonMarkRenderer(OptionDocsRestrictions, CommonMarkRenderer):
395 pass
396
397class CommonMarkConverter(BaseConverter[OptionsCommonMarkRenderer]):
398 __option_block_separator__ = ""
399
400 def __init__(self, manpage_urls: Mapping[str, str], revision: str):
401 super().__init__(revision)
402 self._renderer = OptionsCommonMarkRenderer(manpage_urls)
403
404 def _parallel_render_prepare(self) -> Any:
405 return (self._renderer._manpage_urls, self._revision)
406 @classmethod
407 def _parallel_render_init_worker(cls, a: Any) -> CommonMarkConverter:
408 return cls(*a)
409
410 def _related_packages_header(self) -> list[str]:
411 return [ "*Related packages:*" ]
412
413 def _decl_def_header(self, header: str) -> list[str]:
414 return [ f"*{header}:*" ]
415
416 def _decl_def_entry(self, href: Optional[str], name: str) -> list[str]:
417 if href is not None:
418 return [ f" - [{md_escape(name)}]({href})" ]
419 return [ f" - {md_escape(name)}" ]
420
421 def _decl_def_footer(self) -> list[str]:
422 return []
423
424 def finalize(self) -> str:
425 result = []
426
427 for (name, opt) in self._sorted_options():
428 result.append(f"## {md_escape(name)}\n")
429 result += opt.lines
430 result.append("\n\n")
431
432 return "\n".join(result)
433
434class OptionsAsciiDocRenderer(OptionDocsRestrictions, AsciiDocRenderer):
435 pass
436
437class AsciiDocConverter(BaseConverter[OptionsAsciiDocRenderer]):
438 __option_block_separator__ = ""
439
440 def __init__(self, manpage_urls: Mapping[str, str], revision: str):
441 super().__init__(revision)
442 self._renderer = OptionsAsciiDocRenderer(manpage_urls)
443
444 def _parallel_render_prepare(self) -> Any:
445 return (self._renderer._manpage_urls, self._revision)
446 @classmethod
447 def _parallel_render_init_worker(cls, a: Any) -> AsciiDocConverter:
448 return cls(*a)
449
450 def _related_packages_header(self) -> list[str]:
451 return [ "__Related packages:__" ]
452
453 def _decl_def_header(self, header: str) -> list[str]:
454 return [ f"__{header}:__\n" ]
455
456 def _decl_def_entry(self, href: Optional[str], name: str) -> list[str]:
457 if href is not None:
458 return [ f"* link:{quote(href, safe='/:')}[{asciidoc_escape(name)}]" ]
459 return [ f"* {asciidoc_escape(name)}" ]
460
461 def _decl_def_footer(self) -> list[str]:
462 return []
463
464 def finalize(self) -> str:
465 result = []
466
467 for (name, opt) in self._sorted_options():
468 result.append(f"== {asciidoc_escape(name)}\n")
469 result += opt.lines
470 result.append("\n\n")
471
472 return "\n".join(result)
473
474class OptionsHTMLRenderer(OptionDocsRestrictions, HTMLRenderer):
475 # TODO docbook compat. must be removed together with the matching docbook handlers.
476 def ordered_list_open(self, token: Token, tokens: Sequence[Token], i: int) -> str:
477 token.meta['compact'] = False
478 return super().ordered_list_open(token, tokens, i)
479 def bullet_list_open(self, token: Token, tokens: Sequence[Token], i: int) -> str:
480 token.meta['compact'] = False
481 return super().bullet_list_open(token, tokens, i)
482 def fence(self, token: Token, tokens: Sequence[Token], i: int) -> str:
483 # TODO use token.info. docbook doesn't so we can't yet.
484 return f'<pre class="programlisting">{html.escape(token.content)}</pre>'
485
486class HTMLConverter(BaseConverter[OptionsHTMLRenderer]):
487 __option_block_separator__ = ""
488
489 def __init__(self, manpage_urls: Mapping[str, str], revision: str,
490 varlist_id: str, id_prefix: str, xref_targets: Mapping[str, XrefTarget]):
491 super().__init__(revision)
492 self._xref_targets = xref_targets
493 self._varlist_id = varlist_id
494 self._id_prefix = id_prefix
495 self._renderer = OptionsHTMLRenderer(manpage_urls, self._xref_targets)
496
497 def _parallel_render_prepare(self) -> Any:
498 return (self._renderer._manpage_urls, self._revision,
499 self._varlist_id, self._id_prefix, self._xref_targets)
500 @classmethod
501 def _parallel_render_init_worker(cls, a: Any) -> HTMLConverter:
502 return cls(*a)
503
504 def _related_packages_header(self) -> list[str]:
505 return [
506 '<p><span class="emphasis"><em>Related packages:</em></span></p>',
507 ]
508
509 def _decl_def_header(self, header: str) -> list[str]:
510 return [
511 f'<p><span class="emphasis"><em>{header}:</em></span></p>',
512 '<table border="0" summary="Simple list" class="simplelist">'
513 ]
514
515 def _decl_def_entry(self, href: Optional[str], name: str) -> list[str]:
516 if href is not None:
517 href = f' href="{html.escape(href, True)}"'
518 return [
519 "<tr><td>",
520 f'<code class="filename"><a class="filename" {href} target="_top">',
521 f'{html.escape(name)}',
522 '</a></code>',
523 "</td></tr>"
524 ]
525
526 def _decl_def_footer(self) -> list[str]:
527 return [ "</table>" ]
528
529 def finalize(self) -> str:
530 result = []
531
532 result += [
533 '<div class="variablelist">',
534 f'<a id="{html.escape(self._varlist_id, True)}"></a>',
535 ' <dl class="variablelist">',
536 ]
537
538 for (name, opt) in self._sorted_options():
539 id = make_xml_id(self._id_prefix + name)
540 target = self._xref_targets[id]
541 result += [
542 '<dt>',
543 ' <span class="term">',
544 # docbook compat, these could be one tag
545 f' <a id="{html.escape(id, True)}"></a><a class="term" href="{target.href()}">'
546 # no spaces here (and string merging) for docbook output compat
547 f'<code class="option">{html.escape(name)}</code>',
548 ' </a>',
549 ' </span>',
550 '</dt>',
551 '<dd>',
552 ]
553 result += opt.lines
554 result += [
555 "</dd>",
556 ]
557
558 result += [
559 " </dl>",
560 "</div>"
561 ]
562
563 return "\n".join(result)
564
565def _build_cli_db(p: argparse.ArgumentParser) -> None:
566 p.add_argument('--manpage-urls', required=True)
567 p.add_argument('--revision', required=True)
568 p.add_argument('--document-type', required=True)
569 p.add_argument('--varlist-id', required=True)
570 p.add_argument('--id-prefix', required=True)
571 p.add_argument("infile")
572 p.add_argument("outfile")
573
574def _build_cli_manpage(p: argparse.ArgumentParser) -> None:
575 p.add_argument('--revision', required=True)
576 p.add_argument("infile")
577 p.add_argument("outfile")
578
579def _build_cli_commonmark(p: argparse.ArgumentParser) -> None:
580 p.add_argument('--manpage-urls', required=True)
581 p.add_argument('--revision', required=True)
582 p.add_argument("infile")
583 p.add_argument("outfile")
584
585def _build_cli_asciidoc(p: argparse.ArgumentParser) -> None:
586 p.add_argument('--manpage-urls', required=True)
587 p.add_argument('--revision', required=True)
588 p.add_argument("infile")
589 p.add_argument("outfile")
590
591def _run_cli_db(args: argparse.Namespace) -> None:
592 with open(args.manpage_urls, 'r') as manpage_urls:
593 md = DocBookConverter(
594 json.load(manpage_urls),
595 revision = args.revision,
596 document_type = args.document_type,
597 varlist_id = args.varlist_id,
598 id_prefix = args.id_prefix)
599
600 with open(args.infile, 'r') as f:
601 md.add_options(json.load(f))
602 with open(args.outfile, 'w') as f:
603 f.write(md.finalize())
604
605def _run_cli_manpage(args: argparse.Namespace) -> None:
606 md = ManpageConverter(revision = args.revision)
607
608 with open(args.infile, 'r') as f:
609 md.add_options(json.load(f))
610 with open(args.outfile, 'w') as f:
611 f.write(md.finalize())
612
613def _run_cli_commonmark(args: argparse.Namespace) -> None:
614 with open(args.manpage_urls, 'r') as manpage_urls:
615 md = CommonMarkConverter(json.load(manpage_urls), revision = args.revision)
616
617 with open(args.infile, 'r') as f:
618 md.add_options(json.load(f))
619 with open(args.outfile, 'w') as f:
620 f.write(md.finalize())
621
622def _run_cli_asciidoc(args: argparse.Namespace) -> None:
623 with open(args.manpage_urls, 'r') as manpage_urls:
624 md = AsciiDocConverter(json.load(manpage_urls), revision = args.revision)
625
626 with open(args.infile, 'r') as f:
627 md.add_options(json.load(f))
628 with open(args.outfile, 'w') as f:
629 f.write(md.finalize())
630
631def build_cli(p: argparse.ArgumentParser) -> None:
632 formats = p.add_subparsers(dest='format', required=True)
633 _build_cli_db(formats.add_parser('docbook'))
634 _build_cli_manpage(formats.add_parser('manpage'))
635 _build_cli_commonmark(formats.add_parser('commonmark'))
636 _build_cli_asciidoc(formats.add_parser('asciidoc'))
637
638def run_cli(args: argparse.Namespace) -> None:
639 if args.format == 'docbook':
640 _run_cli_db(args)
641 elif args.format == 'manpage':
642 _run_cli_manpage(args)
643 elif args.format == 'commonmark':
644 _run_cli_commonmark(args)
645 elif args.format == 'asciidoc':
646 _run_cli_asciidoc(args)
647 else:
648 raise RuntimeError('format not hooked up', args)