epic-capybara 1.0.0__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.
- epic_capybara/__about__.py +4 -0
- epic_capybara/__init__.py +3 -0
- epic_capybara/__main__.py +9 -0
- epic_capybara/cli/__init__.py +19 -0
- epic_capybara/cli/bara.py +506 -0
- epic_capybara/cli/capy.py +148 -0
- epic_capybara/cli/cate.py +64 -0
- epic_capybara/filesystem.py +21 -0
- epic_capybara/github.py +53 -0
- epic_capybara/util.py +43 -0
- epic_capybara-1.0.0.dist-info/METADATA +71 -0
- epic_capybara-1.0.0.dist-info/RECORD +15 -0
- epic_capybara-1.0.0.dist-info/WHEEL +4 -0
- epic_capybara-1.0.0.dist-info/entry_points.txt +5 -0
- epic_capybara-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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()
|
epic_capybara/github.py
ADDED
|
@@ -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
|
epic_capybara/util.py
ADDED
|
@@ -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,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,15 @@
|
|
|
1
|
+
epic_capybara/__about__.py,sha256=XKaIi9IVU85nf6UK38LlO_cJN0hrhpPXhhwogdZg428,138
|
|
2
|
+
epic_capybara/__init__.py,sha256=Hybz_Zmg7c3lp7QTJYsNnoe1YpTRCfS5S6Jj7lmO76o,116
|
|
3
|
+
epic_capybara/__main__.py,sha256=8i2gcTdVJOt132yN1u2urHYpubuCnx1a4g9oCL7SVGI,211
|
|
4
|
+
epic_capybara/filesystem.py,sha256=1KwCRBRWFGBKf6HrMRIz12Et6cPXZMFVnA8XRvS090s,566
|
|
5
|
+
epic_capybara/github.py,sha256=9rUmCkRrUfeRxLoOqtiryG5AZMmyFhFZo6URS8fZlgM,1994
|
|
6
|
+
epic_capybara/util.py,sha256=solC8l4pNs1h4cpCskvtf1fjpv7izCMRS6oRjR3I6Io,1669
|
|
7
|
+
epic_capybara/cli/__init__.py,sha256=BcqSnGf9RLUHE2z1-CV9saA7AdIoPtOSLc1VysTCc6U,547
|
|
8
|
+
epic_capybara/cli/bara.py,sha256=qfq2R0pUYN6CrL85z4FHSptz0ho5AR6y4ODiV7tQDdU,19288
|
|
9
|
+
epic_capybara/cli/capy.py,sha256=8SNWSnx3dsBtDLha8iT3sN08SsEsp13ixLpQ9xjoOYo,6814
|
|
10
|
+
epic_capybara/cli/cate.py,sha256=7BlG51dZTieACDjZWf_Xa2hLMRwhtza3DF_E7PrZ0eU,2298
|
|
11
|
+
epic_capybara-1.0.0.dist-info/METADATA,sha256=q7aifoTA_QSXLjqMWbaAjGyopWIkR6RrIEmOAoPsag4,2335
|
|
12
|
+
epic_capybara-1.0.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
13
|
+
epic_capybara-1.0.0.dist-info/entry_points.txt,sha256=kvAL3qahderLPCcAQezkS4aOy2k_IuPil6fgKfXLdvk,146
|
|
14
|
+
epic_capybara-1.0.0.dist-info/licenses/LICENSE,sha256=qS3sGL9u-IELShbYD5dAKspjFY5XVPE8AezM0n3K4yI,1093
|
|
15
|
+
epic_capybara-1.0.0.dist-info/RECORD,,
|
|
@@ -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.
|