langfun 0.1.2.dev202411090804__py3-none-any.whl → 0.1.2.dev202411140804__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of langfun might be problematic. Click here for more details.

Files changed (36) hide show
  1. langfun/core/console.py +10 -2
  2. langfun/core/console_test.py +17 -0
  3. langfun/core/eval/__init__.py +2 -0
  4. langfun/core/eval/v2/__init__.py +38 -0
  5. langfun/core/eval/v2/checkpointing.py +135 -0
  6. langfun/core/eval/v2/checkpointing_test.py +89 -0
  7. langfun/core/eval/v2/evaluation.py +627 -0
  8. langfun/core/eval/v2/evaluation_test.py +156 -0
  9. langfun/core/eval/v2/example.py +295 -0
  10. langfun/core/eval/v2/example_test.py +114 -0
  11. langfun/core/eval/v2/experiment.py +949 -0
  12. langfun/core/eval/v2/experiment_test.py +304 -0
  13. langfun/core/eval/v2/metric_values.py +156 -0
  14. langfun/core/eval/v2/metric_values_test.py +80 -0
  15. langfun/core/eval/v2/metrics.py +357 -0
  16. langfun/core/eval/v2/metrics_test.py +203 -0
  17. langfun/core/eval/v2/progress.py +348 -0
  18. langfun/core/eval/v2/progress_test.py +82 -0
  19. langfun/core/eval/v2/progress_tracking.py +209 -0
  20. langfun/core/eval/v2/progress_tracking_test.py +56 -0
  21. langfun/core/eval/v2/reporting.py +144 -0
  22. langfun/core/eval/v2/reporting_test.py +41 -0
  23. langfun/core/eval/v2/runners.py +417 -0
  24. langfun/core/eval/v2/runners_test.py +311 -0
  25. langfun/core/eval/v2/test_helper.py +80 -0
  26. langfun/core/language_model.py +122 -11
  27. langfun/core/language_model_test.py +97 -4
  28. langfun/core/llms/__init__.py +3 -0
  29. langfun/core/llms/compositional.py +101 -0
  30. langfun/core/llms/compositional_test.py +73 -0
  31. langfun/core/llms/vertexai.py +4 -4
  32. {langfun-0.1.2.dev202411090804.dist-info → langfun-0.1.2.dev202411140804.dist-info}/METADATA +1 -1
  33. {langfun-0.1.2.dev202411090804.dist-info → langfun-0.1.2.dev202411140804.dist-info}/RECORD +36 -12
  34. {langfun-0.1.2.dev202411090804.dist-info → langfun-0.1.2.dev202411140804.dist-info}/WHEEL +1 -1
  35. {langfun-0.1.2.dev202411090804.dist-info → langfun-0.1.2.dev202411140804.dist-info}/LICENSE +0 -0
  36. {langfun-0.1.2.dev202411090804.dist-info → langfun-0.1.2.dev202411140804.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,156 @@
1
+ # Copyright 2024 The Langfun Authors
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ import os
15
+ import tempfile
16
+ import unittest
17
+
18
+ from langfun.core.eval.v2 import evaluation as evaluation_lib
19
+ from langfun.core.eval.v2 import example as example_lib
20
+ from langfun.core.eval.v2 import experiment as experiment_lib
21
+
22
+ from langfun.core.eval.v2 import test_helper
23
+
24
+ import pyglove as pg
25
+
26
+ Example = example_lib.Example
27
+ Evaluation = evaluation_lib.Evaluation
28
+ RunId = experiment_lib.RunId
29
+ Run = experiment_lib.Run
30
+
31
+
32
+ class EvaluationTest(unittest.TestCase):
33
+
34
+ def test_hyper_evaluation(self):
35
+ exp = test_helper.TestEvaluation(
36
+ lm=test_helper.TestLLM(offset=pg.oneof(range(3)))
37
+ )
38
+ self.assertFalse(exp.is_leaf)
39
+ self.assertTrue(
40
+ pg.eq(
41
+ exp.children,
42
+ [
43
+ test_helper.TestEvaluation(lm=test_helper.TestLLM(offset=0)),
44
+ test_helper.TestEvaluation(lm=test_helper.TestLLM(offset=1)),
45
+ test_helper.TestEvaluation(lm=test_helper.TestLLM(offset=2)),
46
+ ]
47
+ )
48
+ )
49
+ self.assertEqual(exp.children[0].num_examples, 10)
50
+ self.assertEqual(
51
+ [c.is_leaf for c in exp.children],
52
+ [True] * len(exp.children)
53
+ )
54
+ self.assertEqual(
55
+ [r.resource_ids() for r in exp.leaf_nodes],
56
+ [set(['test_llm:0']), set(['test_llm:1']), set(['test_llm:2'])]
57
+ )
58
+
59
+ def test_input(self):
60
+ exp = test_helper.TestEvaluation()
61
+ self.assertEqual(exp.num_examples, 10)
62
+ exp = test_helper.TestEvaluation(inputs=test_helper.test_inputs(None))
63
+ self.assertEqual(exp.num_examples, 20)
64
+ @pg.functor
65
+ def my_inputs():
66
+ yield pg.Dict(x=1, y=2)
67
+ yield pg.Dict(x=3, y=4)
68
+ exp = test_helper.TestEvaluation(inputs=my_inputs())
69
+ self.assertEqual(exp.num_examples, 2)
70
+
71
+ def test_evaluate(self):
72
+ exp = test_helper.TestEvaluation()
73
+ example = exp.evaluate(Example(id=3))
74
+ self.assertIs(exp.state.get(3), example)
75
+ self.assertTrue(example.newly_processed)
76
+ self.assertEqual(example.input, pg.Dict(x=2, y=4, groundtruth=6))
77
+ self.assertEqual(example.output, 6)
78
+ self.assertIsNone(example.error)
79
+ self.assertEqual(example.metadata, {})
80
+ self.assertEqual(example.metric_metadata, dict(match=True))
81
+ self.assertIsNotNone(example.usage_summary)
82
+ self.assertGreater(example.usage_summary.total.total_tokens, 0)
83
+ self.assertEqual(example.usage_summary.total.num_requests, 1)
84
+ self.assertIsNotNone(example.execution_status)
85
+ self.assertIsNotNone(example.start_time)
86
+ self.assertIsNotNone(example.end_time)
87
+
88
+ exp = test_helper.TestEvaluation(lm=test_helper.TestLLM(offset=1))
89
+ example = exp.evaluate(3)
90
+ self.assertTrue(example.newly_processed)
91
+ self.assertEqual(example.input, pg.Dict(x=2, y=4, groundtruth=6))
92
+ self.assertEqual(example.output, 7)
93
+ self.assertIsNone(example.error)
94
+ self.assertEqual(example.metadata, {})
95
+ self.assertEqual(example.metric_metadata, dict(mismatch=True))
96
+
97
+ with self.assertRaisesRegex(ValueError, 'x should not be 5'):
98
+ _ = exp.evaluate(6, raise_if_has_error=True)
99
+ example = exp.evaluate(6)
100
+ self.assertTrue(example.newly_processed)
101
+ self.assertEqual(example.input, pg.Dict(x=5, y=25, groundtruth=30))
102
+ self.assertEqual(pg.MISSING_VALUE, example.output)
103
+ self.assertEqual(example.error.tag, 'ValueError')
104
+ self.assertEqual(example.metadata, {})
105
+ self.assertEqual(example.metric_metadata, dict(error='ValueError'))
106
+
107
+ def test_evaluate_with_state(self):
108
+ eval_dir = os.path.join(tempfile.gettempdir(), 'test_eval')
109
+ pg.io.mkdirs(eval_dir, exist_ok=True)
110
+ state_file = os.path.join(eval_dir, 'state.jsonl')
111
+ with pg.io.open_sequence(state_file, 'w') as f:
112
+ exp = test_helper.TestEvaluation()
113
+ example = exp.evaluate(3)
114
+ self.assertTrue(example.newly_processed)
115
+ self.assertEqual(example.input, pg.Dict(x=2, y=4, groundtruth=6))
116
+ self.assertEqual(example.output, 6)
117
+ self.assertEqual(len(exp._state.evaluated_examples), 1)
118
+ f.add(pg.to_json_str(example))
119
+
120
+ exp.reset()
121
+ self.assertEqual(len(exp._state.evaluated_examples), 0)
122
+ exp.load_state(state_file)
123
+ self.assertEqual(len(exp._state.evaluated_examples), 1)
124
+ example = exp.evaluate(3)
125
+ self.assertFalse(example.newly_processed)
126
+ self.assertEqual(example.input, pg.Dict(x=2, y=4, groundtruth=6))
127
+ self.assertEqual(example.output, 6)
128
+ self.assertGreater(example.usage_summary.total.total_tokens, 0)
129
+ self.assertGreater(example.usage_summary.cached.total.total_tokens, 0)
130
+ self.assertEqual(example.usage_summary.cached.total.num_requests, 1)
131
+ self.assertEqual(example.usage_summary.uncached.total.total_tokens, 0)
132
+ self.assertEqual(example.usage_summary.uncached.total.num_requests, 0)
133
+
134
+ def test_html_view(self):
135
+ exp = test_helper.TestEvaluation()
136
+ self.assertIn(
137
+ exp.id,
138
+ exp.to_html(extra_flags=dict(card_view=True, current_run=None)).content
139
+ )
140
+ self.assertIn(
141
+ exp.id,
142
+ exp.to_html(
143
+ extra_flags=dict(
144
+ card_view=False,
145
+ current_run=Run(
146
+ root_dir='/tmp/test_run',
147
+ id=RunId.from_id('20241031_1'),
148
+ experiment=pg.Ref(exp),
149
+ )
150
+ )
151
+ ).content
152
+ )
153
+
154
+
155
+ if __name__ == '__main__':
156
+ unittest.main()
@@ -0,0 +1,295 @@
1
+ # Copyright 2024 The Langfun Authors
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ """Base classes for Langfun evaluation."""
15
+
16
+ import dataclasses
17
+ import inspect
18
+ from typing import Any, Callable
19
+ import langfun.core as lf
20
+ import pyglove as pg
21
+
22
+
23
+ @dataclasses.dataclass
24
+ class Example(pg.JSONConvertible, pg.views.HtmlTreeView.Extension):
25
+ """An item for the evaluation.
26
+
27
+ Attributes:
28
+ id: The 1-based ID of the item in the evaluation set.
29
+ input: An element returned from the `Evaluable.inputs` functor.
30
+ output: The output of the `process` method. If `pg.MISSING_VALUE`, it has
31
+ not been processed yet.
32
+ metadata: The metadata of the item produced by the `process` method.
33
+ metric_metadata: The dictionary returned from `Metric.audit`.
34
+ start_time: The start time of the evaluation item.
35
+ end_time: The end time of the evaluation item.
36
+ usage_summary: The summary of LLM usages of the evaluation item.
37
+ execution_status: The timeit status of the evaluation item.
38
+ """
39
+ id: int
40
+ input: Any = pg.MISSING_VALUE
41
+ output: Any = pg.MISSING_VALUE
42
+ error: pg.object_utils.ErrorInfo | None = None
43
+ metadata: dict[str, Any] = dataclasses.field(default_factory=dict)
44
+ metric_metadata: dict[str, Any] | None = None
45
+ # Execution information.
46
+ newly_processed: bool = True
47
+ start_time: float | None = None
48
+ end_time: float | None = None
49
+ usage_summary: lf.UsageSummary | None = None
50
+ execution_status: dict[str, pg.object_utils.TimeIt.Status] | None = None
51
+
52
+ def __post_init__(self):
53
+ if self.execution_status is not None:
54
+ for status in self.execution_status.values():
55
+ if status.has_error:
56
+ self.error = status.error
57
+ break
58
+
59
+ @property
60
+ def is_processed(self) -> bool:
61
+ """Returns whether the item has been processed."""
62
+ return pg.MISSING_VALUE != self.output
63
+
64
+ @property
65
+ def has_error(self) -> bool:
66
+ """Returns whether the item has an error."""
67
+ return self.error is not None
68
+
69
+ @property
70
+ def elapse(self) -> float | None:
71
+ """Returns the elapse time of the item."""
72
+ if self.execution_status is not None:
73
+ return self.execution_status['evaluate'].elapse
74
+ return None
75
+
76
+ def to_json(self, *, exclude_input: bool = False, **kwargs):
77
+ """Returns the JSON representation of the item."""
78
+ return self.to_json_dict(
79
+ fields=dict(
80
+ id=(self.id, None),
81
+ input=(
82
+ self.input if not exclude_input else pg.MISSING_VALUE,
83
+ pg.MISSING_VALUE
84
+ ),
85
+ output=(self.output, pg.MISSING_VALUE),
86
+ error=(self.error, None),
87
+ metadata=(self.metadata, {}),
88
+ metric_metadata=(self.metric_metadata, None),
89
+ start_time=(self.start_time, None),
90
+ end_time=(self.end_time, None),
91
+ usage_summary=(self.usage_summary, None),
92
+ execution_status=(self.execution_status, None),
93
+ ),
94
+ exclude_default=True,
95
+ **kwargs,
96
+ )
97
+
98
+ @classmethod
99
+ def from_json(
100
+ cls,
101
+ json_value: dict[str, Any],
102
+ *,
103
+ example_input_by_id: Callable[[int], Any] | None = None,
104
+ **kwargs
105
+ ) -> 'Example':
106
+ """Creates an example from the JSON representation."""
107
+ example_id = json_value.get('id')
108
+ if example_input_by_id:
109
+ example_input = example_input_by_id(example_id)
110
+ else:
111
+ example_input = json_value.pop('input', pg.MISSING_VALUE)
112
+ if example_input is not pg.MISSING_VALUE:
113
+ example_input = pg.from_json(example_input, **kwargs)
114
+ json_value['input'] = example_input
115
+
116
+ # NOTE(daiyip): We need to load the types of the examples into the
117
+ # deserialization context, otherwise the deserialization will fail if the
118
+ # types are not registered.
119
+ def example_class_defs(example) -> list[type[Any]]:
120
+ referred_types = set()
121
+ def _visit(k, v, p):
122
+ del k, p
123
+ if inspect.isclass(v):
124
+ referred_types.add(v)
125
+ elif isinstance(v, pg.Object):
126
+ referred_types.add(v.__class__)
127
+ return pg.TraverseAction.ENTER
128
+ pg.traverse(example, _visit)
129
+ return list(referred_types)
130
+
131
+ with pg.JSONConvertible.load_types_for_deserialization(
132
+ *example_class_defs(example_input)
133
+ ):
134
+ return cls(
135
+ **{k: pg.from_json(v, **kwargs) for k, v in json_value.items()}
136
+ )
137
+
138
+ #
139
+ # HTML rendering.
140
+ #
141
+
142
+ def _html_tree_view_content(
143
+ self,
144
+ *,
145
+ view: pg.views.HtmlTreeView,
146
+ root_path: pg.KeyPath | None = None,
147
+ extra_flags: dict[str, Any] | None = None,
148
+ **kwargs
149
+ ):
150
+ root_path = root_path or pg.KeyPath()
151
+ extra_flags = extra_flags or {}
152
+ num_examples = extra_flags.get('num_examples', None)
153
+
154
+ def _metric_metadata_badge(key, value):
155
+ if isinstance(value, bool) and bool:
156
+ text = key
157
+ else:
158
+ text = f'{key}:{value}'
159
+ return pg.views.html.controls.Badge(
160
+ text,
161
+ css_classes=[pg.object_utils.camel_to_snake(key, '-')],
162
+ )
163
+
164
+ def _render_header():
165
+ return pg.Html.element(
166
+ 'div',
167
+ [
168
+ pg.Html.element(
169
+ 'div',
170
+ [
171
+ # Previous button.
172
+ pg.views.html.controls.Label( # pylint: disable=g-long-ternary
173
+ '◀',
174
+ link=f'{self.id - 1}.html',
175
+ css_classes=['previous'],
176
+ ) if self.id > 1 else None,
177
+ # Current example ID.
178
+ pg.views.html.controls.Label(
179
+ f'#{self.id}',
180
+ css_classes=['example-id'],
181
+ ),
182
+ # Next button.
183
+ pg.views.html.controls.Label( # pylint: disable=g-long-ternary
184
+ '▶',
185
+ link=f'{self.id + 1}.html',
186
+ css_classes=['next'],
187
+ ) if (num_examples is None
188
+ or self.id < num_examples) else None,
189
+
190
+ ]
191
+ ),
192
+ pg.Html.element(
193
+ 'div',
194
+ [
195
+ # Usage summary.
196
+ pg.view( # pylint: disable=g-long-ternary
197
+ self.usage_summary,
198
+ extra_flags=dict(as_badge=True)
199
+ ) if self.usage_summary is not None else None,
200
+ # Metric metadata.
201
+ pg.views.html.controls.LabelGroup(
202
+ [ # pylint: disable=g-long-ternary
203
+ _metric_metadata_badge(k, v)
204
+ for k, v in self.metric_metadata.items()
205
+ ] if self.metric_metadata else []
206
+ ),
207
+ ],
208
+ css_classes=['example-container'],
209
+ )
210
+ ]
211
+ )
212
+
213
+ def _render_content():
214
+ def _tab(label, key, default):
215
+ field = getattr(self, key)
216
+ if default == field:
217
+ return None
218
+ return pg.views.html.controls.Tab(
219
+ label=label,
220
+ content=view.render(
221
+ field,
222
+ root_path=root_path + key,
223
+ collapse_level=None,
224
+ **view.get_passthrough_kwargs(**kwargs),
225
+ ),
226
+ )
227
+ tabs = [
228
+ _tab('Input', 'input', pg.MISSING_VALUE),
229
+ _tab('Output', 'output', pg.MISSING_VALUE),
230
+ _tab('Output Metadata', 'metadata', {}),
231
+ _tab('Error', 'error', None),
232
+ ]
233
+ tabs = [tab for tab in tabs if tab is not None]
234
+ return pg.views.html.controls.TabControl(
235
+ tabs,
236
+ len(tabs) - 1,
237
+ )
238
+
239
+ return pg.Html.element(
240
+ 'div',
241
+ [
242
+ _render_header(),
243
+ _render_content(),
244
+ ],
245
+ css_classes=['eval-example']
246
+ )
247
+
248
+ def _html_tree_view_summary(self, *, view, **kwargs):
249
+ return None
250
+
251
+ @classmethod
252
+ def _html_tree_view_css_styles(cls) -> list[str]:
253
+ return super()._html_tree_view_css_styles() + [
254
+ """
255
+ .example-container {
256
+ display: block;
257
+ padding: 10px;
258
+ }
259
+ .example-id {
260
+ font-weight: bold;
261
+ font-size: 40px;
262
+ margin: 0 10px;
263
+ vertical-align: middle;
264
+ }
265
+ a.previous, a.next {
266
+ text-decoration: none;
267
+ vertical-align: middle;
268
+ display: inline-block;
269
+ padding: 8px 8px;
270
+ color: #DDD;
271
+ }
272
+ a.previous:hover, a.next:hover {
273
+ background-color: #ddd;
274
+ color: black;
275
+ }
276
+ /* Badge styles. */
277
+ .eval-example .badge.match {
278
+ color: green;
279
+ background-color: #dcefbe;
280
+ }
281
+ .eval-example .badge.error {
282
+ color: red;
283
+ background-color: #fdcccc;
284
+ }
285
+ .eval-example .badge.mismatch {
286
+ color: orange;
287
+ background-color: #ffefc4;
288
+ }
289
+ .eval-example .badge.score {
290
+ color: blue;
291
+ background-color: #c4dced;
292
+ }
293
+ """
294
+ ]
295
+
@@ -0,0 +1,114 @@
1
+ # Copyright 2024 The Langfun Authors
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ import unittest
15
+
16
+ from langfun.core.eval.v2 import example as example_lib
17
+ import pyglove as pg
18
+
19
+ Example = example_lib.Example
20
+
21
+
22
+ class ExampleTest(unittest.TestCase):
23
+
24
+ def test_basic(self):
25
+ error = pg.object_utils.ErrorInfo(
26
+ tag='ValueError',
27
+ description='Bad input',
28
+ stacktrace='...',
29
+ )
30
+ ex = Example(id=1, execution_status={
31
+ 'evaluate': pg.object_utils.TimeIt.Status(
32
+ name='evaluation', elapse=1.0, error=error
33
+ )
34
+ })
35
+ self.assertEqual(ex.error, error)
36
+ self.assertFalse(ex.is_processed)
37
+ self.assertTrue(ex.has_error)
38
+ self.assertEqual(ex.elapse, 1.0)
39
+
40
+ ex = Example(id=2, output=1)
41
+ self.assertTrue(ex.is_processed)
42
+ self.assertFalse(ex.has_error)
43
+ self.assertIsNone(ex.elapse)
44
+
45
+ def test_json_conversion(self):
46
+ def input_func():
47
+ class A(pg.Object):
48
+ x: int
49
+
50
+ class B(pg.Object):
51
+ x: int = 1
52
+ y: int = 2
53
+
54
+ return [
55
+ pg.Dict(
56
+ a=A,
57
+ b=B
58
+ )
59
+ ]
60
+
61
+ inputs = input_func()
62
+ ex = Example(
63
+ id=1,
64
+ input=inputs[0],
65
+ output=inputs[0].a(1),
66
+ metadata=dict(b=inputs[0].b())
67
+ )
68
+ # Serialize without input.
69
+ json_str = pg.to_json_str(ex, exclude_input=True)
70
+ self.assertEqual(
71
+ pg.from_json_str(
72
+ json_str,
73
+ example_input_by_id=lambda i: inputs[i - 1]
74
+ ),
75
+ ex
76
+ )
77
+ pg.JSONConvertible._TYPE_REGISTRY._type_to_cls_map.pop(
78
+ inputs[0].a.__type_name__
79
+ )
80
+ pg.JSONConvertible._TYPE_REGISTRY._type_to_cls_map.pop(
81
+ inputs[0].b.__type_name__
82
+ )
83
+ v = pg.from_json_str(json_str, auto_dict=True)
84
+ v.output.pop('type_name')
85
+ v.metadata.b.pop('type_name')
86
+ self.assertEqual(
87
+ v,
88
+ Example(
89
+ id=1,
90
+ output=pg.Dict(x=1),
91
+ metadata=dict(b=pg.Dict(x=1, y=2)),
92
+ )
93
+ )
94
+ # Serialize with input.
95
+ ex = Example(id=2, input=pg.Dict(x=1), output=pg.Dict(x=2))
96
+ json_str = pg.to_json_str(ex, exclude_input=False)
97
+ self.assertEqual(pg.from_json_str(json_str), ex)
98
+
99
+ def test_html_view(self):
100
+ ex = Example(
101
+ id=1,
102
+ input=pg.Dict(a=1, b=2),
103
+ output=3,
104
+ metadata=dict(sum=3),
105
+ metric_metadata=dict(match=True),
106
+ )
107
+ self.assertNotIn(
108
+ 'next',
109
+ ex.to_html(extra_flags=dict(num_examples=1)).content,
110
+ )
111
+
112
+
113
+ if __name__ == '__main__':
114
+ unittest.main()