templateexporter.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693
  1. """This module defines TemplateExporter, a highly configurable converter
  2. that uses Jinja2 to export notebook files into different formats.
  3. """
  4. # Copyright (c) IPython Development Team.
  5. # Distributed under the terms of the Modified BSD License.
  6. from __future__ import annotations
  7. import html
  8. import json
  9. import os
  10. import typing as t
  11. import uuid
  12. import warnings
  13. from pathlib import Path
  14. from jinja2 import (
  15. BaseLoader,
  16. ChoiceLoader,
  17. DictLoader,
  18. Environment,
  19. FileSystemLoader,
  20. TemplateNotFound,
  21. )
  22. from jupyter_core.paths import jupyter_path
  23. from nbformat import NotebookNode
  24. from traitlets import Bool, Dict, HasTraits, List, Unicode, default, observe, validate
  25. from traitlets.config import Config
  26. from traitlets.utils.importstring import import_item
  27. from nbconvert import filters
  28. from .exporter import Exporter
  29. # Jinja2 extensions to load.
  30. JINJA_EXTENSIONS = ["jinja2.ext.loopcontrols"]
  31. ROOT = os.path.dirname(__file__)
  32. DEV_MODE = os.path.exists(os.path.join(ROOT, "../../.git"))
  33. default_filters = {
  34. "indent": filters.indent,
  35. "markdown2html": filters.markdown2html,
  36. "markdown2asciidoc": filters.markdown2asciidoc,
  37. "ansi2html": filters.ansi2html,
  38. "filter_data_type": filters.DataTypeFilter,
  39. "get_lines": filters.get_lines,
  40. "highlight2html": filters.Highlight2HTML,
  41. "highlight2latex": filters.Highlight2Latex,
  42. "ipython2python": filters.ipython2python,
  43. "posix_path": filters.posix_path,
  44. "markdown2latex": filters.markdown2latex,
  45. "markdown2rst": filters.markdown2rst,
  46. "comment_lines": filters.comment_lines,
  47. "strip_ansi": filters.strip_ansi,
  48. "strip_dollars": filters.strip_dollars,
  49. "strip_files_prefix": filters.strip_files_prefix,
  50. "html2text": filters.html2text,
  51. "add_anchor": filters.add_anchor,
  52. "ansi2latex": filters.ansi2latex,
  53. "wrap_text": filters.wrap_text,
  54. "escape_latex": filters.escape_latex,
  55. "citation2latex": filters.citation2latex,
  56. "path2url": filters.path2url,
  57. "add_prompts": filters.add_prompts,
  58. "ascii_only": filters.ascii_only,
  59. "prevent_list_blocks": filters.prevent_list_blocks,
  60. "get_metadata": filters.get_metadata,
  61. "convert_pandoc": filters.convert_pandoc,
  62. "json_dumps": json.dumps,
  63. # For removing any HTML
  64. "escape_html": lambda s: html.escape(str(s)),
  65. "escape_html_keep_quotes": lambda s: html.escape(str(s), quote=False),
  66. "escape_html_script": lambda s: s.replace("/", "\\/"),
  67. # For sanitizing HTML for any XSS
  68. "clean_html": filters.clean_html,
  69. "strip_trailing_newline": filters.strip_trailing_newline,
  70. "text_base64": filters.text_base64,
  71. }
  72. # copy of https://github.com/jupyter/jupyter_server/blob/b62458a7f5ad6b5246d2f142258dedaa409de5d9/jupyter_server/config_manager.py#L19
  73. def recursive_update(target, new):
  74. """Recursively update one dictionary using another.
  75. None values will delete their keys.
  76. """
  77. for k, v in new.items():
  78. if isinstance(v, dict):
  79. if k not in target:
  80. target[k] = {}
  81. recursive_update(target[k], v)
  82. if not target[k]:
  83. # Prune empty subdicts
  84. del target[k]
  85. elif v is None:
  86. target.pop(k, None)
  87. else:
  88. target[k] = v
  89. return target # return for convenience
  90. # define function at the top level to avoid pickle errors
  91. def deprecated(msg):
  92. """Emit a deprecation warning."""
  93. warnings.warn(msg, DeprecationWarning, stacklevel=2)
  94. class ExtensionTolerantLoader(BaseLoader):
  95. """A template loader which optionally adds a given extension when searching.
  96. Constructor takes two arguments: *loader* is another Jinja loader instance
  97. to wrap. *extension* is the extension, which will be added to the template
  98. name if finding the template without it fails. This should include the dot,
  99. e.g. '.tpl'.
  100. """
  101. def __init__(self, loader, extension):
  102. """Initialize the loader."""
  103. self.loader = loader
  104. self.extension = extension
  105. def get_source(self, environment, template):
  106. """Get the source for a template."""
  107. try:
  108. return self.loader.get_source(environment, template)
  109. except TemplateNotFound:
  110. if template.endswith(self.extension):
  111. raise TemplateNotFound(template) from None
  112. return self.loader.get_source(environment, template + self.extension)
  113. def list_templates(self):
  114. """List available templates."""
  115. return self.loader.list_templates()
  116. class TemplateExporter(Exporter):
  117. """
  118. Exports notebooks into other file formats. Uses Jinja 2 templating engine
  119. to output new formats. Inherit from this class if you are creating a new
  120. template type along with new filters/preprocessors. If the filters/
  121. preprocessors provided by default suffice, there is no need to inherit from
  122. this class. Instead, override the template_file and file_extension
  123. traits via a config file.
  124. Filters available by default for templates:
  125. {filters}
  126. """
  127. # finish the docstring
  128. __doc__ = (
  129. __doc__.format(filters="- " + "\n - ".join(sorted(default_filters.keys())))
  130. if __doc__
  131. else None
  132. )
  133. _template_cached = None
  134. def _invalidate_template_cache(self, change=None):
  135. self._template_cached = None
  136. @property
  137. def template(self):
  138. if self._template_cached is None:
  139. self._template_cached = self._load_template()
  140. return self._template_cached
  141. _environment_cached = None
  142. def _invalidate_environment_cache(self, change=None):
  143. self._environment_cached = None
  144. self._invalidate_template_cache()
  145. @property
  146. def environment(self):
  147. if self._environment_cached is None:
  148. self._environment_cached = self._create_environment()
  149. return self._environment_cached
  150. @property
  151. def default_config(self):
  152. c = Config(
  153. {
  154. "RegexRemovePreprocessor": {"enabled": True},
  155. "TagRemovePreprocessor": {"enabled": True},
  156. }
  157. )
  158. if super().default_config:
  159. c2 = super().default_config.copy()
  160. c2.merge(c)
  161. c = c2
  162. return c
  163. template_name = Unicode(help="Name of the template to use").tag(
  164. config=True, affects_template=True
  165. )
  166. template_file = Unicode(None, allow_none=True, help="Name of the template file to use").tag(
  167. config=True, affects_template=True
  168. )
  169. raw_template = Unicode("", help="raw template string").tag(affects_environment=True)
  170. enable_async = Bool(False, help="Enable Jinja async template execution").tag(
  171. affects_environment=True
  172. )
  173. _last_template_file = ""
  174. _raw_template_key = "<memory>"
  175. @validate("template_name")
  176. def _template_name_validate(self, change):
  177. template_name = change["value"]
  178. if template_name and template_name.endswith(".tpl"):
  179. warnings.warn(
  180. f"5.x style template name passed '{self.template_name}'. Use --template-name for the template directory with a index.<ext>.j2 file and/or --template-file to denote a different template.",
  181. DeprecationWarning,
  182. stacklevel=2,
  183. )
  184. directory, self.template_file = os.path.split(self.template_name)
  185. if directory:
  186. directory, template_name = os.path.split(directory)
  187. if directory and os.path.isabs(directory):
  188. self.extra_template_basedirs = [directory]
  189. return template_name
  190. @observe("template_file")
  191. def _template_file_changed(self, change):
  192. new = change["new"]
  193. if new == "default":
  194. self.template_file = self.default_template # type:ignore[attr-defined]
  195. return
  196. # check if template_file is a file path
  197. # rather than a name already on template_path
  198. full_path = os.path.abspath(new)
  199. if os.path.isfile(full_path):
  200. directory, self.template_file = os.path.split(full_path)
  201. self.extra_template_paths = [directory, *self.extra_template_paths]
  202. # While not strictly an invalid template file name, the extension hints that there isn't a template directory involved
  203. if self.template_file and self.template_file.endswith(".tpl"):
  204. warnings.warn(
  205. f"5.x style template file passed '{new}'. Use --template-name for the template directory with a index.<ext>.j2 file and/or --template-file to denote a different template.",
  206. DeprecationWarning,
  207. stacklevel=2,
  208. )
  209. @default("template_file")
  210. def _template_file_default(self):
  211. if self.template_extension:
  212. return "index" + self.template_extension
  213. return None
  214. @observe("raw_template")
  215. def _raw_template_changed(self, change):
  216. if not change["new"]:
  217. self.template_file = self._last_template_file
  218. self._invalidate_template_cache()
  219. template_paths = List(["."]).tag(config=True, affects_environment=True)
  220. extra_template_basedirs = List(Unicode()).tag(config=True, affects_environment=True)
  221. extra_template_paths = List(Unicode()).tag(config=True, affects_environment=True)
  222. @default("extra_template_basedirs")
  223. def _default_extra_template_basedirs(self):
  224. return [os.getcwd()]
  225. # Extension that the template files use.
  226. template_extension = Unicode().tag(config=True, affects_environment=True)
  227. template_data_paths = List(
  228. jupyter_path("nbconvert", "templates"), help="Path where templates can be installed too."
  229. ).tag(affects_environment=True)
  230. @default("template_extension")
  231. def _template_extension_default(self):
  232. if self.file_extension:
  233. return self.file_extension + ".j2"
  234. return self.file_extension
  235. exclude_input = Bool(
  236. False, help="This allows you to exclude code cell inputs from all templates if set to True."
  237. ).tag(config=True)
  238. exclude_input_prompt = Bool(
  239. False, help="This allows you to exclude input prompts from all templates if set to True."
  240. ).tag(config=True)
  241. exclude_output = Bool(
  242. False,
  243. help="This allows you to exclude code cell outputs from all templates if set to True.",
  244. ).tag(config=True)
  245. exclude_output_prompt = Bool(
  246. False, help="This allows you to exclude output prompts from all templates if set to True."
  247. ).tag(config=True)
  248. exclude_output_stdin = Bool(
  249. True,
  250. help="This allows you to exclude output of stdin stream from lab template if set to True.",
  251. ).tag(config=True)
  252. exclude_code_cell = Bool(
  253. False, help="This allows you to exclude code cells from all templates if set to True."
  254. ).tag(config=True)
  255. exclude_markdown = Bool(
  256. False, help="This allows you to exclude markdown cells from all templates if set to True."
  257. ).tag(config=True)
  258. exclude_raw = Bool(
  259. False, help="This allows you to exclude raw cells from all templates if set to True."
  260. ).tag(config=True)
  261. exclude_unknown = Bool(
  262. False, help="This allows you to exclude unknown cells from all templates if set to True."
  263. ).tag(config=True)
  264. extra_loaders: List[t.Any] = List(
  265. help="Jinja loaders to find templates. Will be tried in order "
  266. "before the default FileSystem ones.",
  267. ).tag(affects_environment=True)
  268. filters = Dict(
  269. help="""Dictionary of filters, by name and namespace, to add to the Jinja
  270. environment."""
  271. ).tag(config=True, affects_environment=True)
  272. raw_mimetypes = List(
  273. Unicode(), help="""formats of raw cells to be included in this Exporter's output."""
  274. ).tag(config=True)
  275. @default("raw_mimetypes")
  276. def _raw_mimetypes_default(self):
  277. return [self.output_mimetype, ""]
  278. # TODO: passing config is wrong, but changing this revealed more complicated issues
  279. def __init__(self, config=None, **kw):
  280. """
  281. Public constructor
  282. Parameters
  283. ----------
  284. config : config
  285. User configuration instance.
  286. extra_loaders : list[of Jinja Loaders]
  287. ordered list of Jinja loader to find templates. Will be tried in order
  288. before the default FileSystem ones.
  289. template_file : str (optional, kw arg)
  290. Template to use when exporting.
  291. """
  292. super().__init__(config=config, **kw)
  293. self.observe(
  294. self._invalidate_environment_cache, list(self.traits(affects_environment=True))
  295. )
  296. self.observe(self._invalidate_template_cache, list(self.traits(affects_template=True)))
  297. def _load_template(self):
  298. """Load the Jinja template object from the template file
  299. This is triggered by various trait changes that would change the template.
  300. """
  301. # this gives precedence to a raw_template if present
  302. with self.hold_trait_notifications():
  303. if self.template_file and (self.template_file != self._raw_template_key):
  304. self._last_template_file = self.template_file
  305. if self.raw_template:
  306. self.template_file = self._raw_template_key
  307. if not self.template_file:
  308. msg = "No template_file specified!"
  309. raise ValueError(msg)
  310. # First try to load the
  311. # template by name with extension added, then try loading the template
  312. # as if the name is explicitly specified.
  313. template_file = self.template_file
  314. self.log.debug("Attempting to load template %s", template_file)
  315. self.log.debug(" template_paths: %s", os.pathsep.join(self.template_paths))
  316. return self.environment.get_template(template_file)
  317. def from_filename( # type:ignore[override]
  318. self, filename: str, resources: dict[str, t.Any] | None = None, **kw: t.Any
  319. ) -> tuple[str, dict[str, t.Any]]:
  320. """Convert a notebook from a filename."""
  321. return super().from_filename(filename, resources, **kw) # type:ignore[return-value]
  322. def from_file( # type:ignore[override]
  323. self, file_stream: t.Any, resources: dict[str, t.Any] | None = None, **kw: t.Any
  324. ) -> tuple[str, dict[str, t.Any]]:
  325. """Convert a notebook from a file."""
  326. return super().from_file(file_stream, resources, **kw) # type:ignore[return-value]
  327. def from_notebook_node( # type:ignore[override]
  328. self, nb: NotebookNode, resources: dict[str, t.Any] | None = None, **kw: t.Any
  329. ) -> tuple[str, dict[str, t.Any]]:
  330. """
  331. Convert a notebook from a notebook node instance.
  332. Parameters
  333. ----------
  334. nb : :class:`~nbformat.NotebookNode`
  335. Notebook node
  336. resources : dict
  337. Additional resources that can be accessed read/write by
  338. preprocessors and filters.
  339. """
  340. nb_copy, resources = super().from_notebook_node(nb, resources, **kw)
  341. resources.setdefault("raw_mimetypes", self.raw_mimetypes)
  342. resources.setdefault("output_mimetype", self.output_mimetype)
  343. resources["global_content_filter"] = {
  344. "include_code": not self.exclude_code_cell,
  345. "include_markdown": not self.exclude_markdown,
  346. "include_raw": not self.exclude_raw,
  347. "include_unknown": not self.exclude_unknown,
  348. "include_input": not self.exclude_input,
  349. "include_output": not self.exclude_output,
  350. "include_output_stdin": not self.exclude_output_stdin,
  351. "include_input_prompt": not self.exclude_input_prompt,
  352. "include_output_prompt": not self.exclude_output_prompt,
  353. "no_prompt": self.exclude_input_prompt and self.exclude_output_prompt,
  354. }
  355. # Top level variables are passed to the template_exporter here.
  356. output = self.template.render(nb=nb_copy, resources=resources)
  357. output = output.lstrip("\r\n")
  358. return output, resources
  359. def _register_filter(self, environ, name, jinja_filter):
  360. """
  361. Register a filter.
  362. A filter is a function that accepts and acts on one string.
  363. The filters are accessible within the Jinja templating engine.
  364. Parameters
  365. ----------
  366. name : str
  367. name to give the filter in the Jinja engine
  368. filter : filter
  369. """
  370. if jinja_filter is None:
  371. msg = "filter"
  372. raise TypeError(msg)
  373. isclass = isinstance(jinja_filter, type)
  374. constructed = not isclass
  375. # Handle filter's registration based on it's type
  376. if constructed and isinstance(jinja_filter, (str,)):
  377. # filter is a string, import the namespace and recursively call
  378. # this register_filter method
  379. filter_cls = import_item(jinja_filter)
  380. return self._register_filter(environ, name, filter_cls)
  381. if constructed and callable(jinja_filter):
  382. # filter is a function, no need to construct it.
  383. environ.filters[name] = jinja_filter
  384. return jinja_filter
  385. if isclass and issubclass(jinja_filter, HasTraits):
  386. # filter is configurable. Make sure to pass in new default for
  387. # the enabled flag if one was specified.
  388. filter_instance = jinja_filter(parent=self)
  389. self._register_filter(environ, name, filter_instance)
  390. return None
  391. if isclass:
  392. # filter is not configurable, construct it
  393. filter_instance = jinja_filter()
  394. self._register_filter(environ, name, filter_instance)
  395. return None
  396. # filter is an instance of something without a __call__
  397. # attribute.
  398. msg = "filter"
  399. raise TypeError(msg)
  400. def register_filter(self, name, jinja_filter):
  401. """
  402. Register a filter.
  403. A filter is a function that accepts and acts on one string.
  404. The filters are accessible within the Jinja templating engine.
  405. Parameters
  406. ----------
  407. name : str
  408. name to give the filter in the Jinja engine
  409. filter : filter
  410. """
  411. return self._register_filter(self.environment, name, jinja_filter)
  412. def default_filters(self):
  413. """Override in subclasses to provide extra filters.
  414. This should return an iterable of 2-tuples: (name, class-or-function).
  415. You should call the method on the parent class and include the filters
  416. it provides.
  417. If a name is repeated, the last filter provided wins. Filters from
  418. user-supplied config win over filters provided by classes.
  419. """
  420. return default_filters.items()
  421. def _create_environment(self):
  422. """
  423. Create the Jinja templating environment.
  424. """
  425. paths = self.template_paths
  426. self.log.debug("Template paths:\n\t%s", "\n\t".join(paths))
  427. loaders = [
  428. *self.extra_loaders,
  429. ExtensionTolerantLoader(FileSystemLoader(paths), self.template_extension),
  430. DictLoader({self._raw_template_key: self.raw_template}),
  431. ]
  432. environment = Environment( # noqa: S701
  433. loader=ChoiceLoader(loaders),
  434. extensions=JINJA_EXTENSIONS,
  435. enable_async=self.enable_async,
  436. )
  437. environment.globals["uuid4"] = uuid.uuid4
  438. # Add default filters to the Jinja2 environment
  439. for key, value in self.default_filters():
  440. self._register_filter(environment, key, value)
  441. # Load user filters. Overwrite existing filters if need be.
  442. if self.filters:
  443. for key, user_filter in self.filters.items():
  444. self._register_filter(environment, key, user_filter)
  445. return environment
  446. def _init_preprocessors(self):
  447. super()._init_preprocessors()
  448. conf = self._get_conf()
  449. preprocessors = conf.get("preprocessors", {})
  450. # preprocessors is a dict for three reasons
  451. # * We rely on recursive_update, which can only merge dicts, lists will be overwritten
  452. # * We can use the key with numerical prefixing to guarantee ordering (/etc/*.d/XY-file style)
  453. # * We can disable preprocessors by overwriting the value with None
  454. for _, preprocessor in sorted(preprocessors.items(), key=lambda x: x[0]):
  455. if preprocessor is not None:
  456. kwargs = preprocessor.copy()
  457. preprocessor_cls = kwargs.pop("type")
  458. preprocessor_cls = import_item(preprocessor_cls)
  459. if preprocessor_cls.__name__ in self.config:
  460. kwargs.update(self.config[preprocessor_cls.__name__])
  461. preprocessor = preprocessor_cls(**kwargs) # noqa: PLW2901
  462. self.register_preprocessor(preprocessor)
  463. def _get_conf(self):
  464. conf: dict[str, t.Any] = {} # the configuration once all conf files are merged
  465. for path in map(Path, self.template_paths):
  466. conf_path = path / "conf.json"
  467. try:
  468. conf_path_exists = conf_path.exists()
  469. except PermissionError:
  470. # for Python <3.14
  471. pass
  472. else:
  473. if conf_path_exists:
  474. with conf_path.open() as f:
  475. conf = recursive_update(conf, json.load(f))
  476. return conf
  477. @default("template_paths")
  478. def _template_paths(self, prune=True, root_dirs=None):
  479. paths = []
  480. root_dirs = self.get_prefix_root_dirs()
  481. template_names = self.get_template_names()
  482. for template_name in template_names:
  483. for base_dir in self.extra_template_basedirs:
  484. path = os.path.join(base_dir, template_name)
  485. try:
  486. if not prune or os.path.exists(path):
  487. paths.append(path)
  488. except PermissionError:
  489. pass
  490. for root_dir in root_dirs:
  491. base_dir = os.path.join(root_dir, "nbconvert", "templates")
  492. path = os.path.join(base_dir, template_name)
  493. try:
  494. if not prune or os.path.exists(path):
  495. paths.append(path)
  496. except PermissionError:
  497. pass
  498. for root_dir in root_dirs:
  499. # we include root_dir for when we want to be very explicit, e.g.
  500. # {% extends 'nbconvert/templates/classic/base.html' %}
  501. paths.append(root_dir)
  502. # we include base_dir for when we want to be explicit, but less than root_dir, e.g.
  503. # {% extends 'classic/base.html' %}
  504. base_dir = os.path.join(root_dir, "nbconvert", "templates")
  505. paths.append(base_dir)
  506. compatibility_dir = os.path.join(root_dir, "nbconvert", "templates", "compatibility")
  507. paths.append(compatibility_dir)
  508. additional_paths = []
  509. for path in self.template_data_paths:
  510. if not prune or os.path.exists(path):
  511. additional_paths.append(path)
  512. return paths + self.extra_template_paths + additional_paths
  513. @classmethod
  514. def get_compatibility_base_template_conf(cls, name):
  515. """Get the base template config."""
  516. # Hard-coded base template confs to use for backwards compatibility for 5.x-only templates
  517. if name == "display_priority":
  518. return {"base_template": "base"}
  519. if name == "full":
  520. return {"base_template": "classic", "mimetypes": {"text/html": True}}
  521. return None
  522. def get_template_names(self):
  523. """Finds a list of template names where each successive template name is the base template"""
  524. template_names = []
  525. root_dirs = self.get_prefix_root_dirs()
  526. base_template: str | None = self.template_name
  527. merged_conf: dict[str, t.Any] = {} # the configuration once all conf files are merged
  528. while base_template is not None:
  529. template_names.append(base_template)
  530. conf: dict[str, t.Any] = {}
  531. found_at_least_one = False
  532. for base_dir in self.extra_template_basedirs:
  533. template_dir = os.path.join(base_dir, base_template)
  534. if os.path.exists(template_dir):
  535. found_at_least_one = True
  536. conf_file = os.path.join(template_dir, "conf.json")
  537. if os.path.exists(conf_file):
  538. with open(conf_file) as f:
  539. conf = recursive_update(json.load(f), conf)
  540. for root_dir in root_dirs:
  541. template_dir = os.path.join(root_dir, "nbconvert", "templates", base_template)
  542. if os.path.exists(template_dir):
  543. found_at_least_one = True
  544. conf_file = os.path.join(template_dir, "conf.json")
  545. if os.path.exists(conf_file):
  546. with open(conf_file) as f:
  547. conf = recursive_update(json.load(f), conf)
  548. if not found_at_least_one:
  549. # Check for backwards compatibility template names
  550. for root_dir in root_dirs:
  551. compatibility_file = base_template + ".tpl"
  552. compatibility_path = os.path.join(
  553. root_dir, "nbconvert", "templates", "compatibility", compatibility_file
  554. )
  555. if os.path.exists(compatibility_path):
  556. found_at_least_one = True
  557. warnings.warn(
  558. f"5.x template name passed '{self.template_name}'. Use 'lab' or 'classic' for new template usage.",
  559. DeprecationWarning,
  560. stacklevel=2,
  561. )
  562. self.template_file = compatibility_file
  563. conf = self.get_compatibility_base_template_conf(base_template)
  564. self.template_name = t.cast(str, conf.get("base_template"))
  565. break
  566. if not found_at_least_one:
  567. paths = "\n\t".join(root_dirs)
  568. msg = f"No template sub-directory with name {base_template!r} found in the following paths:\n\t{paths}"
  569. raise ValueError(msg)
  570. merged_conf = recursive_update(dict(conf), merged_conf)
  571. base_template = t.cast(t.Any, conf.get("base_template"))
  572. conf = merged_conf
  573. mimetypes = [mimetype for mimetype, enabled in conf.get("mimetypes", {}).items() if enabled]
  574. if self.output_mimetype and self.output_mimetype not in mimetypes and mimetypes:
  575. supported_mimetypes = "\n\t".join(mimetypes)
  576. msg = f"Unsupported mimetype {self.output_mimetype!r} for template {self.template_name!r}, mimetypes supported are: \n\t{supported_mimetypes}"
  577. raise ValueError(msg)
  578. return template_names
  579. def get_prefix_root_dirs(self):
  580. """Get the prefix root dirs."""
  581. # We look at the usual jupyter locations, and for development purposes also
  582. # relative to the package directory (first entry, meaning with highest precedence)
  583. root_dirs = []
  584. if DEV_MODE:
  585. root_dirs.append(os.path.abspath(os.path.join(ROOT, "..", "..", "share", "jupyter")))
  586. root_dirs.extend(jupyter_path())
  587. return root_dirs
  588. def _init_resources(self, resources):
  589. resources = super()._init_resources(resources)
  590. resources["deprecated"] = deprecated
  591. return resources