##// END OF EJS Templates
don't allow 'template' to specify 'template_file'...
MinRK -
Show More
@@ -1,509 +1,510 b''
1 """This module defines Exporter, a highly configurable converter
1 """This module defines Exporter, a highly configurable converter
2 that uses Jinja2 to export notebook files into different formats.
2 that uses Jinja2 to export notebook files into different formats.
3 """
3 """
4
4
5 #-----------------------------------------------------------------------------
5 #-----------------------------------------------------------------------------
6 # Copyright (c) 2013, the IPython Development Team.
6 # Copyright (c) 2013, the IPython Development Team.
7 #
7 #
8 # Distributed under the terms of the Modified BSD License.
8 # Distributed under the terms of the Modified BSD License.
9 #
9 #
10 # The full license is in the file COPYING.txt, distributed with this software.
10 # The full license is in the file COPYING.txt, distributed with this software.
11 #-----------------------------------------------------------------------------
11 #-----------------------------------------------------------------------------
12
12
13 #-----------------------------------------------------------------------------
13 #-----------------------------------------------------------------------------
14 # Imports
14 # Imports
15 #-----------------------------------------------------------------------------
15 #-----------------------------------------------------------------------------
16
16
17 from __future__ import print_function, absolute_import
17 from __future__ import print_function, absolute_import
18
18
19 # Stdlib imports
19 # Stdlib imports
20 import io
20 import io
21 import os
21 import os
22 import inspect
22 import inspect
23 import copy
23 import copy
24 import collections
24 import collections
25 import datetime
25 import datetime
26
26
27 # other libs/dependencies
27 # other libs/dependencies
28 from jinja2 import Environment, FileSystemLoader, ChoiceLoader, TemplateNotFound
28 from jinja2 import Environment, FileSystemLoader, ChoiceLoader, TemplateNotFound
29
29
30 # IPython imports
30 # IPython imports
31 from IPython.config.configurable import LoggingConfigurable
31 from IPython.config.configurable import LoggingConfigurable
32 from IPython.config import Config
32 from IPython.config import Config
33 from IPython.nbformat import current as nbformat
33 from IPython.nbformat import current as nbformat
34 from IPython.utils.traitlets import MetaHasTraits, DottedObjectName, Unicode, List, Dict, Any
34 from IPython.utils.traitlets import MetaHasTraits, DottedObjectName, Unicode, List, Dict, Any
35 from IPython.utils.importstring import import_item
35 from IPython.utils.importstring import import_item
36 from IPython.utils.text import indent
36 from IPython.utils.text import indent
37 from IPython.utils import py3compat
37 from IPython.utils import py3compat
38
38
39 from IPython.nbconvert import transformers as nbtransformers
39 from IPython.nbconvert import transformers as nbtransformers
40 from IPython.nbconvert import filters
40 from IPython.nbconvert import filters
41
41
42 #-----------------------------------------------------------------------------
42 #-----------------------------------------------------------------------------
43 # Globals and constants
43 # Globals and constants
44 #-----------------------------------------------------------------------------
44 #-----------------------------------------------------------------------------
45
45
46 #Jinja2 extensions to load.
46 #Jinja2 extensions to load.
47 JINJA_EXTENSIONS = ['jinja2.ext.loopcontrols']
47 JINJA_EXTENSIONS = ['jinja2.ext.loopcontrols']
48
48
49 default_filters = {
49 default_filters = {
50 'indent': indent,
50 'indent': indent,
51 'markdown2html': filters.markdown2html,
51 'markdown2html': filters.markdown2html,
52 'ansi2html': filters.ansi2html,
52 'ansi2html': filters.ansi2html,
53 'filter_data_type': filters.DataTypeFilter,
53 'filter_data_type': filters.DataTypeFilter,
54 'get_lines': filters.get_lines,
54 'get_lines': filters.get_lines,
55 'highlight2html': filters.highlight2html,
55 'highlight2html': filters.highlight2html,
56 'highlight2latex': filters.highlight2latex,
56 'highlight2latex': filters.highlight2latex,
57 'ipython2python': filters.ipython2python,
57 'ipython2python': filters.ipython2python,
58 'markdown2latex': filters.markdown2latex,
58 'markdown2latex': filters.markdown2latex,
59 'markdown2rst': filters.markdown2rst,
59 'markdown2rst': filters.markdown2rst,
60 'comment_lines': filters.comment_lines,
60 'comment_lines': filters.comment_lines,
61 'strip_ansi': filters.strip_ansi,
61 'strip_ansi': filters.strip_ansi,
62 'strip_dollars': filters.strip_dollars,
62 'strip_dollars': filters.strip_dollars,
63 'strip_files_prefix': filters.strip_files_prefix,
63 'strip_files_prefix': filters.strip_files_prefix,
64 'html2text' : filters.html2text,
64 'html2text' : filters.html2text,
65 'add_anchor': filters.add_anchor,
65 'add_anchor': filters.add_anchor,
66 'ansi2latex': filters.ansi2latex,
66 'ansi2latex': filters.ansi2latex,
67 'strip_math_space': filters.strip_math_space,
67 'strip_math_space': filters.strip_math_space,
68 'wrap_text': filters.wrap_text,
68 'wrap_text': filters.wrap_text,
69 'escape_latex': filters.escape_latex
69 'escape_latex': filters.escape_latex
70 }
70 }
71
71
72 #-----------------------------------------------------------------------------
72 #-----------------------------------------------------------------------------
73 # Class
73 # Class
74 #-----------------------------------------------------------------------------
74 #-----------------------------------------------------------------------------
75
75
76 class ResourcesDict(collections.defaultdict):
76 class ResourcesDict(collections.defaultdict):
77 def __missing__(self, key):
77 def __missing__(self, key):
78 return ''
78 return ''
79
79
80
80
81 class Exporter(LoggingConfigurable):
81 class Exporter(LoggingConfigurable):
82 """
82 """
83 Exports notebooks into other file formats. Uses Jinja 2 templating engine
83 Exports notebooks into other file formats. Uses Jinja 2 templating engine
84 to output new formats. Inherit from this class if you are creating a new
84 to output new formats. Inherit from this class if you are creating a new
85 template type along with new filters/transformers. If the filters/
85 template type along with new filters/transformers. If the filters/
86 transformers provided by default suffice, there is no need to inherit from
86 transformers provided by default suffice, there is no need to inherit from
87 this class. Instead, override the template_file and file_extension
87 this class. Instead, override the template_file and file_extension
88 traits via a config file.
88 traits via a config file.
89
89
90 {filters}
90 {filters}
91 """
91 """
92
92
93 # finish the docstring
93 # finish the docstring
94 __doc__ = __doc__.format(filters = '- '+'\n - '.join(default_filters.keys()))
94 __doc__ = __doc__.format(filters = '- '+'\n - '.join(default_filters.keys()))
95
95
96
96
97 template_file = Unicode(u'default',
97 template_file = Unicode(u'default',
98 config=True,
98 config=True,
99 help="Name of the template file to use")
99 help="Name of the template file to use")
100 def _template_file_changed(self, name, old, new):
100 def _template_file_changed(self, name, old, new):
101 if new=='default':
101 if new=='default':
102 self.template_file = self.default_template
102 self.template_file = self.default_template
103 else:
103 else:
104 self.template_file = new
104 self.template_file = new
105 self._load_template()
105 self._load_template()
106
106
107 default_template = Unicode(u'')
107 default_template = Unicode(u'')
108 template = Any()
108 template = Any()
109 environment = Any()
109 environment = Any()
110
110
111 file_extension = Unicode(
111 file_extension = Unicode(
112 'txt', config=True,
112 'txt', config=True,
113 help="Extension of the file that should be written to disk"
113 help="Extension of the file that should be written to disk"
114 )
114 )
115
115
116 template_path = List(['.'], config=True)
116 template_path = List(['.'], config=True)
117 def _template_path_changed(self, name, old, new):
117 def _template_path_changed(self, name, old, new):
118 self._load_template()
118 self._load_template()
119
119
120 default_template_path = Unicode(
120 default_template_path = Unicode(
121 os.path.join("..", "templates"),
121 os.path.join("..", "templates"),
122 help="Path where the template files are located.")
122 help="Path where the template files are located.")
123
123
124 template_skeleton_path = Unicode(
124 template_skeleton_path = Unicode(
125 os.path.join("..", "templates", "skeleton"),
125 os.path.join("..", "templates", "skeleton"),
126 help="Path where the template skeleton files are located.")
126 help="Path where the template skeleton files are located.")
127
127
128 #Jinja block definitions
128 #Jinja block definitions
129 jinja_comment_block_start = Unicode("", config=True)
129 jinja_comment_block_start = Unicode("", config=True)
130 jinja_comment_block_end = Unicode("", config=True)
130 jinja_comment_block_end = Unicode("", config=True)
131 jinja_variable_block_start = Unicode("", config=True)
131 jinja_variable_block_start = Unicode("", config=True)
132 jinja_variable_block_end = Unicode("", config=True)
132 jinja_variable_block_end = Unicode("", config=True)
133 jinja_logic_block_start = Unicode("", config=True)
133 jinja_logic_block_start = Unicode("", config=True)
134 jinja_logic_block_end = Unicode("", config=True)
134 jinja_logic_block_end = Unicode("", config=True)
135
135
136 #Extension that the template files use.
136 #Extension that the template files use.
137 template_extension = Unicode(".tpl", config=True)
137 template_extension = Unicode(".tpl", config=True)
138
138
139 #Configurability, allows the user to easily add filters and transformers.
139 #Configurability, allows the user to easily add filters and transformers.
140 transformers = List(config=True,
140 transformers = List(config=True,
141 help="""List of transformers, by name or namespace, to enable.""")
141 help="""List of transformers, by name or namespace, to enable.""")
142
142
143 filters = Dict(config=True,
143 filters = Dict(config=True,
144 help="""Dictionary of filters, by name and namespace, to add to the Jinja
144 help="""Dictionary of filters, by name and namespace, to add to the Jinja
145 environment.""")
145 environment.""")
146
146
147 default_transformers = List([nbtransformers.coalesce_streams,
147 default_transformers = List([nbtransformers.coalesce_streams,
148 nbtransformers.SVG2PDFTransformer,
148 nbtransformers.SVG2PDFTransformer,
149 nbtransformers.ExtractOutputTransformer,
149 nbtransformers.ExtractOutputTransformer,
150 nbtransformers.CSSHTMLHeaderTransformer,
150 nbtransformers.CSSHTMLHeaderTransformer,
151 nbtransformers.RevealHelpTransformer,
151 nbtransformers.RevealHelpTransformer,
152 nbtransformers.LatexTransformer,
152 nbtransformers.LatexTransformer,
153 nbtransformers.SphinxTransformer],
153 nbtransformers.SphinxTransformer],
154 config=True,
154 config=True,
155 help="""List of transformers available by default, by name, namespace,
155 help="""List of transformers available by default, by name, namespace,
156 instance, or type.""")
156 instance, or type.""")
157
157
158
158
159 def __init__(self, config=None, extra_loaders=None, **kw):
159 def __init__(self, config=None, extra_loaders=None, **kw):
160 """
160 """
161 Public constructor
161 Public constructor
162
162
163 Parameters
163 Parameters
164 ----------
164 ----------
165 config : config
165 config : config
166 User configuration instance.
166 User configuration instance.
167 extra_loaders : list[of Jinja Loaders]
167 extra_loaders : list[of Jinja Loaders]
168 ordered list of Jinja loader to find templates. Will be tried in order
168 ordered list of Jinja loader to find templates. Will be tried in order
169 before the default FileSystem ones.
169 before the default FileSystem ones.
170 template : str (optional, kw arg)
170 template : str (optional, kw arg)
171 Template to use when exporting.
171 Template to use when exporting.
172 """
172 """
173 if not config:
174 config = self.default_config
173
175
174 super(Exporter, self).__init__(config=config, **kw)
176 super(Exporter, self).__init__(config=config, **kw)
175
177
176 #Init
178 #Init
177 self._init_template(**kw)
179 self._init_template()
178 self._init_environment(extra_loaders=extra_loaders)
180 self._init_environment(extra_loaders=extra_loaders)
179 self._init_transformers()
181 self._init_transformers()
180 self._init_filters()
182 self._init_filters()
181
183
182
184
183 @property
185 @property
184 def default_config(self):
186 def default_config(self):
185 return Config()
187 return Config()
186
188
187 def _config_changed(self, name, old, new):
189 def _config_changed(self, name, old, new):
188 """When setting config, make sure to start with our default_config"""
190 """When setting config, make sure to start with our default_config"""
189 c = self.default_config
191 c = self.default_config
190 if new:
192 if new:
191 c.merge(new)
193 c.merge(new)
192 if c != new:
194 if c != old:
193 self.config = c
195 self.config = c
196 super(Exporter, self)._config_changed(name, old, c)
194
197
195
198
196 def _load_template(self):
199 def _load_template(self):
197 """Load the Jinja template object from the template file
200 """Load the Jinja template object from the template file
198
201
199 This is a no-op if the template attribute is already defined,
202 This is a no-op if the template attribute is already defined,
200 or the Jinja environment is not setup yet.
203 or the Jinja environment is not setup yet.
201
204
202 This is triggered by various trait changes that would change the template.
205 This is triggered by various trait changes that would change the template.
203 """
206 """
204 if self.template is not None:
207 if self.template is not None:
205 return
208 return
206 # called too early, do nothing
209 # called too early, do nothing
207 if self.environment is None:
210 if self.environment is None:
208 return
211 return
209 # Try different template names during conversion. First try to load the
212 # Try different template names during conversion. First try to load the
210 # template by name with extension added, then try loading the template
213 # template by name with extension added, then try loading the template
211 # as if the name is explicitly specified, then try the name as a
214 # as if the name is explicitly specified, then try the name as a
212 # 'flavor', and lastly just try to load the template by module name.
215 # 'flavor', and lastly just try to load the template by module name.
213 module_name = self.__module__.rsplit('.', 1)[-1]
216 module_name = self.__module__.rsplit('.', 1)[-1]
214 try_names = [self.template_file + self.template_extension,
217 try_names = [self.template_file + self.template_extension,
215 self.template_file,
218 self.template_file,
216 module_name + '_' + self.template_file + self.template_extension,
219 module_name + '_' + self.template_file + self.template_extension,
217 module_name + self.template_extension]
220 module_name + self.template_extension]
218 for try_name in try_names:
221 for try_name in try_names:
219 self.log.debug("Attempting to load template %s", try_name)
222 self.log.debug("Attempting to load template %s", try_name)
220 try:
223 try:
221 self.template = self.environment.get_template(try_name)
224 self.template = self.environment.get_template(try_name)
222 except TemplateNotFound:
225 except TemplateNotFound:
223 pass
226 pass
224 else:
227 else:
225 self.log.info("Loaded template %s", try_name)
228 self.log.info("Loaded template %s", try_name)
226 break
229 break
227
230
228 def from_notebook_node(self, nb, resources=None, **kw):
231 def from_notebook_node(self, nb, resources=None, **kw):
229 """
232 """
230 Convert a notebook from a notebook node instance.
233 Convert a notebook from a notebook node instance.
231
234
232 Parameters
235 Parameters
233 ----------
236 ----------
234 nb : Notebook node
237 nb : Notebook node
235 resources : dict (**kw)
238 resources : dict (**kw)
236 of additional resources that can be accessed read/write by
239 of additional resources that can be accessed read/write by
237 transformers and filters.
240 transformers and filters.
238 """
241 """
239 nb_copy = copy.deepcopy(nb)
242 nb_copy = copy.deepcopy(nb)
240 resources = self._init_resources(resources)
243 resources = self._init_resources(resources)
241
244
242 # Preprocess
245 # Preprocess
243 nb_copy, resources = self._transform(nb_copy, resources)
246 nb_copy, resources = self._transform(nb_copy, resources)
244
247
245 self._load_template()
248 self._load_template()
246
249
247 if self.template is not None:
250 if self.template is not None:
248 output = self.template.render(nb=nb_copy, resources=resources)
251 output = self.template.render(nb=nb_copy, resources=resources)
249 else:
252 else:
250 raise IOError('template file "%s" could not be found' % self.template_file)
253 raise IOError('template file "%s" could not be found' % self.template_file)
251 return output, resources
254 return output, resources
252
255
253
256
254 def from_filename(self, filename, resources=None, **kw):
257 def from_filename(self, filename, resources=None, **kw):
255 """
258 """
256 Convert a notebook from a notebook file.
259 Convert a notebook from a notebook file.
257
260
258 Parameters
261 Parameters
259 ----------
262 ----------
260 filename : str
263 filename : str
261 Full filename of the notebook file to open and convert.
264 Full filename of the notebook file to open and convert.
262 """
265 """
263
266
264 #Pull the metadata from the filesystem.
267 #Pull the metadata from the filesystem.
265 if resources is None:
268 if resources is None:
266 resources = ResourcesDict()
269 resources = ResourcesDict()
267 if not 'metadata' in resources or resources['metadata'] == '':
270 if not 'metadata' in resources or resources['metadata'] == '':
268 resources['metadata'] = ResourcesDict()
271 resources['metadata'] = ResourcesDict()
269 basename = os.path.basename(filename)
272 basename = os.path.basename(filename)
270 notebook_name = basename[:basename.rfind('.')]
273 notebook_name = basename[:basename.rfind('.')]
271 resources['metadata']['name'] = notebook_name
274 resources['metadata']['name'] = notebook_name
272
275
273 modified_date = datetime.datetime.fromtimestamp(os.path.getmtime(filename))
276 modified_date = datetime.datetime.fromtimestamp(os.path.getmtime(filename))
274 resources['metadata']['modified_date'] = modified_date.strftime("%B %d, %Y")
277 resources['metadata']['modified_date'] = modified_date.strftime("%B %d, %Y")
275
278
276 with io.open(filename) as f:
279 with io.open(filename) as f:
277 return self.from_notebook_node(nbformat.read(f, 'json'), resources=resources,**kw)
280 return self.from_notebook_node(nbformat.read(f, 'json'), resources=resources,**kw)
278
281
279
282
280 def from_file(self, file_stream, resources=None, **kw):
283 def from_file(self, file_stream, resources=None, **kw):
281 """
284 """
282 Convert a notebook from a notebook file.
285 Convert a notebook from a notebook file.
283
286
284 Parameters
287 Parameters
285 ----------
288 ----------
286 file_stream : file-like object
289 file_stream : file-like object
287 Notebook file-like object to convert.
290 Notebook file-like object to convert.
288 """
291 """
289 return self.from_notebook_node(nbformat.read(file_stream, 'json'), resources=resources, **kw)
292 return self.from_notebook_node(nbformat.read(file_stream, 'json'), resources=resources, **kw)
290
293
291
294
292 def register_transformer(self, transformer, enabled=False):
295 def register_transformer(self, transformer, enabled=False):
293 """
296 """
294 Register a transformer.
297 Register a transformer.
295 Transformers are classes that act upon the notebook before it is
298 Transformers are classes that act upon the notebook before it is
296 passed into the Jinja templating engine. Transformers are also
299 passed into the Jinja templating engine. Transformers are also
297 capable of passing additional information to the Jinja
300 capable of passing additional information to the Jinja
298 templating engine.
301 templating engine.
299
302
300 Parameters
303 Parameters
301 ----------
304 ----------
302 transformer : transformer
305 transformer : transformer
303 """
306 """
304 if transformer is None:
307 if transformer is None:
305 raise TypeError('transformer')
308 raise TypeError('transformer')
306 isclass = isinstance(transformer, type)
309 isclass = isinstance(transformer, type)
307 constructed = not isclass
310 constructed = not isclass
308
311
309 #Handle transformer's registration based on it's type
312 #Handle transformer's registration based on it's type
310 if constructed and isinstance(transformer, py3compat.string_types):
313 if constructed and isinstance(transformer, py3compat.string_types):
311 #Transformer is a string, import the namespace and recursively call
314 #Transformer is a string, import the namespace and recursively call
312 #this register_transformer method
315 #this register_transformer method
313 transformer_cls = import_item(transformer)
316 transformer_cls = import_item(transformer)
314 return self.register_transformer(transformer_cls, enabled)
317 return self.register_transformer(transformer_cls, enabled)
315
318
316 if constructed and hasattr(transformer, '__call__'):
319 if constructed and hasattr(transformer, '__call__'):
317 #Transformer is a function, no need to construct it.
320 #Transformer is a function, no need to construct it.
318 #Register and return the transformer.
321 #Register and return the transformer.
319 if enabled:
322 if enabled:
320 transformer.enabled = True
323 transformer.enabled = True
321 self._transformers.append(transformer)
324 self._transformers.append(transformer)
322 return transformer
325 return transformer
323
326
324 elif isclass and isinstance(transformer, MetaHasTraits):
327 elif isclass and isinstance(transformer, MetaHasTraits):
325 #Transformer is configurable. Make sure to pass in new default for
328 #Transformer is configurable. Make sure to pass in new default for
326 #the enabled flag if one was specified.
329 #the enabled flag if one was specified.
327 self.register_transformer(transformer(parent=self), enabled)
330 self.register_transformer(transformer(parent=self), enabled)
328
331
329 elif isclass:
332 elif isclass:
330 #Transformer is not configurable, construct it
333 #Transformer is not configurable, construct it
331 self.register_transformer(transformer(), enabled)
334 self.register_transformer(transformer(), enabled)
332
335
333 else:
336 else:
334 #Transformer is an instance of something without a __call__
337 #Transformer is an instance of something without a __call__
335 #attribute.
338 #attribute.
336 raise TypeError('transformer')
339 raise TypeError('transformer')
337
340
338
341
339 def register_filter(self, name, jinja_filter):
342 def register_filter(self, name, jinja_filter):
340 """
343 """
341 Register a filter.
344 Register a filter.
342 A filter is a function that accepts and acts on one string.
345 A filter is a function that accepts and acts on one string.
343 The filters are accesible within the Jinja templating engine.
346 The filters are accesible within the Jinja templating engine.
344
347
345 Parameters
348 Parameters
346 ----------
349 ----------
347 name : str
350 name : str
348 name to give the filter in the Jinja engine
351 name to give the filter in the Jinja engine
349 filter : filter
352 filter : filter
350 """
353 """
351 if jinja_filter is None:
354 if jinja_filter is None:
352 raise TypeError('filter')
355 raise TypeError('filter')
353 isclass = isinstance(jinja_filter, type)
356 isclass = isinstance(jinja_filter, type)
354 constructed = not isclass
357 constructed = not isclass
355
358
356 #Handle filter's registration based on it's type
359 #Handle filter's registration based on it's type
357 if constructed and isinstance(jinja_filter, py3compat.string_types):
360 if constructed and isinstance(jinja_filter, py3compat.string_types):
358 #filter is a string, import the namespace and recursively call
361 #filter is a string, import the namespace and recursively call
359 #this register_filter method
362 #this register_filter method
360 filter_cls = import_item(jinja_filter)
363 filter_cls = import_item(jinja_filter)
361 return self.register_filter(name, filter_cls)
364 return self.register_filter(name, filter_cls)
362
365
363 if constructed and hasattr(jinja_filter, '__call__'):
366 if constructed and hasattr(jinja_filter, '__call__'):
364 #filter is a function, no need to construct it.
367 #filter is a function, no need to construct it.
365 self.environment.filters[name] = jinja_filter
368 self.environment.filters[name] = jinja_filter
366 return jinja_filter
369 return jinja_filter
367
370
368 elif isclass and isinstance(jinja_filter, MetaHasTraits):
371 elif isclass and isinstance(jinja_filter, MetaHasTraits):
369 #filter is configurable. Make sure to pass in new default for
372 #filter is configurable. Make sure to pass in new default for
370 #the enabled flag if one was specified.
373 #the enabled flag if one was specified.
371 filter_instance = jinja_filter(parent=self)
374 filter_instance = jinja_filter(parent=self)
372 self.register_filter(name, filter_instance )
375 self.register_filter(name, filter_instance )
373
376
374 elif isclass:
377 elif isclass:
375 #filter is not configurable, construct it
378 #filter is not configurable, construct it
376 filter_instance = jinja_filter()
379 filter_instance = jinja_filter()
377 self.register_filter(name, filter_instance)
380 self.register_filter(name, filter_instance)
378
381
379 else:
382 else:
380 #filter is an instance of something without a __call__
383 #filter is an instance of something without a __call__
381 #attribute.
384 #attribute.
382 raise TypeError('filter')
385 raise TypeError('filter')
383
386
384
387
385 def _init_template(self, **kw):
388 def _init_template(self):
386 """
389 """
387 Make sure a template name is specified. If one isn't specified, try to
390 Make sure a template name is specified. If one isn't specified, try to
388 build one from the information we know.
391 build one from the information we know.
389 """
392 """
390 self._template_file_changed('template_file', self.template_file, self.template_file)
393 self._template_file_changed('template_file', self.template_file, self.template_file)
391 if 'template' in kw:
392 self.template_file = kw['template']
393
394
394
395
395 def _init_environment(self, extra_loaders=None):
396 def _init_environment(self, extra_loaders=None):
396 """
397 """
397 Create the Jinja templating environment.
398 Create the Jinja templating environment.
398 """
399 """
399 here = os.path.dirname(os.path.realpath(__file__))
400 here = os.path.dirname(os.path.realpath(__file__))
400 loaders = []
401 loaders = []
401 if extra_loaders:
402 if extra_loaders:
402 loaders.extend(extra_loaders)
403 loaders.extend(extra_loaders)
403
404
404 paths = self.template_path
405 paths = self.template_path
405 paths.extend([os.path.join(here, self.default_template_path),
406 paths.extend([os.path.join(here, self.default_template_path),
406 os.path.join(here, self.template_skeleton_path)])
407 os.path.join(here, self.template_skeleton_path)])
407 loaders.append(FileSystemLoader(paths))
408 loaders.append(FileSystemLoader(paths))
408
409
409 self.environment = Environment(
410 self.environment = Environment(
410 loader= ChoiceLoader(loaders),
411 loader= ChoiceLoader(loaders),
411 extensions=JINJA_EXTENSIONS
412 extensions=JINJA_EXTENSIONS
412 )
413 )
413
414
414 #Set special Jinja2 syntax that will not conflict with latex.
415 #Set special Jinja2 syntax that will not conflict with latex.
415 if self.jinja_logic_block_start:
416 if self.jinja_logic_block_start:
416 self.environment.block_start_string = self.jinja_logic_block_start
417 self.environment.block_start_string = self.jinja_logic_block_start
417 if self.jinja_logic_block_end:
418 if self.jinja_logic_block_end:
418 self.environment.block_end_string = self.jinja_logic_block_end
419 self.environment.block_end_string = self.jinja_logic_block_end
419 if self.jinja_variable_block_start:
420 if self.jinja_variable_block_start:
420 self.environment.variable_start_string = self.jinja_variable_block_start
421 self.environment.variable_start_string = self.jinja_variable_block_start
421 if self.jinja_variable_block_end:
422 if self.jinja_variable_block_end:
422 self.environment.variable_end_string = self.jinja_variable_block_end
423 self.environment.variable_end_string = self.jinja_variable_block_end
423 if self.jinja_comment_block_start:
424 if self.jinja_comment_block_start:
424 self.environment.comment_start_string = self.jinja_comment_block_start
425 self.environment.comment_start_string = self.jinja_comment_block_start
425 if self.jinja_comment_block_end:
426 if self.jinja_comment_block_end:
426 self.environment.comment_end_string = self.jinja_comment_block_end
427 self.environment.comment_end_string = self.jinja_comment_block_end
427
428
428
429
429 def _init_transformers(self):
430 def _init_transformers(self):
430 """
431 """
431 Register all of the transformers needed for this exporter, disabled
432 Register all of the transformers needed for this exporter, disabled
432 unless specified explicitly.
433 unless specified explicitly.
433 """
434 """
434 self._transformers = []
435 self._transformers = []
435
436
436 #Load default transformers (not necessarly enabled by default).
437 #Load default transformers (not necessarly enabled by default).
437 if self.default_transformers:
438 if self.default_transformers:
438 for transformer in self.default_transformers:
439 for transformer in self.default_transformers:
439 self.register_transformer(transformer)
440 self.register_transformer(transformer)
440
441
441 #Load user transformers. Enable by default.
442 #Load user transformers. Enable by default.
442 if self.transformers:
443 if self.transformers:
443 for transformer in self.transformers:
444 for transformer in self.transformers:
444 self.register_transformer(transformer, enabled=True)
445 self.register_transformer(transformer, enabled=True)
445
446
446
447
447 def _init_filters(self):
448 def _init_filters(self):
448 """
449 """
449 Register all of the filters required for the exporter.
450 Register all of the filters required for the exporter.
450 """
451 """
451
452
452 #Add default filters to the Jinja2 environment
453 #Add default filters to the Jinja2 environment
453 for key, value in default_filters.items():
454 for key, value in default_filters.items():
454 self.register_filter(key, value)
455 self.register_filter(key, value)
455
456
456 #Load user filters. Overwrite existing filters if need be.
457 #Load user filters. Overwrite existing filters if need be.
457 if self.filters:
458 if self.filters:
458 for key, user_filter in self.filters.items():
459 for key, user_filter in self.filters.items():
459 self.register_filter(key, user_filter)
460 self.register_filter(key, user_filter)
460
461
461
462
462 def _init_resources(self, resources):
463 def _init_resources(self, resources):
463
464
464 #Make sure the resources dict is of ResourcesDict type.
465 #Make sure the resources dict is of ResourcesDict type.
465 if resources is None:
466 if resources is None:
466 resources = ResourcesDict()
467 resources = ResourcesDict()
467 if not isinstance(resources, ResourcesDict):
468 if not isinstance(resources, ResourcesDict):
468 new_resources = ResourcesDict()
469 new_resources = ResourcesDict()
469 new_resources.update(resources)
470 new_resources.update(resources)
470 resources = new_resources
471 resources = new_resources
471
472
472 #Make sure the metadata extension exists in resources
473 #Make sure the metadata extension exists in resources
473 if 'metadata' in resources:
474 if 'metadata' in resources:
474 if not isinstance(resources['metadata'], ResourcesDict):
475 if not isinstance(resources['metadata'], ResourcesDict):
475 resources['metadata'] = ResourcesDict(resources['metadata'])
476 resources['metadata'] = ResourcesDict(resources['metadata'])
476 else:
477 else:
477 resources['metadata'] = ResourcesDict()
478 resources['metadata'] = ResourcesDict()
478 if not resources['metadata']['name']:
479 if not resources['metadata']['name']:
479 resources['metadata']['name'] = 'Notebook'
480 resources['metadata']['name'] = 'Notebook'
480
481
481 #Set the output extension
482 #Set the output extension
482 resources['output_extension'] = self.file_extension
483 resources['output_extension'] = self.file_extension
483 return resources
484 return resources
484
485
485
486
486 def _transform(self, nb, resources):
487 def _transform(self, nb, resources):
487 """
488 """
488 Preprocess the notebook before passing it into the Jinja engine.
489 Preprocess the notebook before passing it into the Jinja engine.
489 To preprocess the notebook is to apply all of the
490 To preprocess the notebook is to apply all of the
490
491
491 Parameters
492 Parameters
492 ----------
493 ----------
493 nb : notebook node
494 nb : notebook node
494 notebook that is being exported.
495 notebook that is being exported.
495 resources : a dict of additional resources that
496 resources : a dict of additional resources that
496 can be accessed read/write by transformers
497 can be accessed read/write by transformers
497 and filters.
498 and filters.
498 """
499 """
499
500
500 # Do a copy.deepcopy first,
501 # Do a copy.deepcopy first,
501 # we are never safe enough with what the transformers could do.
502 # we are never safe enough with what the transformers could do.
502 nbc = copy.deepcopy(nb)
503 nbc = copy.deepcopy(nb)
503 resc = copy.deepcopy(resources)
504 resc = copy.deepcopy(resources)
504
505
505 #Run each transformer on the notebook. Carry the output along
506 #Run each transformer on the notebook. Carry the output along
506 #to each transformer
507 #to each transformer
507 for transformer in self._transformers:
508 for transformer in self._transformers:
508 nbc, resc = transformer(nbc, resc)
509 nbc, resc = transformer(nbc, resc)
509 return nbc, resc
510 return nbc, resc
@@ -1,113 +1,112 b''
1 """
1 """
2 Module with tests for exporter.py
2 Module with tests for exporter.py
3 """
3 """
4
4
5 #-----------------------------------------------------------------------------
5 #-----------------------------------------------------------------------------
6 # Copyright (c) 2013, the IPython Development Team.
6 # Copyright (c) 2013, the IPython Development Team.
7 #
7 #
8 # Distributed under the terms of the Modified BSD License.
8 # Distributed under the terms of the Modified BSD License.
9 #
9 #
10 # The full license is in the file COPYING.txt, distributed with this software.
10 # The full license is in the file COPYING.txt, distributed with this software.
11 #-----------------------------------------------------------------------------
11 #-----------------------------------------------------------------------------
12
12
13 #-----------------------------------------------------------------------------
13 #-----------------------------------------------------------------------------
14 # Imports
14 # Imports
15 #-----------------------------------------------------------------------------
15 #-----------------------------------------------------------------------------
16
16
17 from IPython.config import Config
17 from IPython.config import Config
18
18
19 from .base import ExportersTestsBase
19 from .base import ExportersTestsBase
20 from .cheese import CheeseTransformer
20 from .cheese import CheeseTransformer
21 from ..exporter import Exporter
21 from ..exporter import Exporter
22
22
23
23
24 #-----------------------------------------------------------------------------
24 #-----------------------------------------------------------------------------
25 # Class
25 # Class
26 #-----------------------------------------------------------------------------
26 #-----------------------------------------------------------------------------
27
27
28 class TestExporter(ExportersTestsBase):
28 class TestExporter(ExportersTestsBase):
29 """Contains test functions for exporter.py"""
29 """Contains test functions for exporter.py"""
30
30
31
31
32 def test_constructor(self):
32 def test_constructor(self):
33 """
33 """
34 Can an Exporter be constructed?
34 Can an Exporter be constructed?
35 """
35 """
36 Exporter()
36 Exporter()
37
37
38
38
39 def test_export(self):
39 def test_export(self):
40 """
40 """
41 Can an Exporter export something?
41 Can an Exporter export something?
42 """
42 """
43 exporter = self._make_exporter()
43 exporter = self._make_exporter()
44 (output, resources) = exporter.from_filename(self._get_notebook())
44 (output, resources) = exporter.from_filename(self._get_notebook())
45 assert len(output) > 0
45 assert len(output) > 0
46
46
47
47
48 def test_extract_outputs(self):
48 def test_extract_outputs(self):
49 """
49 """
50 If the ExtractOutputTransformer is enabled, are outputs extracted?
50 If the ExtractOutputTransformer is enabled, are outputs extracted?
51 """
51 """
52 config = Config({'ExtractOutputTransformer': {'enabled': True}})
52 config = Config({'ExtractOutputTransformer': {'enabled': True}})
53 exporter = self._make_exporter(config=config)
53 exporter = self._make_exporter(config=config)
54 (output, resources) = exporter.from_filename(self._get_notebook())
54 (output, resources) = exporter.from_filename(self._get_notebook())
55 assert resources is not None
55 assert resources is not None
56 assert 'outputs' in resources
56 assert 'outputs' in resources
57 assert len(resources['outputs']) > 0
57 assert len(resources['outputs']) > 0
58
58
59
59
60 def test_transformer_class(self):
60 def test_transformer_class(self):
61 """
61 """
62 Can a transformer be added to the transformers list by class type?
62 Can a transformer be added to the transformers list by class type?
63 """
63 """
64 config = Config({'Exporter': {'transformers': [CheeseTransformer]}})
64 config = Config({'Exporter': {'transformers': [CheeseTransformer]}})
65 exporter = self._make_exporter(config=config)
65 exporter = self._make_exporter(config=config)
66 (output, resources) = exporter.from_filename(self._get_notebook())
66 (output, resources) = exporter.from_filename(self._get_notebook())
67 assert resources is not None
67 assert resources is not None
68 assert 'cheese' in resources
68 assert 'cheese' in resources
69 assert resources['cheese'] == 'real'
69 assert resources['cheese'] == 'real'
70
70
71
71
72 def test_transformer_instance(self):
72 def test_transformer_instance(self):
73 """
73 """
74 Can a transformer be added to the transformers list by instance?
74 Can a transformer be added to the transformers list by instance?
75 """
75 """
76 config = Config({'Exporter': {'transformers': [CheeseTransformer()]}})
76 config = Config({'Exporter': {'transformers': [CheeseTransformer()]}})
77 exporter = self._make_exporter(config=config)
77 exporter = self._make_exporter(config=config)
78 (output, resources) = exporter.from_filename(self._get_notebook())
78 (output, resources) = exporter.from_filename(self._get_notebook())
79 assert resources is not None
79 assert resources is not None
80 assert 'cheese' in resources
80 assert 'cheese' in resources
81 assert resources['cheese'] == 'real'
81 assert resources['cheese'] == 'real'
82
82
83
83
84 def test_transformer_dottedobjectname(self):
84 def test_transformer_dottedobjectname(self):
85 """
85 """
86 Can a transformer be added to the transformers list by dotted object name?
86 Can a transformer be added to the transformers list by dotted object name?
87 """
87 """
88 config = Config({'Exporter': {'transformers': ['IPython.nbconvert.exporters.tests.cheese.CheeseTransformer']}})
88 config = Config({'Exporter': {'transformers': ['IPython.nbconvert.exporters.tests.cheese.CheeseTransformer']}})
89 exporter = self._make_exporter(config=config)
89 exporter = self._make_exporter(config=config)
90 (output, resources) = exporter.from_filename(self._get_notebook())
90 (output, resources) = exporter.from_filename(self._get_notebook())
91 assert resources is not None
91 assert resources is not None
92 assert 'cheese' in resources
92 assert 'cheese' in resources
93 assert resources['cheese'] == 'real'
93 assert resources['cheese'] == 'real'
94
94
95
95
96 def test_transformer_via_method(self):
96 def test_transformer_via_method(self):
97 """
97 """
98 Can a transformer be added via the Exporter convinience method?
98 Can a transformer be added via the Exporter convenience method?
99 """
99 """
100 exporter = self._make_exporter()
100 exporter = self._make_exporter()
101 exporter.register_transformer(CheeseTransformer, enabled=True)
101 exporter.register_transformer(CheeseTransformer, enabled=True)
102 (output, resources) = exporter.from_filename(self._get_notebook())
102 (output, resources) = exporter.from_filename(self._get_notebook())
103 assert resources is not None
103 assert resources is not None
104 assert 'cheese' in resources
104 assert 'cheese' in resources
105 assert resources['cheese'] == 'real'
105 assert resources['cheese'] == 'real'
106
106
107
107
108 def _make_exporter(self, config=None):
108 def _make_exporter(self, config=None):
109 #Create the exporter instance, make sure to set a template name since
109 #Create the exporter instance, make sure to set a template name since
110 #the base Exporter doesn't have a template associated with it.
110 #the base Exporter doesn't have a template associated with it.
111 exporter = Exporter(config=config)
111 exporter = Exporter(config=config, template_file='python')
112 exporter.template_file = 'python'
113 return exporter No newline at end of file
112 return exporter
@@ -1,59 +1,59 b''
1 """
1 """
2 Module with tests for html.py
2 Module with tests for html.py
3 """
3 """
4
4
5 #-----------------------------------------------------------------------------
5 #-----------------------------------------------------------------------------
6 # Copyright (c) 2013, the IPython Development Team.
6 # Copyright (c) 2013, the IPython Development Team.
7 #
7 #
8 # Distributed under the terms of the Modified BSD License.
8 # Distributed under the terms of the Modified BSD License.
9 #
9 #
10 # The full license is in the file COPYING.txt, distributed with this software.
10 # The full license is in the file COPYING.txt, distributed with this software.
11 #-----------------------------------------------------------------------------
11 #-----------------------------------------------------------------------------
12
12
13 #-----------------------------------------------------------------------------
13 #-----------------------------------------------------------------------------
14 # Imports
14 # Imports
15 #-----------------------------------------------------------------------------
15 #-----------------------------------------------------------------------------
16
16
17 from .base import ExportersTestsBase
17 from .base import ExportersTestsBase
18 from ..html import HTMLExporter
18 from ..html import HTMLExporter
19 from IPython.testing.decorators import onlyif_cmds_exist
19 from IPython.testing.decorators import onlyif_cmds_exist
20
20
21 #-----------------------------------------------------------------------------
21 #-----------------------------------------------------------------------------
22 # Class
22 # Class
23 #-----------------------------------------------------------------------------
23 #-----------------------------------------------------------------------------
24
24
25 class TestHTMLExporter(ExportersTestsBase):
25 class TestHTMLExporter(ExportersTestsBase):
26 """Contains test functions for html.py"""
26 """Contains test functions for html.py"""
27
27
28 def test_constructor(self):
28 def test_constructor(self):
29 """
29 """
30 Can a HTMLExporter be constructed?
30 Can a HTMLExporter be constructed?
31 """
31 """
32 HTMLExporter()
32 HTMLExporter()
33
33
34
34
35 @onlyif_cmds_exist('pandoc')
35 @onlyif_cmds_exist('pandoc')
36 def test_export(self):
36 def test_export(self):
37 """
37 """
38 Can a HTMLExporter export something?
38 Can a HTMLExporter export something?
39 """
39 """
40 (output, resources) = HTMLExporter().from_filename(self._get_notebook())
40 (output, resources) = HTMLExporter().from_filename(self._get_notebook())
41 assert len(output) > 0
41 assert len(output) > 0
42
42
43
43
44 @onlyif_cmds_exist('pandoc')
44 @onlyif_cmds_exist('pandoc')
45 def test_export_basic(self):
45 def test_export_basic(self):
46 """
46 """
47 Can a HTMLExporter export using the 'basic' template?
47 Can a HTMLExporter export using the 'basic' template?
48 """
48 """
49 (output, resources) = HTMLExporter(template='basic').from_filename(self._get_notebook())
49 (output, resources) = HTMLExporter(template_file='basic').from_filename(self._get_notebook())
50 assert len(output) > 0
50 assert len(output) > 0
51
51
52
52
53 @onlyif_cmds_exist('pandoc')
53 @onlyif_cmds_exist('pandoc')
54 def test_export_full(self):
54 def test_export_full(self):
55 """
55 """
56 Can a HTMLExporter export using the 'full' template?
56 Can a HTMLExporter export using the 'full' template?
57 """
57 """
58 (output, resources) = HTMLExporter(template='full').from_filename(self._get_notebook())
58 (output, resources) = HTMLExporter(template_file='full').from_filename(self._get_notebook())
59 assert len(output) > 0
59 assert len(output) > 0
@@ -1,68 +1,68 b''
1 """
1 """
2 Module with tests for latex.py
2 Module with tests for latex.py
3 """
3 """
4
4
5 #-----------------------------------------------------------------------------
5 #-----------------------------------------------------------------------------
6 # Copyright (c) 2013, the IPython Development Team.
6 # Copyright (c) 2013, the IPython Development Team.
7 #
7 #
8 # Distributed under the terms of the Modified BSD License.
8 # Distributed under the terms of the Modified BSD License.
9 #
9 #
10 # The full license is in the file COPYING.txt, distributed with this software.
10 # The full license is in the file COPYING.txt, distributed with this software.
11 #-----------------------------------------------------------------------------
11 #-----------------------------------------------------------------------------
12
12
13 #-----------------------------------------------------------------------------
13 #-----------------------------------------------------------------------------
14 # Imports
14 # Imports
15 #-----------------------------------------------------------------------------
15 #-----------------------------------------------------------------------------
16
16
17 from .base import ExportersTestsBase
17 from .base import ExportersTestsBase
18 from ..latex import LatexExporter
18 from ..latex import LatexExporter
19 from IPython.testing.decorators import onlyif_cmds_exist
19 from IPython.testing.decorators import onlyif_cmds_exist
20
20
21 #-----------------------------------------------------------------------------
21 #-----------------------------------------------------------------------------
22 # Class
22 # Class
23 #-----------------------------------------------------------------------------
23 #-----------------------------------------------------------------------------
24
24
25 class TestLatexExporter(ExportersTestsBase):
25 class TestLatexExporter(ExportersTestsBase):
26 """Contains test functions for latex.py"""
26 """Contains test functions for latex.py"""
27
27
28 def test_constructor(self):
28 def test_constructor(self):
29 """
29 """
30 Can a LatexExporter be constructed?
30 Can a LatexExporter be constructed?
31 """
31 """
32 LatexExporter()
32 LatexExporter()
33
33
34
34
35 @onlyif_cmds_exist('pandoc')
35 @onlyif_cmds_exist('pandoc')
36 def test_export(self):
36 def test_export(self):
37 """
37 """
38 Can a LatexExporter export something?
38 Can a LatexExporter export something?
39 """
39 """
40 (output, resources) = LatexExporter().from_filename(self._get_notebook())
40 (output, resources) = LatexExporter().from_filename(self._get_notebook())
41 assert len(output) > 0
41 assert len(output) > 0
42
42
43
43
44 @onlyif_cmds_exist('pandoc')
44 @onlyif_cmds_exist('pandoc')
45 def test_export_book(self):
45 def test_export_book(self):
46 """
46 """
47 Can a LatexExporter export using 'book' template?
47 Can a LatexExporter export using 'book' template?
48 """
48 """
49 (output, resources) = LatexExporter(template='book').from_filename(self._get_notebook())
49 (output, resources) = LatexExporter(template_file='book').from_filename(self._get_notebook())
50 assert len(output) > 0
50 assert len(output) > 0
51
51
52
52
53 @onlyif_cmds_exist('pandoc')
53 @onlyif_cmds_exist('pandoc')
54 def test_export_basic(self):
54 def test_export_basic(self):
55 """
55 """
56 Can a LatexExporter export using 'basic' template?
56 Can a LatexExporter export using 'basic' template?
57 """
57 """
58 (output, resources) = LatexExporter(template='basic').from_filename(self._get_notebook())
58 (output, resources) = LatexExporter(template_file='basic').from_filename(self._get_notebook())
59 assert len(output) > 0
59 assert len(output) > 0
60
60
61
61
62 @onlyif_cmds_exist('pandoc')
62 @onlyif_cmds_exist('pandoc')
63 def test_export_article(self):
63 def test_export_article(self):
64 """
64 """
65 Can a LatexExporter export using 'article' template?
65 Can a LatexExporter export using 'article' template?
66 """
66 """
67 (output, resources) = LatexExporter(template='article').from_filename(self._get_notebook())
67 (output, resources) = LatexExporter(template_file='article').from_filename(self._get_notebook())
68 assert len(output) > 0 No newline at end of file
68 assert len(output) > 0
@@ -1,50 +1,50 b''
1 """
1 """
2 Module with tests for slides.py
2 Module with tests for slides.py
3 """
3 """
4
4
5 #-----------------------------------------------------------------------------
5 #-----------------------------------------------------------------------------
6 # Copyright (c) 2013, the IPython Development Team.
6 # Copyright (c) 2013, the IPython Development Team.
7 #
7 #
8 # Distributed under the terms of the Modified BSD License.
8 # Distributed under the terms of the Modified BSD License.
9 #
9 #
10 # The full license is in the file COPYING.txt, distributed with this software.
10 # The full license is in the file COPYING.txt, distributed with this software.
11 #-----------------------------------------------------------------------------
11 #-----------------------------------------------------------------------------
12
12
13 #-----------------------------------------------------------------------------
13 #-----------------------------------------------------------------------------
14 # Imports
14 # Imports
15 #-----------------------------------------------------------------------------
15 #-----------------------------------------------------------------------------
16
16
17 from .base import ExportersTestsBase
17 from .base import ExportersTestsBase
18 from ..slides import SlidesExporter
18 from ..slides import SlidesExporter
19 from IPython.testing.decorators import onlyif_cmds_exist
19 from IPython.testing.decorators import onlyif_cmds_exist
20
20
21 #-----------------------------------------------------------------------------
21 #-----------------------------------------------------------------------------
22 # Class
22 # Class
23 #-----------------------------------------------------------------------------
23 #-----------------------------------------------------------------------------
24
24
25 class TestSlidesExporter(ExportersTestsBase):
25 class TestSlidesExporter(ExportersTestsBase):
26 """Contains test functions for slides.py"""
26 """Contains test functions for slides.py"""
27
27
28 def test_constructor(self):
28 def test_constructor(self):
29 """
29 """
30 Can a SlidesExporter be constructed?
30 Can a SlidesExporter be constructed?
31 """
31 """
32 SlidesExporter()
32 SlidesExporter()
33
33
34
34
35 @onlyif_cmds_exist('pandoc')
35 @onlyif_cmds_exist('pandoc')
36 def test_export(self):
36 def test_export(self):
37 """
37 """
38 Can a SlidesExporter export something?
38 Can a SlidesExporter export something?
39 """
39 """
40 (output, resources) = SlidesExporter().from_filename(self._get_notebook())
40 (output, resources) = SlidesExporter().from_filename(self._get_notebook())
41 assert len(output) > 0
41 assert len(output) > 0
42
42
43
43
44 @onlyif_cmds_exist('pandoc')
44 @onlyif_cmds_exist('pandoc')
45 def test_export_reveal(self):
45 def test_export_reveal(self):
46 """
46 """
47 Can a SlidesExporter export using the 'reveal' template?
47 Can a SlidesExporter export using the 'reveal' template?
48 """
48 """
49 (output, resources) = SlidesExporter(template='reveal').from_filename(self._get_notebook())
49 (output, resources) = SlidesExporter(template_file='reveal').from_filename(self._get_notebook())
50 assert len(output) > 0
50 assert len(output) > 0
General Comments 0
You need to be logged in to leave comments. Login now