epic-capybara 1.0.0__tar.gz

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.
@@ -0,0 +1,17 @@
1
+ name: "Test capybara installation"
2
+
3
+ on:
4
+ push:
5
+ pull_request:
6
+
7
+ jobs:
8
+ build:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - name: Checkout
12
+ uses: actions/checkout@v3
13
+ - name: Build and install epic-capybara
14
+ run: |
15
+ pip install hatch
16
+ hatch build
17
+ pip install dist/*.whl
@@ -0,0 +1,51 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ permissions:
8
+ id-token: write
9
+
10
+ jobs:
11
+ build:
12
+ name: Build distribution
13
+ runs-on: ubuntu-latest
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+
17
+ - name: Set up Python
18
+ uses: actions/setup-python@v5
19
+ with:
20
+ python-version: "3.12"
21
+
22
+ - name: Install build
23
+ run: python -m pip install --upgrade build
24
+
25
+ - name: Build package
26
+ run: python -m build
27
+
28
+ - name: Store the distribution packages
29
+ uses: actions/upload-artifact@v4
30
+ with:
31
+ name: python-package-distributions
32
+ path: dist/
33
+
34
+ publish-to-pypi:
35
+ name: Publish to PyPI
36
+ needs: build
37
+ runs-on: ubuntu-latest
38
+ environment:
39
+ name: pypi
40
+ url: https://pypi.org/p/epic-capybara
41
+ permissions:
42
+ id-token: write
43
+ steps:
44
+ - name: Download distribution packages
45
+ uses: actions/download-artifact@v4
46
+ with:
47
+ name: python-package-distributions
48
+ path: dist/
49
+
50
+ - name: Publish to PyPI
51
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,6 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .eggs/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023 Electron-Ion Collider (EIC) Software
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,71 @@
1
+ Metadata-Version: 2.4
2
+ Name: epic-capybara
3
+ Version: 1.0.0
4
+ Summary: Track and visualize multi-dimensional CI metrics
5
+ Project-URL: Documentation, https://github.com/eic/epic-capybara#readme
6
+ Project-URL: Issues, https://github.com/eic/epic-capybara/issues
7
+ Project-URL: Source, https://github.com/eic/epic-capybara
8
+ Author-email: Dmitry Kalinkin <dmitry.kalinkin@gmail.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Programming Language :: Python
13
+ Classifier: Programming Language :: Python :: 3.7
14
+ Classifier: Programming Language :: Python :: 3.8
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: Implementation :: CPython
19
+ Classifier: Programming Language :: Python :: Implementation :: PyPy
20
+ Classifier: Topic :: Scientific/Engineering :: Physics
21
+ Requires-Python: >=3.7
22
+ Requires-Dist: awkward
23
+ Requires-Dist: bokeh>=3.0.0
24
+ Requires-Dist: click
25
+ Requires-Dist: hist
26
+ Requires-Dist: pygithub
27
+ Requires-Dist: requests
28
+ Requires-Dist: scipy
29
+ Requires-Dist: uproot
30
+ Provides-Extra: rntuple
31
+ Requires-Dist: uproot>=5.7.0; extra == 'rntuple'
32
+ Description-Content-Type: text/markdown
33
+
34
+ # epic-capybara
35
+
36
+ epic-capybara is a collection of tools for comparison and presentation of
37
+ differences between [ROOT](https://root.cern) TTree files. The available tools
38
+ are:
39
+
40
+ - `capybara capy` fetches CI artifacts either for a single revision of for the PR branch and its reference branch
41
+ - `capybara bara` projects each TTree leaf onto a histogram, render it as an html report using Bokeh
42
+ - `capybara cate` upload report to a github repo
43
+
44
+ See `capybara --help` or `capybara <tool-name> --help` for options.
45
+
46
+ ### Installing
47
+
48
+ You will need to obtain epic-capybara itself and [Hatch](https://hatch.pypa.io/latest/).
49
+
50
+ ```
51
+ git clone git@github.com:eic/epic-capybara.git
52
+ pip install hatch
53
+ cd epic-capybara
54
+ ```
55
+
56
+ You can then either run the development version:
57
+ ```
58
+ hatch env run -- capybara capy pr 123
59
+ ```
60
+
61
+ Or build a wheel and install it locally
62
+ ```
63
+ hatch build
64
+ pip install dist/*.whl
65
+ ```
66
+
67
+ ### Legacy scripts
68
+
69
+ This repository contains scripts for ROOT file comparisons in CI.
70
+ - `capy.py`: obtain ROOT files from previous CI pipelines
71
+ - `bara.py`: compare two ROOT files
@@ -0,0 +1,38 @@
1
+ # epic-capybara
2
+
3
+ epic-capybara is a collection of tools for comparison and presentation of
4
+ differences between [ROOT](https://root.cern) TTree files. The available tools
5
+ are:
6
+
7
+ - `capybara capy` fetches CI artifacts either for a single revision of for the PR branch and its reference branch
8
+ - `capybara bara` projects each TTree leaf onto a histogram, render it as an html report using Bokeh
9
+ - `capybara cate` upload report to a github repo
10
+
11
+ See `capybara --help` or `capybara <tool-name> --help` for options.
12
+
13
+ ### Installing
14
+
15
+ You will need to obtain epic-capybara itself and [Hatch](https://hatch.pypa.io/latest/).
16
+
17
+ ```
18
+ git clone git@github.com:eic/epic-capybara.git
19
+ pip install hatch
20
+ cd epic-capybara
21
+ ```
22
+
23
+ You can then either run the development version:
24
+ ```
25
+ hatch env run -- capybara capy pr 123
26
+ ```
27
+
28
+ Or build a wheel and install it locally
29
+ ```
30
+ hatch build
31
+ pip install dist/*.whl
32
+ ```
33
+
34
+ ### Legacy scripts
35
+
36
+ This repository contains scripts for ROOT file comparisons in CI.
37
+ - `capy.py`: obtain ROOT files from previous CI pipelines
38
+ - `bara.py`: compare two ROOT files
@@ -0,0 +1,4 @@
1
+ # SPDX-FileCopyrightText: 2023-present Dmitry Kalinkin <dmitry.kalinkin@gmail.com>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ __version__ = '1.0.0'
@@ -0,0 +1,3 @@
1
+ # SPDX-FileCopyrightText: 2023-present Dmitry Kalinkin <dmitry.kalinkin@gmail.com>
2
+ #
3
+ # SPDX-License-Identifier: MIT
@@ -0,0 +1,9 @@
1
+ # SPDX-FileCopyrightText: 2023-present Dmitry Kalinkin <dmitry.kalinkin@gmail.com>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ import sys
5
+
6
+ if __name__ == '__main__':
7
+ from .cli import capybara
8
+
9
+ sys.exit(capybara())
@@ -0,0 +1,19 @@
1
+ # SPDX-FileCopyrightText: 2023-present Dmitry Kalinkin <dmitry.kalinkin@gmail.com>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ import click
5
+
6
+ from ..__about__ import __version__
7
+ from .capy import capy
8
+ from .bara import bara
9
+ from .cate import cate
10
+
11
+ @click.group(context_settings={'help_option_names': ['-h', '--help']}, invoke_without_command=False)
12
+ @click.version_option(version=__version__, prog_name='capybara')
13
+ @click.pass_context
14
+ def capybara(ctx: click.Context):
15
+ pass
16
+
17
+ capybara.add_command(capy)
18
+ capybara.add_command(bara)
19
+ capybara.add_command(cate)
@@ -0,0 +1,506 @@
1
+ import gzip
2
+ import os
3
+ import re
4
+
5
+ import awkward as ak
6
+ import click
7
+ import numpy as np
8
+ import uproot
9
+ from bokeh.events import DocumentReady
10
+ from bokeh.io import curdoc
11
+ from bokeh.layouts import gridplot
12
+ from bokeh.models import ColumnDataSource
13
+ from bokeh.models import CustomJSExpr
14
+ from bokeh.models import Range1d
15
+ from bokeh.models import PrintfTickFormatter
16
+ from bokeh.plotting import figure, output_file, save
17
+ from hist import Hist
18
+ from scipy.stats import kstest
19
+
20
+ from ..util import skip_common_prefix
21
+
22
+ _MIDPOINT_EXPR_CODE = """
23
+ const y1 = this.data.y1;
24
+ const y2 = this.data.y2;
25
+ return y1.map((v, i) => (v + y2[i]) / 2);
26
+ """
27
+
28
+
29
+ def _is_leaf(obj):
30
+ """Check if an uproot branch/field object is a leaf (has no sub-branches/sub-fields).
31
+
32
+ Supports both TTree TBranch objects (which use `.branches`) and
33
+ RNTuple RField objects (which use `.fields`).
34
+ """
35
+ if hasattr(obj, 'branches'):
36
+ return len(obj.branches) == 0
37
+ if hasattr(obj, 'fields'):
38
+ return len(obj.fields) == 0
39
+ return True
40
+
41
+
42
+ def _normalize_key(key):
43
+ """Normalize uproot key format between TTree and RNTuple styles.
44
+
45
+ TTree EDM4hep keys follow 'CollectionName/CollectionName.fieldPath' pattern.
46
+ RNTuple EDM4hep keys follow 'CollectionName.fieldPath' pattern.
47
+ This function converts TTree-style keys to the RNTuple-style format so that
48
+ the same physics quantity has the same key regardless of the input file format.
49
+
50
+ TTree keys for fixed-size array branches include a trailing '[N]' size
51
+ annotation (e.g. 'covariance.covariance[21]') which is absent in RNTuple
52
+ keys; this suffix is stripped so the two formats match.
53
+
54
+ >>> _normalize_key('MCParticles/MCParticles.momentum.x')
55
+ 'MCParticles.momentum.x'
56
+ >>> _normalize_key('MCParticles.momentum.x')
57
+ 'MCParticles.momentum.x'
58
+ >>> _normalize_key('EventHeader/EventHeader.eventNumber')
59
+ 'EventHeader.eventNumber'
60
+ >>> _normalize_key('CentralCKFTrackParameters/CentralCKFTrackParameters.covariance.covariance[21]')
61
+ 'CentralCKFTrackParameters.covariance.covariance'
62
+ """
63
+ if "/" in key:
64
+ _, field_part = key.split("/", 1)
65
+ else:
66
+ field_part = key
67
+ return re.sub(r'\[\d+\]$', '', field_part)
68
+
69
+
70
+ def match_filter(key, match, unmatch):
71
+ accept = True
72
+ if match:
73
+ accept = False
74
+ for regex in match:
75
+ if regex.match(key):
76
+ accept = True
77
+ for regex in unmatch:
78
+ if regex.match(key):
79
+ accept = False
80
+ return accept
81
+
82
+
83
+ @click.command()
84
+ @click.argument("files", type=click.File('rb'), nargs=-1)
85
+ @click.option(
86
+ "-m", "--match", multiple=True,
87
+ help="Only include collections with names matching a regex"
88
+ )
89
+ @click.option(
90
+ "-M", "--unmatch", multiple=True,
91
+ help="Exclude collections with names matching a regex"
92
+ )
93
+ @click.option(
94
+ "--serve", is_flag=True,
95
+ default=False,
96
+ help="Run a local HTTP server to view the report"
97
+ )
98
+ def bara(files, match, unmatch, serve):
99
+ arr = {}
100
+
101
+ match = list(map(re.compile, match))
102
+ unmatch = list(map(re.compile, unmatch))
103
+
104
+ for _file in files:
105
+ tree = uproot.open(_file)["events"]
106
+
107
+ sort_by_evtnum = None
108
+ for evtnum_key in ["EventHeader/EventHeader.eventNumber", "EventHeader.eventNumber"]:
109
+ if evtnum_key in tree.keys(recursive=True):
110
+ evtnum = tree[evtnum_key].array()
111
+ sort_by_evtnum = ak.argsort(ak.flatten(evtnum))
112
+ break
113
+
114
+ for key in tree.keys(recursive=True):
115
+ if not key.startswith("PARAMETERS") and _is_leaf(tree[key]):
116
+ normalized = _normalize_key(key)
117
+ if match_filter(normalized, match, unmatch):
118
+ val = tree[key].array()
119
+ if sort_by_evtnum is not None:
120
+ val = val[sort_by_evtnum]
121
+ arr.setdefault(normalized, {})[_file] = val
122
+
123
+ paths = skip_common_prefix([_file.name.split("/") for _file in files])
124
+ paths = skip_common_prefix([reversed(list(path)) for path in paths])
125
+ labels = ["/".join(reversed(list(reversed_path))) for reversed_path in paths]
126
+
127
+ collection_figs = {}
128
+ collection_with_diffs = {}
129
+ collection_matching_count = {}
130
+ collection_step_exprs = {}
131
+
132
+ for key in sorted(arr.keys()):
133
+ if any("string" in str(ak.type(a)) for a in arr[key].values()):
134
+ click.echo(f"String value detected for key \"{key}\". Skipping...")
135
+ continue
136
+ x_min = min(filter(
137
+ lambda v: v is not None,
138
+ map(lambda a: ak.min(ak.mask(a, np.isfinite(a))), arr[key].values())
139
+ ), default=None)
140
+ if x_min is None:
141
+ continue
142
+ x_range = max(filter(
143
+ lambda v: v is not None,
144
+ map(lambda a: ak.max(ak.mask(a - x_min, np.isfinite(a))), arr[key].values())
145
+ ), default=None)
146
+ nbins = 10
147
+
148
+ if (any("* uint" in str(ak.type(a)) for a in arr[key].values())
149
+ or any("* int" in str(ak.type(a)) for a in arr[key].values())):
150
+ x_range = x_range + 1
151
+ nbins = int(min(100, np.ceil(x_range)))
152
+ else:
153
+ x_range = x_range * 1.1
154
+
155
+ if x_range == 0:
156
+ x_range = 1
157
+
158
+ if "." in key:
159
+ branch_name = key.split(".", 1)[0]
160
+ leaf_name = key
161
+ else:
162
+ branch_name = key
163
+ leaf_name = key
164
+
165
+ midpoint_expr = collection_step_exprs.setdefault(
166
+ branch_name,
167
+ CustomJSExpr(code=_MIDPOINT_EXPR_CODE),
168
+ )
169
+ fig = figure(x_axis_label=leaf_name, y_axis_label="Entries")
170
+ if x_range < 1.:
171
+ fig.xaxis.formatter = PrintfTickFormatter(format="%.2g")
172
+ collection_figs.setdefault(branch_name, []).append(fig)
173
+ y_max = 0
174
+
175
+ prev_file_arr = None
176
+ vis_params = [
177
+ ("green", 1.5, "solid", " "),
178
+ ("red", 3, "dashed", ","),
179
+ ("blue", 2, "dotted", "."),
180
+ ]
181
+
182
+ leaf_min_pvalue = 1.0
183
+ if set(arr[key].keys()) != set(files):
184
+ # not every file has the key
185
+ collection_with_diffs[branch_name] = 0.0
186
+ leaf_min_pvalue = 0.0
187
+
188
+ for _file, label, (color, line_width, line_dash, hatch_pattern) in zip(files, labels, vis_params):
189
+ if _file not in arr[key]:
190
+ continue
191
+ file_arr = arr[key][_file]
192
+
193
+ # diff and KS test
194
+ pvalue = None
195
+ if prev_file_arr is not None:
196
+ if ((ak.num(file_arr, axis=0) != ak.num(prev_file_arr, axis=0))
197
+ or ak.any(ak.num(file_arr, axis=1)
198
+ != ak.num(prev_file_arr, axis=1))
199
+ or ak.any(ak.nan_to_none(file_arr)
200
+ != ak.nan_to_none(prev_file_arr))):
201
+ if (ak.num(ak.flatten(file_arr, axis=None), axis=0) > 0 and
202
+ ak.num(ak.flatten(prev_file_arr, axis=None), axis=0) > 0):
203
+ # We can only apply the KS test on non-empty arrays
204
+ pvalue = kstest(
205
+ ak.to_numpy(ak.flatten(file_arr, axis=None)),
206
+ ak.to_numpy(ak.flatten(prev_file_arr, axis=None))
207
+ ).pvalue
208
+ else:
209
+ pvalue = 0
210
+ print(key, f"p = {pvalue:.3f}")
211
+ print(prev_file_arr)
212
+ print(file_arr)
213
+ collection_with_diffs[branch_name] = min(pvalue, collection_with_diffs.get(branch_name, 1.))
214
+ leaf_min_pvalue = min(leaf_min_pvalue, pvalue)
215
+
216
+ # Figure
217
+ h = (
218
+ Hist.new
219
+ .Reg(nbins, 0, x_range, name="x", label=key)
220
+ .Int64()
221
+ )
222
+ h.fill(x=ak.flatten(file_arr - x_min, axis=None))
223
+
224
+ ys, edges = h.to_numpy()
225
+ y0 = np.concatenate([ys, [ys[-1]]])
226
+ legend_label=label + (f"\n{100*pvalue:.0f}%CL KS" if pvalue is not None else "")
227
+ source = ColumnDataSource(
228
+ {
229
+ "x": edges + x_min,
230
+ "y1": y0 - np.sqrt(y0),
231
+ "y2": y0 + np.sqrt(y0),
232
+ }
233
+ )
234
+ step_r = fig.step(
235
+ x="x",
236
+ y={"expr": midpoint_expr},
237
+ mode="after",
238
+ source=source,
239
+ legend_label=legend_label,
240
+ line_color=color,
241
+ line_width=line_width,
242
+ line_dash=line_dash,
243
+ )
244
+ step_r.nonselection_glyph = step_r.glyph
245
+ varea_r = fig.varea_step(
246
+ x="x",
247
+ y1="y1",
248
+ y2="y2",
249
+ step_mode="after",
250
+ source=source,
251
+ legend_label=legend_label,
252
+ fill_color=color if hatch_pattern == " " else None,
253
+ fill_alpha=0.25,
254
+ hatch_color=color,
255
+ hatch_alpha=0.5,
256
+ hatch_pattern=hatch_pattern,
257
+ )
258
+ varea_r.nonselection_glyph = varea_r.glyph
259
+ fig.legend.background_fill_alpha = 0.5 # make legend more transparent
260
+
261
+ y_max = max(y_max, np.max(y0 + np.sqrt(y0)))
262
+ prev_file_arr = file_arr
263
+
264
+ if leaf_min_pvalue == 1.0:
265
+ collection_matching_count[branch_name] = collection_matching_count.get(branch_name, 0) + 1
266
+
267
+ x_bounds = (x_min - 0.05 * x_range, x_min + 1.05 * x_range)
268
+ y_bounds = (- 0.05 * y_max, 1.05 * y_max)
269
+ # Set y range for histograms
270
+ if np.all(np.isfinite(x_bounds)):
271
+ try:
272
+ fig.x_range = Range1d(
273
+ *x_bounds,
274
+ bounds=x_bounds)
275
+ except ValueError as e:
276
+ click.secho(str(e), fg="red", err=True)
277
+ else:
278
+ click.secho(f"overflow while calculating x bounds for \"{key}\"", fg="red", err=True)
279
+ if np.all(np.isfinite(y_bounds)):
280
+ try:
281
+ fig.y_range = Range1d(
282
+ *y_bounds,
283
+ bounds=y_bounds)
284
+ except ValueError as e:
285
+ click.secho(str(e), fg="red", err=True)
286
+ else:
287
+ click.secho(f"overflow while calculating y bounds for \"{key}\"", fg="red", err=True)
288
+
289
+ def to_filename(branch_name):
290
+ return branch_name.replace("#", "__pound__")
291
+
292
+ def option_key(item):
293
+ collection_name, figs = item
294
+ key = ""
295
+ if collection_name in collection_with_diffs:
296
+ if collection_with_diffs[collection_name] > 0.99:
297
+ key += " 0.99"
298
+ elif collection_with_diffs[collection_name] > 0.95:
299
+ key += " 0.95"
300
+ elif collection_with_diffs[collection_name] > 0.67:
301
+ key += " 0.67"
302
+ else:
303
+ key += " 0.00"
304
+ key += collection_name.lstrip("_")
305
+ return key
306
+
307
+ options = [("", "")]
308
+ for collection_name, figs in sorted(collection_figs.items(), key=option_key):
309
+ marker = ""
310
+ if collection_name in collection_with_diffs:
311
+ if collection_with_diffs[collection_name] > 0.99:
312
+ marker = " (*)"
313
+ elif collection_with_diffs[collection_name] > 0.95:
314
+ marker = " (**)"
315
+ elif collection_with_diffs[collection_name] > 0.67:
316
+ marker = " (***)"
317
+ else:
318
+ marker = " (****)"
319
+ options.append((to_filename(collection_name), collection_name + marker))
320
+
321
+ from bokeh.models import CustomJS, Select, DataTable, TableColumn, HTMLTemplateFormatter, NumberFormatter, StringFormatter
322
+
323
+ def mk_summary_table():
324
+ rows = []
325
+ for collection_name, figs in sorted(
326
+ collection_figs.items(),
327
+ key=lambda item: item[0].lstrip("_"),
328
+ ):
329
+ if collection_name in collection_with_diffs:
330
+ pvalue = collection_with_diffs[collection_name]
331
+ if pvalue > 0.99:
332
+ color = "#28a745" # green
333
+ elif pvalue > 0.95:
334
+ color = "#ffc107" # yellow
335
+ elif pvalue > 0.67:
336
+ color = "#fd7e14" # orange
337
+ else:
338
+ color = "#dc3545" # red
339
+ pvalue_str = f"{pvalue:.3f}"
340
+ else:
341
+ color = "transparent"
342
+ pvalue_str = ""
343
+ n_total = len(figs)
344
+ n_match = collection_matching_count.get(collection_name, 0)
345
+ n_diff = n_total - n_match
346
+ rows.append((collection_name, color, pvalue_str, n_match, n_diff, n_total))
347
+
348
+ source = ColumnDataSource({
349
+ "collection": [r[0] for r in rows],
350
+ "filename": [to_filename(r[0]) for r in rows],
351
+ "color": [r[1] for r in rows],
352
+ "pvalue": [r[2] for r in rows],
353
+ "nmatch": [r[3] for r in rows],
354
+ "ndiff": [r[4] for r in rows],
355
+ "nplots": [r[5] for r in rows],
356
+ })
357
+ square_style = (
358
+ 'display:inline-block;width:0.9em;height:0.9em;'
359
+ 'margin-right:6px;vertical-align:middle;'
360
+ 'border:1px solid #999;background-color:<%= color %>;'
361
+ )
362
+ link_fmt = HTMLTemplateFormatter(
363
+ template=f'<span style="{square_style}"></span>'
364
+ '<a href="#<%= filename %>"><%= value %></a>'
365
+ )
366
+ right_str = StringFormatter(text_align="right")
367
+ right_num = NumberFormatter(text_align="right")
368
+ columns = [
369
+ TableColumn(field="collection", title="Collection", formatter=link_fmt, width=500),
370
+ TableColumn(field="pvalue", title="min KS p-value", formatter=right_str, width=120),
371
+ TableColumn(field="nmatch", title="# matching", formatter=right_num, width=80),
372
+ TableColumn(field="ndiff", title="# differing", formatter=right_num, width=80),
373
+ TableColumn(field="nplots", title="# plots", formatter=right_num, width=80),
374
+ ]
375
+ table = DataTable(
376
+ source=source,
377
+ columns=columns,
378
+ width=800,
379
+ sizing_mode="stretch_height",
380
+ index_position=None,
381
+ sortable=True,
382
+ selectable=True,
383
+ )
384
+ source.selected.js_on_change("indices", CustomJS(args={"source": source}, code="""
385
+ const idx = cb_obj.indices;
386
+ if (idx.length > 0) {
387
+ const filename = source.data["filename"][idx[0]];
388
+ window.location.hash = "#" + filename;
389
+ fetchAndReplaceBokehDocument(filename);
390
+ }
391
+ """))
392
+ return table
393
+
394
+ def mk_dropdown(value=""):
395
+ dropdown = Select(title="Select branch (**** < 67% CL, ..., * > 99% CL stat. equiv.):", value=value, options=options)
396
+ dropdown.js_on_change("value", CustomJS(code="""
397
+ console.log('dropdown: ' + this.value, this.toString())
398
+ if (this.value != "") {
399
+ window.location.hash = "#" + this.value;
400
+ fetchAndReplaceBokehDocument(this.value);
401
+ } else {
402
+ // Empty option selected: navigate back to the index page.
403
+ window.location.hash = "";
404
+ }
405
+ """))
406
+ return dropdown
407
+
408
+ def mk_dropdown_minimal(value=""):
409
+ # Embed only the currently selected option; the full list is stored once
410
+ # in index.html's JavaScript and restored client-side after each load.
411
+ # This avoids repeating a ~54 KB options list in every .json.gz file.
412
+ label = next((lbl for val, lbl in options if val == value), value)
413
+ minimal_options = [("", "")] + ([(value, label)] if value else [])
414
+ dropdown = Select(title="Select branch (**** < 67% CL, ..., * > 99% CL stat. equiv.):", value=value, options=minimal_options)
415
+ dropdown.js_on_change("value", CustomJS(code="""
416
+ console.log('dropdown: ' + this.value, this.toString())
417
+ if (this.value != "") {
418
+ window.location.hash = "#" + this.value;
419
+ fetchAndReplaceBokehDocument(this.value);
420
+ } else {
421
+ // Empty option selected: navigate back to the index page.
422
+ window.location.hash = "";
423
+ }
424
+ """))
425
+ return dropdown
426
+
427
+ from bokeh.layouts import column
428
+ from bokeh.embed import json_item
429
+ import json
430
+
431
+ os.makedirs("capybara-reports", exist_ok=True)
432
+
433
+ for collection_name, figs in collection_figs.items():
434
+ item = column(
435
+ mk_dropdown_minimal(collection_name),
436
+ gridplot(figs, ncols=3, width=400, height=300),
437
+ )
438
+
439
+ with gzip.open(f"capybara-reports/{to_filename(collection_name)}.json.gz", "wt") as fp:
440
+ json.dump(json_item(item), fp, separators=(',', ':'))
441
+
442
+ curdoc().js_on_event(DocumentReady, CustomJS(args={"all_options": options}, code="""
443
+ window._bokehSelectOptions = all_options;
444
+
445
+ function fetchAndReplaceBokehDocument(location) {
446
+ fetch(location + '.json.gz')
447
+ .then(async function(response) {
448
+ if (!response.ok) {
449
+ throw new Error('Network response was not ok');
450
+ }
451
+
452
+ const ds = new DecompressionStream('gzip');
453
+ const decompressedStream = response.body.pipeThrough(ds);
454
+ const decompressedResponse = new Response(decompressedStream);
455
+ const item = await decompressedResponse.json();
456
+
457
+ Bokeh.documents[0].replace_with_json(item.doc);
458
+
459
+ // Restore the full options list to the newly loaded Select widget.
460
+ for (const [, model] of Bokeh.documents[0]._all_models) {
461
+ if (model.options instanceof Array) {
462
+ model.options = window._bokehSelectOptions;
463
+ model.value = location;
464
+ break;
465
+ }
466
+ }
467
+ })
468
+ .catch(function(error) {
469
+ console.error('Fetch or decompression failed:', error);
470
+ });
471
+ }
472
+
473
+ window.onhashchange = function() {
474
+ var location = window.location.hash.replace(/^#/, "");
475
+ if (location == "") {
476
+ // No hash: return to the index page. Since there is no index.json.gz,
477
+ // just reload the page to get a fresh index.html.
478
+ if (typeof window.current_location !== 'undefined') {
479
+ window.location.reload();
480
+ }
481
+ return;
482
+ }
483
+ if ((typeof current_location === 'undefined') || (current_location != location)) {
484
+ fetchAndReplaceBokehDocument(location);
485
+ window.current_location = location;
486
+ }
487
+ }
488
+ window.onhashchange();
489
+ """))
490
+ output_file(filename="capybara-reports/index.html", title="ePIC capybara report")
491
+ save(column(
492
+ mk_dropdown(),
493
+ mk_summary_table(),
494
+ sizing_mode="stretch_height",
495
+ ))
496
+
497
+ if serve:
498
+ os.chdir("capybara-reports/")
499
+ from http.server import SimpleHTTPRequestHandler
500
+ from socketserver import TCPServer
501
+ with TCPServer(("127.0.0.1", 24535), SimpleHTTPRequestHandler) as httpd:
502
+ print("Serving report at http://127.0.0.1:24535")
503
+ try:
504
+ httpd.serve_forever()
505
+ except KeyboardInterrupt:
506
+ pass
@@ -0,0 +1,148 @@
1
+ import click
2
+ from github import Auth, Github, GithubException
3
+
4
+ from ..github import download_artifact
5
+
6
+
7
+ @click.command()
8
+ @click.option('--artifact-name', default="rec_dis_18x275_minQ2=1000_craterlake_18x275.edm4eic.root")
9
+ @click.option('--token', envvar="GITHUB_TOKEN", required=True, help="GitHub access token (defaults to GITHUB_TOKEN environment variable)")
10
+ @click.option('--owner', default="eic", help="Owner of the target repository")
11
+ @click.option('--repo', default="EICrecon", help="Name of the target repository")
12
+ @click.argument('pr_number', type=int)
13
+ @click.pass_context
14
+ def pr(ctx: click.Context, artifact_name: str, owner: str, pr_number: int, repo: str, token: str):
15
+ gh = Github(auth=Auth.Token(token))
16
+ repo = gh.get_user(owner).get_repo(repo)
17
+
18
+ click.secho(f'Fetching metadata for #{pr_number}...', fg='green', err=True)
19
+ try:
20
+ pr = repo.get_pull(pr_number)
21
+ except GithubException as e:
22
+ click.secho("Pull Request is not accessible", fg="red", err=True)
23
+ click.secho(e)
24
+ ctx.exit(1)
25
+ click.echo(f"Title: {pr.title}", err=True)
26
+ other_repo_message = ""
27
+ if pr.head.repo != repo:
28
+ other_repo_message = click.style(f"{pr.head.repo.owner.login}/{pr.head.repo.name}", italic=True) + "/"
29
+ click.echo(f"PR head: {other_repo_message}{click.style(pr.head.ref, bold=True)}@{pr.head.sha}, targets {click.style(pr.base.ref, bold=True)}@{pr.base.sha}", err=True)
30
+
31
+ workflow_head = None
32
+ workflow_base = None
33
+ messages = []
34
+
35
+ def item_show_func(workflow):
36
+ workflow_id = str(workflow.id) if hasattr(workflow, "id") else "?"
37
+ head_found = "no" if workflow_head is None else "yes"
38
+ base_found = "no" if workflow_base is None else "yes"
39
+ return f"{workflow_id} head:{head_found} base:{base_found}"
40
+
41
+ with click.progressbar(
42
+ repo.get_workflow_runs(),
43
+ label="Loading workflows",
44
+ item_show_func=item_show_func,
45
+ ) as workflows_bar:
46
+ for workflow in workflows_bar:
47
+ # workflow.head_commit.sha is not available, so we take the latest
48
+ # TODO check if PR is the latest by parsing log files?
49
+ if workflow.head_repository == pr.head.repo and workflow.head_branch == pr.head.ref and workflow_head is None:
50
+ if workflow.get_artifacts().totalCount == 0:
51
+ messages.append(dict(message=f"Skipping workflow {workflow.html_url} on {workflow.head_branch} with no artifacts", fg="red", err=True))
52
+ continue
53
+ workflow_head = workflow
54
+ if workflow.head_repository == repo and workflow.head_branch == pr.base.ref and workflow_base is None:
55
+ if workflow.get_artifacts().totalCount == 0:
56
+ messages.append(dict(message=f"Skipping workflow {workflow.html_url} on {workflow.head_branch} with no artifacts", fg="red", err=True))
57
+ continue
58
+ workflow_base = workflow
59
+ if workflow_head is not None and workflow_base is not None:
60
+ break
61
+
62
+ if workflow_head is None:
63
+ click.secho("No completed workflow found for head branch", fg="red")
64
+ ctx.exit(1)
65
+ if workflow_base is None:
66
+ click.secho("No completed workflow found for base branch", fg="red")
67
+ ctx.exit(1)
68
+
69
+ for message in messages:
70
+ click.secho(**message)
71
+
72
+ click.echo(f"PR base workflow: {workflow_base.html_url}")
73
+ click.echo(f"PR head workflow: {workflow_head.html_url}")
74
+
75
+ click.echo(download_artifact(workflow_base, artifact_name, token=token, click=click))
76
+ click.echo(download_artifact(workflow_head, artifact_name, token=token, click=click))
77
+
78
+
79
+ @click.command()
80
+ @click.option('--artifact-name', default="rec_dis_18x275_minQ2=1000_craterlake_18x275.edm4eic.root")
81
+ @click.option('--token', envvar="GITHUB_TOKEN", required=True, help="GitHub access token (defaults to GITHUB_TOKEN environment variable)")
82
+ @click.option('--owner', default="eic", help="Owner of the target repository")
83
+ @click.option('--repo', default="EICrecon", help="Name of the target repository")
84
+ @click.argument('ref', type=str)
85
+ @click.pass_context
86
+ def rev(ctx: click.Context, artifact_name: str, owner: str, ref: str, repo: str, token: str):
87
+ gh = Github(auth=Auth.Token(token))
88
+ repo = gh.get_user(owner).get_repo(repo)
89
+
90
+ rev = repo.get_commit(ref)
91
+
92
+ def item_show_func(workflow):
93
+ workflow_id = str(workflow.id) if hasattr(workflow, "id") else "?"
94
+ head_found = "no" if workflow_head is None else "yes"
95
+ return f"{workflow_id} head:{head_found}"
96
+
97
+ workflow_head = None
98
+ messages = []
99
+
100
+ with click.progressbar(
101
+ repo.get_workflow_runs(),
102
+ label="Loading workflows",
103
+ item_show_func=item_show_func,
104
+ ) as workflows_bar:
105
+ for workflow in workflows_bar:
106
+ # workflow.head_commit.sha is not available, so we take the latest
107
+ # TODO check if PR is the latest by parsing log files?
108
+ if workflow.head_repository == repo and workflow.head_sha == rev.sha and workflow_head is None:
109
+ if workflow.get_artifacts().totalCount == 0:
110
+ messages.append(dict(message=f"Skipping workflow {workflow.html_url} on {workflow.head_branch} with no artifacts", fg="red", err=True))
111
+ continue
112
+ workflow_head = workflow
113
+ if workflow_head is not None:
114
+ break
115
+
116
+ if workflow_head is None:
117
+ click.secho("No completed workflow found for head branch", fg="red")
118
+ ctx.exit(1)
119
+
120
+ for message in messages:
121
+ click.secho(**message)
122
+
123
+ click.echo(f"PR head workflow: {workflow_head.html_url}")
124
+
125
+ click.echo(download_artifact(workflow_head, artifact_name, token=token, click=click))
126
+
127
+
128
+ class ForwardGroup(click.Group):
129
+ def resolve_command(self, ctx, args):
130
+ try:
131
+ cmd_name, cmd, args = super().resolve_command(ctx, args)
132
+ except click.exceptions.UsageError as e:
133
+ click.secho(f"Invoking `capy' without subcommand is deprecated. Use `capy pr'.", fg="yellow", err=True)
134
+ args = ["pr"] + args
135
+ cmd_name, cmd, args = super().resolve_command(ctx, args)
136
+ return cmd_name, cmd, args
137
+
138
+ @click.group(cls=ForwardGroup, context_settings={'help_option_names': ['-h', '--help']})
139
+ @click.option('--artifact-name', default="rec_dis_18x275_minQ2=1000_craterlake_18x275.edm4eic.root")
140
+ @click.option('--token', envvar="GITHUB_TOKEN", required=True, help="GitHub access token (defaults to GITHUB_TOKEN environment variable)")
141
+ @click.option('--owner', default="eic", help="Owner of the target repository")
142
+ @click.option('--repo', default="EICrecon", help="Name of the target repository")
143
+ def capy(**kwargs):
144
+ pass
145
+
146
+
147
+ capy.add_command(pr)
148
+ capy.add_command(rev)
@@ -0,0 +1,64 @@
1
+ import shutil
2
+ import subprocess
3
+ from github import Auth, Github, GithubException
4
+ from pathlib import Path
5
+
6
+ import click
7
+ from urllib.parse import urlparse
8
+
9
+ from ..filesystem import hashdir
10
+ from ..util import get_cache_dir
11
+
12
+
13
+ @click.command()
14
+ @click.option('--token', envvar="GITHUB_TOKEN", required=True, help="GitHub access token (defaults to GITHUB_TOKEN environment variable)")
15
+ @click.option('--owner', help="Owner of the target repository (token owner by default)")
16
+ @click.option('--repo', default="capybara-reports", help="Name of the target repository")
17
+ @click.argument('report-dir', type=click.Path(exists=True, file_okay=False, dir_okay=True))
18
+ @click.pass_context
19
+ def cate(ctx: click.Context, owner: str, repo: str, report_dir: str, token: str):
20
+ gh = Github(auth=Auth.Token(token))
21
+
22
+ if owner is not None:
23
+ user = gh.get_user(owner)
24
+ else:
25
+ user = gh.get_user()
26
+
27
+ try:
28
+ repo = user.get_repo(repo)
29
+ except GithubException:
30
+ click.secho(f"Repository {user.login}/{repo.name} is not available. Attempting to create...", fg="yellow", err=True)
31
+ repo = user.create_repo(repo)
32
+
33
+ report_dir = Path(report_dir)
34
+
35
+ prefix = hashdir(report_dir)
36
+
37
+ clone_url = urlparse(repo.clone_url)
38
+ # Add authentication information
39
+ clone_url = clone_url._replace(
40
+ netloc=f"{user.login}:{token}@{clone_url.netloc}",
41
+ )
42
+ local_repo = get_cache_dir() / user.login / repo.name
43
+
44
+ if not local_repo.exists():
45
+ subprocess.check_output(["git", "clone", clone_url.geturl(), str(local_repo)])
46
+ else:
47
+ subprocess.check_output(["git", "-C", str(local_repo), "pull"])
48
+ shutil.copytree(report_dir, local_repo / prefix)
49
+ subprocess.check_output(["git", "-C", str(local_repo), "add", prefix])
50
+ subprocess.check_output(["git", "-C", str(local_repo), "commit", "-m", f"Adding {prefix}/"])
51
+ subprocess.check_output(["git", "-C", str(local_repo), "push"])
52
+
53
+ try:
54
+ file = repo.get_contents(".nojekyll")
55
+ except GithubException.UnknownObjectException:
56
+ # file does not exist
57
+ repo.create_file(
58
+ ".nojekyll",
59
+ f"Adding .nojekyll", # commit message
60
+ "",
61
+ branch="gh-pages",
62
+ )
63
+
64
+ click.echo(f"https://{user.login}.github.io/{repo.name}/{prefix}/")
@@ -0,0 +1,21 @@
1
+ import hashlib
2
+ from pathlib import Path
3
+
4
+
5
+ def hashdir(path):
6
+ digest = hashlib.md5()
7
+
8
+ def recurse(cur_path, digest=None):
9
+ paths = cur_path.iterdir()
10
+ for file_ in sorted([p for p in paths if p.is_file()]):
11
+ digest.update(str(file_.relative_to(path)).encode())
12
+ with open(file_, "rb") as fp:
13
+ digest.update(fp.read())
14
+
15
+ paths = cur_path.iterdir()
16
+ for dir_ in sorted([p for p in paths if p.is_dir()]):
17
+ recurse(dir_, digest)
18
+
19
+ recurse(path, digest)
20
+
21
+ return digest.hexdigest()
@@ -0,0 +1,53 @@
1
+ import io
2
+ from pathlib import Path
3
+ from zipfile import ZipFile
4
+
5
+ import requests
6
+
7
+
8
+ def download_artifact(workflow, artifact_name, token=None, click=None):
9
+ artifacts = [artifact for artifact in workflow.get_artifacts() if artifact.name == artifact_name]
10
+ if not artifacts:
11
+ if click is not None:
12
+ click.secho(f"Can not obtain {artifact_name}", fg="red", err=True)
13
+ if workflow.get_artifacts().totalCount:
14
+ click.secho(f"Available artifacts:", fg="red", err=True)
15
+ for artifact in workflow.get_artifacts():
16
+ click.echo(artifact.name)
17
+ else:
18
+ click.secho(f"No artifacts available", fg="red", err=True)
19
+ return None
20
+
21
+ outdir = Path(workflow.created_at.isoformat().replace(":", "-") + "_" + workflow.head_sha)
22
+ outpath = outdir / artifact_name
23
+ if outpath.exists():
24
+ return outpath
25
+
26
+ if not outpath.parent.exists():
27
+ outdir.mkdir()
28
+
29
+ artifact, = artifacts
30
+ req = requests.get(artifact.archive_download_url, headers={"Authorization": f"token {token}"} if token else {})
31
+ zfp = ZipFile(io.BytesIO(req.content))
32
+
33
+ # Check if this is a single file zip
34
+ zip_filename = None
35
+ if artifact_name in zfp.namelist():
36
+ zip_filename = artifact_name
37
+ elif len(zfp.namelist()) == 1:
38
+ zip_filename, = zfp.namelist()
39
+ if click is not None:
40
+ click.secho(f"Can't locate {artifact_name} in the artifact ZIP archive, using {zip_filename} instead", fg="orange", err=True)
41
+
42
+ if zip_filename is not None:
43
+ # Extract a single file
44
+ with zfp.open(zip_filename) as fp_zip:
45
+ with open(outpath, "wb") as fp_out:
46
+ fp_out.write(fp_zip.read())
47
+ else:
48
+ # Extract all files
49
+ if click is not None:
50
+ click.secho(f"Can't locate {artifact_name} in the artifact ZIP archive, extracting all", fg="green", err=True)
51
+ zfp.extractall(path=outpath)
52
+
53
+ return outpath
@@ -0,0 +1,43 @@
1
+ import os
2
+ from pathlib import Path
3
+ from itertools import chain, cycle, dropwhile, starmap, tee
4
+
5
+
6
+ def get_cache_dir():
7
+ if "XDG_CACHE_HOME" in os.environ:
8
+ return os.environ["XDG_CACHE_HOME"] / "epic-capybara"
9
+ elif "HOME" in os.environ:
10
+ return Path(os.environ["HOME"]) / ".cache" / "epic-capybara"
11
+ elif "TMPDIR" in os.environ:
12
+ return Path(os.environ["TMPDIR"]) / "epic-capybara"
13
+ else:
14
+ raise RuntimeError("Unable to fine a suitable cache location")
15
+
16
+
17
+ def skip_common_prefix(iters: list):
18
+ """Given a list of iterators, skips values until at least one iterator differs from the others. Returns the remaining iterators.
19
+
20
+ >>> [list(iter) for iter in skip_common_prefix(["hello"])]
21
+ [['h', 'e', 'l', 'l', 'o']]
22
+ >>> [list(iter) for iter in skip_common_prefix(["hello", "world!"])]
23
+ [['h', 'e', 'l', 'l', 'o'], ['w', 'o', 'r', 'l', 'd', '!']]
24
+ >>> [list(iter) for iter in skip_common_prefix([[3, 2, 1], [1, 2, 3, 4]])]
25
+ [[3, 2, 1], [1, 2, 3, 4]]
26
+ >>> [list(iter) for iter in skip_common_prefix([[1, 2, 3], [1, 2, 3, 4]])]
27
+ [[], [4]]
28
+ """
29
+ # ensure that we have iterators, so that we can chain unconsumed tail in the end
30
+ iters = list(map(iter, iters))
31
+ if len(iters) == 0:
32
+ raise ValueError("iters must not be empty")
33
+ elif len(iters) == 1:
34
+ tuple_iters = [zip(*iters)]
35
+ else:
36
+ tuple_iters = tee(
37
+ dropwhile(lambda vals: all(val == vals[0] for val in vals), zip(*iters)),
38
+ len(iters),
39
+ )
40
+ return list(starmap(
41
+ lambda ix, tuple_iter: chain(map(lambda t: t[ix], tuple_iter), iters[ix]),
42
+ enumerate(tuple_iters)
43
+ ))
@@ -0,0 +1,82 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "epic-capybara"
7
+ description = 'Track and visualize multi-dimensional CI metrics'
8
+ readme = "README.md"
9
+ requires-python = ">=3.7"
10
+ license = "MIT"
11
+ keywords = []
12
+ authors = [
13
+ { name = "Dmitry Kalinkin", email = "dmitry.kalinkin@gmail.com" },
14
+ ]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Programming Language :: Python",
18
+ "Programming Language :: Python :: 3.7",
19
+ "Programming Language :: Python :: 3.8",
20
+ "Programming Language :: Python :: 3.9",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: Implementation :: CPython",
24
+ "Programming Language :: Python :: Implementation :: PyPy",
25
+ "Topic :: Scientific/Engineering :: Physics",
26
+ ]
27
+ dependencies = [
28
+ "awkward",
29
+ "bokeh>=3.0.0",
30
+ "click",
31
+ "hist",
32
+ "PyGithub",
33
+ "requests",
34
+ "scipy",
35
+ "uproot",
36
+ ]
37
+ dynamic = ["version"]
38
+
39
+ [project.optional-dependencies]
40
+ rntuple = [
41
+ "uproot>=5.7.0",
42
+ ]
43
+
44
+ [project.urls]
45
+ Documentation = "https://github.com/eic/epic-capybara#readme"
46
+ Issues = "https://github.com/eic/epic-capybara/issues"
47
+ Source = "https://github.com/eic/epic-capybara"
48
+
49
+ [project.scripts]
50
+ capybara = "epic_capybara.cli:capybara"
51
+ capy = "epic_capybara.cli:capy"
52
+ bara = "epic_capybara.cli:bara"
53
+ cate = "epic_capybara.cli:cate"
54
+
55
+ [tool.hatch.version]
56
+ path = "epic_capybara/__about__.py"
57
+
58
+ [tool.hatch.envs.default]
59
+ dependencies = [
60
+ "pytest",
61
+ "pytest-cov",
62
+ ]
63
+ [tool.hatch.envs.default.scripts]
64
+ cov = "pytest --cov-report=term-missing --cov-config=pyproject.toml --cov=epic_capybara --cov=tests {args}"
65
+ no-cov = "cov --no-cov {args}"
66
+
67
+ [[tool.hatch.envs.test.matrix]]
68
+ python = ["37", "38", "39", "310", "311"]
69
+
70
+ [tool.coverage.run]
71
+ branch = true
72
+ parallel = true
73
+ omit = [
74
+ "epic_capybara/__about__.py",
75
+ ]
76
+
77
+ [tool.coverage.report]
78
+ exclude_lines = [
79
+ "no cov",
80
+ "if __name__ == .__main__.:",
81
+ "if TYPE_CHECKING:",
82
+ ]
@@ -0,0 +1,3 @@
1
+ # SPDX-FileCopyrightText: 2023-present Dmitry Kalinkin <dmitry.kalinkin@gmail.com>
2
+ #
3
+ # SPDX-License-Identifier: MIT
@@ -0,0 +1,7 @@
1
+ import doctest
2
+
3
+ import epic_capybara.util
4
+
5
+ def test_docstrings():
6
+ doctest_results = doctest.testmod(m=epic_capybara.util)
7
+ assert doctest_results.failed == 0