rootfig 0.1.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.
rootfig/io/sources.py ADDED
@@ -0,0 +1,487 @@
1
+ """Data sources: where branch arrays come from.
2
+
3
+ Two implementations of the :class:`Source` protocol are provided:
4
+
5
+ * :class:`FileSource` reads ROOT files with uproot. It accepts single paths,
6
+ glob patterns, lists of either, remote URLs, and ``"file.root:tree"``
7
+ shorthand, and works with both ``TTree`` and ``RNTuple`` objects.
8
+ * :class:`ArraySource` wraps data that is already in memory (a mapping of
9
+ arrays, an Awkward record array, or a NumPy structured array).
10
+
11
+ Everything downstream of this module (expressions, selection, histogramming,
12
+ plotting) only sees the protocol, so it is independent of file I/O.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import glob
18
+ import os
19
+ from collections.abc import Mapping, Sequence
20
+ from dataclasses import dataclass, field
21
+ from os import PathLike
22
+ from pathlib import Path
23
+ from typing import Any, Protocol, runtime_checkable
24
+
25
+ import awkward as ak
26
+ import numpy as np
27
+ import uproot
28
+
29
+ from rootfig.errors import SourceError
30
+
31
+ __all__ = ["ArraySource", "FileSource", "FilesLike", "Source", "as_source", "resolve_files"]
32
+
33
+ FilesLike = str | PathLike[str] | Sequence[str | PathLike[str]]
34
+ """A path, glob pattern, URL, ``"path:tree"`` string, or a sequence of those."""
35
+
36
+ _REMOTE_PREFIXES = ("root://", "http://", "https://", "s3://", "gs://", "xrootd://")
37
+ _TREE_CLASSNAMES = ("TTree", "TNtuple", "TNtupleD", "TChain")
38
+ _RNTUPLE_MARKER = "RNTuple"
39
+
40
+
41
+ @runtime_checkable
42
+ class Source(Protocol):
43
+ """Anything that can list branch names and return branch arrays.
44
+
45
+ Implementations must be cheap to construct; opening files or reading data
46
+ should happen inside :meth:`branches` and :meth:`arrays`.
47
+ """
48
+
49
+ def branches(self) -> list[str]:
50
+ """Return the names of all available branches."""
51
+ ...
52
+
53
+ def arrays(self, branches: Sequence[str]) -> dict[str, ak.Array]:
54
+ """Read the given branches and return them as Awkward arrays of equal length."""
55
+ ...
56
+
57
+ def describe(self) -> str:
58
+ """Return a short human-readable description used in labels and messages."""
59
+ ...
60
+
61
+ # Implementations should also provide ``num_entries() -> int`` (the number of
62
+ # events); rootfig falls back to reading one branch when it is absent.
63
+
64
+
65
+ # --------------------------------------------------------------------------------------
66
+ # Files
67
+ # --------------------------------------------------------------------------------------
68
+
69
+
70
+ def _is_remote(path: str) -> bool:
71
+ return path.startswith(_REMOTE_PREFIXES)
72
+
73
+
74
+ def _split_tree(path: str) -> tuple[str, str | None]:
75
+ """Split ``"file.root:tree"`` into ``("file.root", "tree")``.
76
+
77
+ Only the last colon is considered and only when the part before it ends
78
+ with ``.root`` (case-insensitive), so URLs such as ``root://host//file.root``
79
+ are left intact. The tree part may name a tree inside a directory
80
+ (``"file.root:dir/events"``).
81
+ """
82
+ head, sep, tail = path.rpartition(":")
83
+ if sep and head.lower().endswith(".root") and tail:
84
+ return head, tail
85
+ return path, None
86
+
87
+
88
+ def resolve_files(files: FilesLike) -> tuple[tuple[str, ...], str | None]:
89
+ """Expand ``files`` into a sorted tuple of concrete paths and an optional tree name.
90
+
91
+ Parameters
92
+ ----------
93
+ files
94
+ A path, glob pattern, remote URL, ``"path:tree"`` string, or a sequence
95
+ of any of these. Local glob patterns are expanded and sorted so results
96
+ are deterministic regardless of filesystem order.
97
+
98
+ Returns
99
+ -------
100
+ tuple
101
+ ``(paths, tree)`` where ``tree`` is the tree name found in ``"path:tree"``
102
+ strings (all must agree) or ``None``.
103
+
104
+ Raises
105
+ ------
106
+ SourceError
107
+ If a pattern matches nothing, a local file does not exist, or the
108
+ embedded tree names disagree.
109
+ """
110
+ if isinstance(files, str | PathLike):
111
+ items: list[str] = [os.fspath(files)]
112
+ else:
113
+ items = [os.fspath(f) for f in files]
114
+ if not items:
115
+ msg = "no input files given"
116
+ raise SourceError(msg)
117
+
118
+ paths: list[str] = []
119
+ trees: set[str] = set()
120
+ for item in items:
121
+ path, tree = _split_tree(item)
122
+ if tree is not None:
123
+ trees.add(tree)
124
+ if _is_remote(path):
125
+ paths.append(path)
126
+ continue
127
+ expanded = os.path.expanduser(path)
128
+ if glob.has_magic(expanded):
129
+ matches = sorted(glob.glob(expanded))
130
+ if not matches:
131
+ msg = f"no files match pattern {path!r}"
132
+ raise SourceError(msg)
133
+ paths.extend(matches)
134
+ else:
135
+ if not Path(expanded).is_file():
136
+ msg = f"file not found: {path!r}"
137
+ raise SourceError(msg)
138
+ paths.append(expanded)
139
+
140
+ if len(trees) > 1:
141
+ msg = f"conflicting tree names in file specification: {sorted(trees)}"
142
+ raise SourceError(msg)
143
+ return tuple(paths), (trees.pop() if trees else None)
144
+
145
+
146
+ def _strip_cycle(key: str) -> str:
147
+ return key.rsplit(";", 1)[0]
148
+
149
+
150
+ def _leaf_names(keys: Sequence[str]) -> list[str]:
151
+ """Branch names as users write them: sub-branches by their own (dotted) name, no parents.
152
+
153
+ uproot lists split object branches of a ``TTree`` as ``Parent/Parent.field``
154
+ plus the parent itself, and the nested fields of an ``RNTuple`` as
155
+ ``Parent.field`` plus ``Parent``; only the leaves hold arrays that can be
156
+ histogrammed.
157
+ """
158
+ parents = {key.rsplit("/", 1)[0] for key in keys if "/" in key}
159
+ leaves = [key.rsplit("/", 1)[-1] for key in keys if key not in parents]
160
+ records = {leaf for leaf in leaves if any(other.startswith(f"{leaf}.") for other in leaves)}
161
+ return [leaf for leaf in leaves if leaf not in records]
162
+
163
+
164
+ def _extract_nested(data: Mapping[str, Any], name: str) -> ak.Array | None:
165
+ """Return the field ``Parent.field.sub`` from a record array read as ``Parent``."""
166
+ parts = name.split(".")
167
+ if parts[0] not in data:
168
+ return None
169
+ array = data[parts[0]]
170
+ for part in parts[1:]:
171
+ if part not in getattr(array, "fields", ()):
172
+ return None
173
+ array = array[part]
174
+ return ak.Array(array)
175
+
176
+
177
+ def _is_tree_class(classname: str) -> bool:
178
+ return classname.startswith(_TREE_CLASSNAMES) or _RNTUPLE_MARKER in classname
179
+
180
+
181
+ def _detect_tree(path: str) -> str:
182
+ """Return the name of the only tree-like object in ``path`` or raise."""
183
+ with uproot.open(path) as file:
184
+ classnames: dict[str, str] = file.classnames(recursive=True)
185
+ trees = sorted({_strip_cycle(k) for k, cls in classnames.items() if _is_tree_class(cls)})
186
+ if len(trees) == 1:
187
+ return trees[0]
188
+ if not trees:
189
+ found = sorted({f"{_strip_cycle(k)} ({cls})" for k, cls in classnames.items()})
190
+ msg = f"no TTree or RNTuple found in {path!r}; objects present: {found}"
191
+ raise SourceError(msg)
192
+ msg = f"several trees found in {path!r}: {trees}. Pass tree=... to choose one"
193
+ raise SourceError(msg)
194
+
195
+
196
+ @dataclass(frozen=True)
197
+ class FileSource:
198
+ """Branch arrays read from one or more ROOT files with uproot.
199
+
200
+ Parameters
201
+ ----------
202
+ files
203
+ See :data:`FilesLike`. Globs are expanded and sorted at construction.
204
+ tree
205
+ Name of the ``TTree``/``RNTuple``. If omitted, the files are inspected
206
+ and the tree is auto-detected when there is exactly one.
207
+ entry_start, entry_stop
208
+ Restrict reading to a range of entries counted across all files, in the
209
+ same way as :func:`uproot.concatenate`. Useful for quick looks at large
210
+ inputs.
211
+ """
212
+
213
+ files: tuple[str, ...]
214
+ tree: str | None = None
215
+ entry_start: int | None = None
216
+ entry_stop: int | None = None
217
+ _cache: dict[str, Any] = field(default_factory=dict, repr=False, compare=False)
218
+
219
+ def __init__(
220
+ self,
221
+ files: FilesLike,
222
+ tree: str | None = None,
223
+ *,
224
+ entry_start: int | None = None,
225
+ entry_stop: int | None = None,
226
+ ) -> None:
227
+ paths, embedded_tree = resolve_files(files)
228
+ if tree is not None and embedded_tree is not None and tree != embedded_tree:
229
+ msg = f"tree {tree!r} conflicts with {embedded_tree!r} given in the file path"
230
+ raise SourceError(msg)
231
+ object.__setattr__(self, "files", paths)
232
+ object.__setattr__(self, "tree", tree if tree is not None else embedded_tree)
233
+ object.__setattr__(self, "entry_start", entry_start)
234
+ object.__setattr__(self, "entry_stop", entry_stop)
235
+ object.__setattr__(self, "_cache", {})
236
+
237
+ # -- Source protocol --------------------------------------------------------------
238
+
239
+ def resolved_tree(self) -> str:
240
+ """Return the tree name, auto-detecting it from the first file if needed."""
241
+ if self.tree is not None:
242
+ return self.tree
243
+ if "tree" not in self._cache:
244
+ self._cache["tree"] = _detect_tree(self.files[0])
245
+ return str(self._cache["tree"])
246
+
247
+ def branches(self) -> list[str]:
248
+ """Return the branch (or RNTuple field) names of the tree in the first file.
249
+
250
+ The result is cached on the instance; the file is opened only once.
251
+ """
252
+ if "branches" in self._cache:
253
+ return list(self._cache["branches"])
254
+ tree = self.resolved_tree()
255
+ try:
256
+ with uproot.open(self.files[0]) as file:
257
+ obj = file[tree]
258
+ keys = list(obj.keys())
259
+ self._cache["rntuple"] = _RNTUPLE_MARKER in type(obj).__name__
260
+ except uproot.KeyInFileError as exc:
261
+ msg = f"tree {tree!r} not found in {self.files[0]!r}"
262
+ raise SourceError(msg) from exc
263
+ names = _leaf_names(keys)
264
+ self._cache["branches"] = names
265
+ return list(names)
266
+
267
+ def _is_rntuple(self) -> bool:
268
+ if "rntuple" not in self._cache:
269
+ self.branches()
270
+ return bool(self._cache["rntuple"])
271
+
272
+ def num_entries(self) -> int:
273
+ """Return the number of entries (all files, honouring the entry range)."""
274
+ tree = self.resolved_tree()
275
+ if "num_entries" not in self._cache:
276
+ total = 0
277
+ for path in self.files:
278
+ with uproot.open(path) as file:
279
+ total += int(file[tree].num_entries)
280
+ self._cache["num_entries"] = total
281
+ total = int(self._cache["num_entries"])
282
+ start, stop, _ = slice(self.entry_start, self.entry_stop).indices(total)
283
+ return max(stop - start, 0)
284
+
285
+ def read_scalar(self, key: str) -> float:
286
+ """Sum a number stored under ``key`` in every file.
287
+
288
+ Accepts histograms (their total content including flow bins, e.g. a
289
+ sum-of-weights histogram) and ``TParameter`` objects (their value).
290
+ Typical use: the number of generated events written by a framework.
291
+ """
292
+ total = 0.0
293
+ for path in self.files:
294
+ with uproot.open(path) as file:
295
+ try:
296
+ obj = file[key]
297
+ except uproot.KeyInFileError as exc:
298
+ msg = f"object {key!r} not found in {path!r}"
299
+ raise SourceError(msg) from exc
300
+ if hasattr(obj, "axes") and hasattr(obj, "values"): # a histogram
301
+ total += float(np.sum(obj.values(flow=True)))
302
+ elif hasattr(obj, "member") and "fVal" in getattr(obj, "members", {}):
303
+ total += float(obj.member("fVal")) # a TParameter
304
+ else:
305
+ msg = f"object {key!r} in {path!r} ({type(obj).__name__}) holds no number"
306
+ raise SourceError(msg)
307
+ return total
308
+
309
+ def arrays(self, branches: Sequence[str]) -> dict[str, ak.Array]:
310
+ """Read ``branches`` from all files and concatenate them."""
311
+ tree = self.resolved_tree()
312
+ if not branches:
313
+ return {}
314
+ wanted = set(branches)
315
+ # A TTree presents split object branches by their full dotted leaf name; an
316
+ # RNTuple presents nested fields by their short name, one level at a time, and
317
+ # returns the parent as a record array. For RNTuples let every level through and
318
+ # pick the nested field out of the record afterwards.
319
+ parts = (
320
+ {part for name in wanted for part in name.split(".")} if self._is_rntuple() else set()
321
+ )
322
+ try:
323
+ # Select by name rather than by expression so that dotted sub-branch names
324
+ # (``Collection.field.x`` in EDM4hep/podio files) and odd characters work.
325
+ data = uproot.concatenate(
326
+ [{path: tree} for path in self.files],
327
+ filter_name=lambda name: name in wanted or name in parts,
328
+ entry_start=self.entry_start,
329
+ entry_stop=self.entry_stop,
330
+ library="ak",
331
+ how=dict,
332
+ )
333
+ except uproot.KeyInFileError as exc:
334
+ msg = f"could not read {tree!r} from {self.files}: {exc}"
335
+ raise SourceError(msg) from exc
336
+ result: dict[str, ak.Array] = {}
337
+ missing: list[str] = []
338
+ for name in branches:
339
+ array = ak.Array(data[name]) if name in data else _extract_nested(data, name)
340
+ if array is None:
341
+ missing.append(name)
342
+ else:
343
+ result[name] = array
344
+ if missing:
345
+ # RNTuple reads silently drop unknown fields; report them explicitly.
346
+ msg = f"branches {missing} not found in tree {tree!r}"
347
+ raise SourceError(msg)
348
+ return result
349
+
350
+ def describe(self) -> str:
351
+ """Return e.g. ``'events.root'`` or ``'3 files (run*.root)'``."""
352
+ if len(self.files) == 1:
353
+ return Path(self.files[0]).name
354
+ return f"{len(self.files)} files ({Path(self.files[0]).name}, ...)"
355
+
356
+ @property
357
+ def default_label(self) -> str:
358
+ """A legend label derived from the first file's stem."""
359
+ return Path(self.files[0]).stem
360
+
361
+
362
+ # --------------------------------------------------------------------------------------
363
+ # In-memory arrays
364
+ # --------------------------------------------------------------------------------------
365
+
366
+
367
+ @dataclass(frozen=True)
368
+ class ArraySource:
369
+ """Branch arrays already in memory.
370
+
371
+ Parameters
372
+ ----------
373
+ data
374
+ A mapping from branch name to array-like, an Awkward record array, or a
375
+ NumPy structured array. All columns must have the same length.
376
+ entry_start, entry_stop
377
+ Keep only this range of entries (Python slice semantics).
378
+ """
379
+
380
+ data: Mapping[str, ak.Array]
381
+
382
+ def __init__(
383
+ self,
384
+ data: Mapping[str, Any] | ak.Array | np.ndarray,
385
+ *,
386
+ entry_start: int | None = None,
387
+ entry_stop: int | None = None,
388
+ ) -> None:
389
+ columns = _to_columns(data)
390
+ lengths = {name: len(array) for name, array in columns.items()}
391
+ if len(set(lengths.values())) > 1:
392
+ msg = f"all arrays must have the same length, got {lengths}"
393
+ raise SourceError(msg)
394
+ if entry_start is not None or entry_stop is not None:
395
+ columns = {name: array[entry_start:entry_stop] for name, array in columns.items()}
396
+ object.__setattr__(self, "data", columns)
397
+
398
+ def branches(self) -> list[str]:
399
+ """Return the column names."""
400
+ return list(self.data)
401
+
402
+ def arrays(self, branches: Sequence[str]) -> dict[str, ak.Array]:
403
+ """Return the requested columns."""
404
+ missing = [b for b in branches if b not in self.data]
405
+ if missing:
406
+ msg = f"columns {missing} not found; available: {sorted(self.data)}"
407
+ raise SourceError(msg)
408
+ return {name: self.data[name] for name in branches}
409
+
410
+ def num_entries(self) -> int:
411
+ """Return the number of events (length of the arrays)."""
412
+ return len(next(iter(self.data.values()))) if self.data else 0
413
+
414
+ def describe(self) -> str:
415
+ """Return a short description of the in-memory data."""
416
+ n = len(next(iter(self.data.values()))) if self.data else 0
417
+ return f"in-memory arrays ({n} events, {len(self.data)} columns)"
418
+
419
+ @property
420
+ def default_label(self) -> str:
421
+ """A generic legend label for in-memory data."""
422
+ return "arrays"
423
+
424
+
425
+ def _to_columns(data: object) -> dict[str, ak.Array]:
426
+ if isinstance(data, Mapping):
427
+ if not data:
428
+ msg = "empty mapping given as data source"
429
+ raise SourceError(msg)
430
+ return {str(k): _as_ak(v) for k, v in data.items()}
431
+ if isinstance(data, ak.Array):
432
+ if not data.fields:
433
+ msg = "Awkward array must be a record array with named fields"
434
+ raise SourceError(msg)
435
+ return {name: data[name] for name in data.fields}
436
+ if isinstance(data, np.ndarray):
437
+ if data.dtype.names is None:
438
+ msg = "NumPy array must be a structured array with named fields"
439
+ raise SourceError(msg)
440
+ return {name: ak.Array(data[name]) for name in data.dtype.names}
441
+ msg = f"unsupported in-memory data of type {type(data).__name__}"
442
+ raise SourceError(msg)
443
+
444
+
445
+ def _as_ak(value: Any) -> ak.Array:
446
+ if isinstance(value, ak.Array):
447
+ return value
448
+ try:
449
+ return ak.Array(value)
450
+ except Exception as exc:
451
+ msg = f"cannot convert {type(value).__name__} to an Awkward array"
452
+ raise SourceError(msg) from exc
453
+
454
+
455
+ # --------------------------------------------------------------------------------------
456
+ # Dispatch
457
+ # --------------------------------------------------------------------------------------
458
+
459
+
460
+ def as_source(
461
+ data: Any,
462
+ *,
463
+ tree: str | None = None,
464
+ entry_start: int | None = None,
465
+ entry_stop: int | None = None,
466
+ ) -> Source:
467
+ """Turn user input into a :class:`Source`.
468
+
469
+ Strings, paths, and sequences of them become a :class:`FileSource`;
470
+ mappings, Awkward record arrays and NumPy structured arrays become an
471
+ :class:`ArraySource`; existing sources are returned unchanged.
472
+ """
473
+ if isinstance(data, FileSource | ArraySource):
474
+ return data
475
+ if isinstance(data, str | PathLike):
476
+ return FileSource(data, tree, entry_start=entry_start, entry_stop=entry_stop)
477
+ if isinstance(data, Mapping | ak.Array | np.ndarray):
478
+ return ArraySource(data, entry_start=entry_start, entry_stop=entry_stop)
479
+ if isinstance(data, Sequence) and all(isinstance(f, str | PathLike) for f in data):
480
+ return FileSource(data, tree, entry_start=entry_start, entry_stop=entry_stop)
481
+ if isinstance(data, Source):
482
+ return data
483
+ msg = (
484
+ f"cannot interpret {type(data).__name__} as a data source; expected file path(s), "
485
+ "a mapping of arrays, an Awkward record array, or a rootfig Source"
486
+ )
487
+ raise SourceError(msg)
@@ -0,0 +1,27 @@
1
+ """Declarative descriptions of what to plot: samples, variables, cuts, binning, style."""
2
+
3
+ from rootfig.model.binning import Axis, Bins, RangeSpec, auto_range, log_bins, resolve_axis
4
+ from rootfig.model.cuts import Cut, CutLike, as_cut
5
+ from rootfig.model.samples import Sample, SampleLike, as_samples
6
+ from rootfig.model.style import Style, StyleLike, as_style
7
+ from rootfig.model.variables import Variable, as_variable
8
+
9
+ __all__ = [
10
+ "Axis",
11
+ "Bins",
12
+ "Cut",
13
+ "CutLike",
14
+ "RangeSpec",
15
+ "Sample",
16
+ "SampleLike",
17
+ "Style",
18
+ "StyleLike",
19
+ "Variable",
20
+ "as_cut",
21
+ "as_samples",
22
+ "as_style",
23
+ "as_variable",
24
+ "auto_range",
25
+ "log_bins",
26
+ "resolve_axis",
27
+ ]