workfold 0.1.0a1__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.
- workfold/__init__.py +10 -0
- workfold/__main__.py +5 -0
- workfold/aggregation.py +394 -0
- workfold/application.py +1022 -0
- workfold/cli.py +204 -0
- workfold/collectors/__init__.py +54 -0
- workfold/collectors/base.py +41 -0
- workfold/collectors/filesystem.py +858 -0
- workfold/collectors/filesystem_times.py +379 -0
- workfold/collectors/git.py +788 -0
- workfold/collectors/git_changes.py +495 -0
- workfold/collectors/git_objects.py +370 -0
- workfold/collectors/git_reflogs.py +649 -0
- workfold/collectors/git_tags.py +549 -0
- workfold/collectors/ignores.py +621 -0
- workfold/collectors/linux_statx.py +145 -0
- workfold/config.py +505 -0
- workfold/coverage.py +553 -0
- workfold/models.py +363 -0
- workfold/provenance.py +187 -0
- workfold/py.typed +1 -0
- workfold/renderers/__init__.py +5 -0
- workfold/renderers/terminal.py +621 -0
- workfold/reports.py +101 -0
- workfold/sanitization.py +143 -0
- workfold/schedule.py +211 -0
- workfold/time_ranges.py +316 -0
- workfold-0.1.0a1.dist-info/METADATA +204 -0
- workfold-0.1.0a1.dist-info/RECORD +33 -0
- workfold-0.1.0a1.dist-info/WHEEL +5 -0
- workfold-0.1.0a1.dist-info/entry_points.txt +2 -0
- workfold-0.1.0a1.dist-info/licenses/LICENSE +21 -0
- workfold-0.1.0a1.dist-info/top_level.txt +1 -0
workfold/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""workfold command-line package."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("workfold")
|
|
7
|
+
except PackageNotFoundError: # pragma: no cover - only used from an unpackaged source tree
|
|
8
|
+
__version__ = "0.0.0"
|
|
9
|
+
|
|
10
|
+
__all__ = ["__version__"]
|
workfold/__main__.py
ADDED
workfold/aggregation.py
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
"""Renderer-neutral sparse weekly layout and activity summaries."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import heapq
|
|
6
|
+
from collections import Counter
|
|
7
|
+
from collections.abc import Iterable
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from datetime import timedelta
|
|
10
|
+
from typing import TypeVar
|
|
11
|
+
|
|
12
|
+
from workfold.models import ClassifiedMarker, RecordKind, Source, Weekday
|
|
13
|
+
|
|
14
|
+
MINUTES_PER_DAY = 24 * 60
|
|
15
|
+
NANOSECONDS_PER_SECOND = 1_000_000_000
|
|
16
|
+
NANOSECONDS_PER_MINUTE = 60 * NANOSECONDS_PER_SECOND
|
|
17
|
+
NANOSECONDS_PER_DAY = 24 * 60 * NANOSECONDS_PER_MINUTE
|
|
18
|
+
_CountKey = TypeVar("_CountKey", Source, RecordKind)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True, slots=True)
|
|
22
|
+
class ClusterCell:
|
|
23
|
+
"""The exact, ordered events for one weekday in a time cluster."""
|
|
24
|
+
|
|
25
|
+
weekday: Weekday
|
|
26
|
+
markers: tuple[ClassifiedMarker, ...]
|
|
27
|
+
|
|
28
|
+
def __post_init__(self) -> None:
|
|
29
|
+
if not self.markers:
|
|
30
|
+
raise ValueError("a cluster cell must contain at least one marker")
|
|
31
|
+
if any(marker.weekday is not self.weekday for marker in self.markers):
|
|
32
|
+
raise ValueError("cluster cell markers must match its weekday")
|
|
33
|
+
if len({marker.marker.marker_id for marker in self.markers}) != len(self.markers):
|
|
34
|
+
raise ValueError("a cluster cell cannot contain duplicate markers")
|
|
35
|
+
if self.markers != tuple(sorted(self.markers, key=_marker_order_key)):
|
|
36
|
+
raise ValueError("cluster cell markers must use deterministic event order")
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def event_count(self) -> int:
|
|
40
|
+
"""Return the number of individually renderable events in this cell."""
|
|
41
|
+
|
|
42
|
+
return len(self.markers)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True, slots=True)
|
|
46
|
+
class TimeCluster:
|
|
47
|
+
"""One globally aligned, greedily anchored occupied wall-clock row.
|
|
48
|
+
|
|
49
|
+
``start_time_ns`` and ``end_time_ns`` are the first and last observed
|
|
50
|
+
local times, not an estimate of activity duration. The clustering window
|
|
51
|
+
itself is stored once on :class:`Aggregation`.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
start_time_ns: int
|
|
55
|
+
end_time_ns: int
|
|
56
|
+
cells: tuple[ClusterCell, ...]
|
|
57
|
+
|
|
58
|
+
def __post_init__(self) -> None:
|
|
59
|
+
if not 0 <= self.start_time_ns <= self.end_time_ns < NANOSECONDS_PER_DAY:
|
|
60
|
+
raise ValueError("cluster times must form a non-empty range within one day")
|
|
61
|
+
if not self.cells:
|
|
62
|
+
raise ValueError("a time cluster must contain at least one occupied cell")
|
|
63
|
+
weekdays = tuple(cell.weekday for cell in self.cells)
|
|
64
|
+
if weekdays != tuple(sorted(weekdays)) or len(set(weekdays)) != len(weekdays):
|
|
65
|
+
raise ValueError("cluster cells must be unique and ordered by weekday")
|
|
66
|
+
markers = tuple(marker for cell in self.cells for marker in cell.markers)
|
|
67
|
+
observed_times = tuple(marker.time_of_day_ns for marker in markers)
|
|
68
|
+
if min(observed_times) != self.start_time_ns or max(observed_times) != self.end_time_ns:
|
|
69
|
+
raise ValueError("cluster bounds must match its observed event times")
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def event_count(self) -> int:
|
|
73
|
+
"""Return the number of individually renderable events in this row."""
|
|
74
|
+
|
|
75
|
+
return sum(cell.event_count for cell in self.cells)
|
|
76
|
+
|
|
77
|
+
def cell(self, weekday: Weekday) -> ClusterCell | None:
|
|
78
|
+
"""Return the occupied cell for *weekday*, if one exists."""
|
|
79
|
+
|
|
80
|
+
return next((cell for cell in self.cells if cell.weekday is weekday), None)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True, slots=True)
|
|
84
|
+
class HiddenMarkers:
|
|
85
|
+
"""Markers hidden on one side of an explicit display crop."""
|
|
86
|
+
|
|
87
|
+
total: int = 0
|
|
88
|
+
source_counts: tuple[tuple[Source, int], ...] = ()
|
|
89
|
+
|
|
90
|
+
def count_for_source(self, source: Source) -> int:
|
|
91
|
+
"""Return the hidden marker count for *source*."""
|
|
92
|
+
|
|
93
|
+
return _lookup_count(self.source_counts, source)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(frozen=True, slots=True)
|
|
97
|
+
class Aggregation:
|
|
98
|
+
"""Sparse occupied rows plus exact full-scope summary state."""
|
|
99
|
+
|
|
100
|
+
cluster_window: timedelta
|
|
101
|
+
display_start_minute: int
|
|
102
|
+
display_end_minute: int
|
|
103
|
+
display_is_explicit: bool
|
|
104
|
+
clusters: tuple[TimeCluster, ...]
|
|
105
|
+
event_count: int
|
|
106
|
+
within_schedule_count: int
|
|
107
|
+
outside_schedule_count: int
|
|
108
|
+
weekend_count: int
|
|
109
|
+
source_counts: tuple[tuple[Source, int], ...]
|
|
110
|
+
record_kind_counts: tuple[tuple[RecordKind, int], ...]
|
|
111
|
+
hidden_before: HiddenMarkers
|
|
112
|
+
hidden_after: HiddenMarkers
|
|
113
|
+
retained_outside_markers: tuple[ClassifiedMarker, ...]
|
|
114
|
+
outside_marker_count: int
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def outside_omitted_count(self) -> int:
|
|
118
|
+
"""Return outside markers not retained because of the list limit."""
|
|
119
|
+
|
|
120
|
+
return self.outside_marker_count - len(self.retained_outside_markers)
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def displayed_event_count(self) -> int:
|
|
124
|
+
"""Return the count represented by the sparse chart rows."""
|
|
125
|
+
|
|
126
|
+
return sum(cluster.event_count for cluster in self.clusters)
|
|
127
|
+
|
|
128
|
+
def count_for_source(self, source: Source) -> int:
|
|
129
|
+
"""Return the total marker count for *source*."""
|
|
130
|
+
|
|
131
|
+
return _lookup_count(self.source_counts, source)
|
|
132
|
+
|
|
133
|
+
def count_for_record_kind(self, record_kind: RecordKind) -> int:
|
|
134
|
+
"""Return the total marker count for *record_kind*."""
|
|
135
|
+
|
|
136
|
+
return _lookup_count(self.record_kind_counts, record_kind)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def aggregate_markers(
|
|
140
|
+
markers: Iterable[ClassifiedMarker],
|
|
141
|
+
*,
|
|
142
|
+
cluster_window: timedelta,
|
|
143
|
+
schedule_bounds: tuple[int, int] | None = None,
|
|
144
|
+
display_range: tuple[int, int] | None = None,
|
|
145
|
+
outside_limit: int = 50,
|
|
146
|
+
) -> Aggregation:
|
|
147
|
+
"""Summarize markers and build globally aligned sparse time clusters.
|
|
148
|
+
|
|
149
|
+
Visible events are sorted by exact localized time of day. Each cluster is
|
|
150
|
+
anchored at the earliest unassigned event and contains the half-open range
|
|
151
|
+
``[anchor, anchor + cluster_window)``. This intentionally prevents
|
|
152
|
+
transitive chaining from making a row wider than the requested duration.
|
|
153
|
+
|
|
154
|
+
``display_range`` remains a half-open wall-clock minute range. Cropping is
|
|
155
|
+
applied before clustering, so hidden events cannot affect visible rows.
|
|
156
|
+
All summary counts continue to describe the complete marker input.
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
cluster_window_ns = _cluster_window_ns(cluster_window)
|
|
160
|
+
_validate_schedule_bounds(schedule_bounds)
|
|
161
|
+
_validate_display_range(display_range)
|
|
162
|
+
if outside_limit < 0:
|
|
163
|
+
raise ValueError("outside_limit must not be negative")
|
|
164
|
+
|
|
165
|
+
visible_markers: list[ClassifiedMarker] = []
|
|
166
|
+
source_counts: Counter[Source] = Counter()
|
|
167
|
+
record_kind_counts: Counter[RecordKind] = Counter()
|
|
168
|
+
hidden_before_sources: Counter[Source] = Counter()
|
|
169
|
+
hidden_after_sources: Counter[Source] = Counter()
|
|
170
|
+
hidden_before_total = 0
|
|
171
|
+
hidden_after_total = 0
|
|
172
|
+
event_count = 0
|
|
173
|
+
within_schedule_count = 0
|
|
174
|
+
outside_schedule_count = 0
|
|
175
|
+
weekend_count = 0
|
|
176
|
+
outside_marker_count = 0
|
|
177
|
+
outside_heap: list[tuple[tuple[int, str], int, ClassifiedMarker]] = []
|
|
178
|
+
occupied_start_ns: int | None = None
|
|
179
|
+
occupied_end_ns: int | None = None
|
|
180
|
+
|
|
181
|
+
display_start_ns = display_range[0] * NANOSECONDS_PER_MINUTE if display_range is not None else None
|
|
182
|
+
display_end_ns = display_range[1] * NANOSECONDS_PER_MINUTE if display_range is not None else None
|
|
183
|
+
|
|
184
|
+
for classified in markers:
|
|
185
|
+
_validate_classified_marker(classified)
|
|
186
|
+
source, record_kind = _marker_dimensions(classified)
|
|
187
|
+
time_of_day_ns = classified.time_of_day_ns
|
|
188
|
+
|
|
189
|
+
event_count += 1
|
|
190
|
+
source_counts[source] += 1
|
|
191
|
+
record_kind_counts[record_kind] += 1
|
|
192
|
+
occupied_start_ns = time_of_day_ns if occupied_start_ns is None else min(occupied_start_ns, time_of_day_ns)
|
|
193
|
+
occupied_end_ns = time_of_day_ns if occupied_end_ns is None else max(occupied_end_ns, time_of_day_ns)
|
|
194
|
+
|
|
195
|
+
if classified.within_schedule:
|
|
196
|
+
within_schedule_count += 1
|
|
197
|
+
else:
|
|
198
|
+
outside_schedule_count += 1
|
|
199
|
+
outside_marker_count += 1
|
|
200
|
+
_retain_recent(outside_heap, classified, outside_limit, event_count)
|
|
201
|
+
if classified.weekend:
|
|
202
|
+
weekend_count += 1
|
|
203
|
+
|
|
204
|
+
if display_start_ns is None or display_end_ns is None:
|
|
205
|
+
visible_markers.append(classified)
|
|
206
|
+
elif display_start_ns <= time_of_day_ns < display_end_ns:
|
|
207
|
+
visible_markers.append(classified)
|
|
208
|
+
elif time_of_day_ns < display_start_ns:
|
|
209
|
+
hidden_before_total += 1
|
|
210
|
+
hidden_before_sources[source] += 1
|
|
211
|
+
else:
|
|
212
|
+
hidden_after_total += 1
|
|
213
|
+
hidden_after_sources[source] += 1
|
|
214
|
+
|
|
215
|
+
display_start, display_end = _resolve_display_range(
|
|
216
|
+
display_range=display_range,
|
|
217
|
+
schedule_bounds=schedule_bounds,
|
|
218
|
+
occupied_start_ns=occupied_start_ns,
|
|
219
|
+
occupied_end_ns=occupied_end_ns,
|
|
220
|
+
)
|
|
221
|
+
clusters = _cluster_markers(visible_markers, cluster_window_ns)
|
|
222
|
+
retained_outside = tuple(item[2] for item in sorted(outside_heap, key=lambda item: (item[0], item[1])))
|
|
223
|
+
|
|
224
|
+
aggregation = Aggregation(
|
|
225
|
+
cluster_window=cluster_window,
|
|
226
|
+
display_start_minute=display_start,
|
|
227
|
+
display_end_minute=display_end,
|
|
228
|
+
display_is_explicit=display_range is not None,
|
|
229
|
+
clusters=clusters,
|
|
230
|
+
event_count=event_count,
|
|
231
|
+
within_schedule_count=within_schedule_count,
|
|
232
|
+
outside_schedule_count=outside_schedule_count,
|
|
233
|
+
weekend_count=weekend_count,
|
|
234
|
+
source_counts=_freeze_counter(source_counts),
|
|
235
|
+
record_kind_counts=_freeze_counter(record_kind_counts),
|
|
236
|
+
hidden_before=HiddenMarkers(hidden_before_total, _freeze_counter(hidden_before_sources)),
|
|
237
|
+
hidden_after=HiddenMarkers(hidden_after_total, _freeze_counter(hidden_after_sources)),
|
|
238
|
+
retained_outside_markers=retained_outside,
|
|
239
|
+
outside_marker_count=outside_marker_count,
|
|
240
|
+
)
|
|
241
|
+
if aggregation.displayed_event_count + hidden_before_total + hidden_after_total != event_count:
|
|
242
|
+
raise RuntimeError("displayed and hidden marker totals do not reconcile")
|
|
243
|
+
return aggregation
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _cluster_markers(markers: Iterable[ClassifiedMarker], window_ns: int) -> tuple[TimeCluster, ...]:
|
|
247
|
+
ordered = sorted(markers, key=_marker_order_key)
|
|
248
|
+
clusters: list[TimeCluster] = []
|
|
249
|
+
cursor = 0
|
|
250
|
+
while cursor < len(ordered):
|
|
251
|
+
anchor = ordered[cursor].time_of_day_ns
|
|
252
|
+
window_end = anchor + window_ns
|
|
253
|
+
end = cursor + 1
|
|
254
|
+
while end < len(ordered) and ordered[end].time_of_day_ns < window_end:
|
|
255
|
+
end += 1
|
|
256
|
+
cluster_markers = ordered[cursor:end]
|
|
257
|
+
by_weekday: dict[Weekday, list[ClassifiedMarker]] = {}
|
|
258
|
+
for marker in cluster_markers:
|
|
259
|
+
by_weekday.setdefault(marker.weekday, []).append(marker)
|
|
260
|
+
cells = tuple(ClusterCell(weekday, tuple(by_weekday[weekday])) for weekday in sorted(by_weekday))
|
|
261
|
+
clusters.append(
|
|
262
|
+
TimeCluster(
|
|
263
|
+
start_time_ns=anchor,
|
|
264
|
+
end_time_ns=cluster_markers[-1].time_of_day_ns,
|
|
265
|
+
cells=cells,
|
|
266
|
+
)
|
|
267
|
+
)
|
|
268
|
+
cursor = end
|
|
269
|
+
return tuple(clusters)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _marker_order_key(classified: ClassifiedMarker) -> tuple[int, int, int, str]:
|
|
273
|
+
"""Order events by wall time, instant, source, then provenance.
|
|
274
|
+
|
|
275
|
+
Source is only consulted for genuinely simultaneous folded events, where
|
|
276
|
+
no chronological ordering exists. Keeping Git before filesystem makes
|
|
277
|
+
mixed cells visually stable instead of exposing hash-derived marker IDs.
|
|
278
|
+
"""
|
|
279
|
+
|
|
280
|
+
return (
|
|
281
|
+
classified.time_of_day_ns,
|
|
282
|
+
classified.marker.occurred_at_utc_ns,
|
|
283
|
+
0 if classified.marker.origin.source is Source.GIT else 1,
|
|
284
|
+
classified.marker.marker_id,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _marker_dimensions(classified: ClassifiedMarker) -> tuple[Source, RecordKind]:
|
|
289
|
+
observations = classified.marker.observations
|
|
290
|
+
if not observations:
|
|
291
|
+
raise ValueError("an activity marker must contain at least one observation")
|
|
292
|
+
source = observations[0].origin.source
|
|
293
|
+
record_kind = observations[0].origin.record_kind
|
|
294
|
+
for observation in observations[1:]:
|
|
295
|
+
if observation.origin.source is not source or observation.origin.record_kind is not record_kind:
|
|
296
|
+
raise ValueError("coalesced marker observations must share source and record kind")
|
|
297
|
+
return source, record_kind
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _validate_classified_marker(classified: ClassifiedMarker) -> None:
|
|
301
|
+
if not 0 <= classified.time_of_day_ns < NANOSECONDS_PER_DAY:
|
|
302
|
+
raise ValueError("classified marker local time must fall within one day")
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _cluster_window_ns(cluster_window: object) -> int:
|
|
306
|
+
if not isinstance(cluster_window, timedelta):
|
|
307
|
+
raise TypeError("cluster_window must be a datetime.timedelta")
|
|
308
|
+
nanoseconds = (
|
|
309
|
+
cluster_window.days * 86_400 * NANOSECONDS_PER_SECOND
|
|
310
|
+
+ cluster_window.seconds * NANOSECONDS_PER_SECOND
|
|
311
|
+
+ cluster_window.microseconds * 1_000
|
|
312
|
+
)
|
|
313
|
+
if not 0 < nanoseconds < NANOSECONDS_PER_DAY:
|
|
314
|
+
raise ValueError("cluster_window must be greater than zero and less than 24 hours")
|
|
315
|
+
return nanoseconds
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _retain_recent(
|
|
319
|
+
heap: list[tuple[tuple[int, str], int, ClassifiedMarker]],
|
|
320
|
+
classified: ClassifiedMarker,
|
|
321
|
+
limit: int,
|
|
322
|
+
ordinal: int,
|
|
323
|
+
) -> None:
|
|
324
|
+
if limit == 0:
|
|
325
|
+
return
|
|
326
|
+
key = (classified.marker.occurred_at_utc_ns, classified.marker.marker_id)
|
|
327
|
+
item = (key, ordinal, classified)
|
|
328
|
+
if len(heap) < limit:
|
|
329
|
+
heapq.heappush(heap, item)
|
|
330
|
+
elif key > heap[0][0]:
|
|
331
|
+
heapq.heapreplace(heap, item)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _resolve_display_range(
|
|
335
|
+
*,
|
|
336
|
+
display_range: tuple[int, int] | None,
|
|
337
|
+
schedule_bounds: tuple[int, int] | None,
|
|
338
|
+
occupied_start_ns: int | None,
|
|
339
|
+
occupied_end_ns: int | None,
|
|
340
|
+
) -> tuple[int, int]:
|
|
341
|
+
if display_range is not None:
|
|
342
|
+
return display_range
|
|
343
|
+
|
|
344
|
+
starts: list[int] = []
|
|
345
|
+
ends: list[int] = []
|
|
346
|
+
if schedule_bounds is not None:
|
|
347
|
+
starts.append(schedule_bounds[0])
|
|
348
|
+
ends.append(schedule_bounds[1])
|
|
349
|
+
if occupied_start_ns is not None and occupied_end_ns is not None:
|
|
350
|
+
starts.append(occupied_start_ns // NANOSECONDS_PER_MINUTE)
|
|
351
|
+
ends.append(occupied_end_ns // NANOSECONDS_PER_MINUTE + 1)
|
|
352
|
+
if not starts:
|
|
353
|
+
return (0, MINUTES_PER_DAY)
|
|
354
|
+
|
|
355
|
+
start = max(0, (min(starts) // 60) * 60)
|
|
356
|
+
end = min(MINUTES_PER_DAY, ((max(ends) + 59) // 60) * 60)
|
|
357
|
+
if start == end:
|
|
358
|
+
end = min(MINUTES_PER_DAY, start + 60)
|
|
359
|
+
if start == end:
|
|
360
|
+
start = max(0, end - 60)
|
|
361
|
+
return (start, end)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _validate_schedule_bounds(bounds: tuple[int, int] | None) -> None:
|
|
365
|
+
if bounds is None:
|
|
366
|
+
return
|
|
367
|
+
start, end = bounds
|
|
368
|
+
if not 0 <= start < end <= MINUTES_PER_DAY:
|
|
369
|
+
raise ValueError("schedule_bounds must be within 00:00-24:00 and non-empty")
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _validate_display_range(display_range: tuple[int, int] | None) -> None:
|
|
373
|
+
if display_range is None:
|
|
374
|
+
return
|
|
375
|
+
start, end = display_range
|
|
376
|
+
if not 0 <= start < end <= MINUTES_PER_DAY:
|
|
377
|
+
raise ValueError("display_range must be within 00:00-24:00 and non-empty")
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _freeze_counter(counter: Counter[_CountKey]) -> tuple[tuple[_CountKey, int], ...]:
|
|
381
|
+
return tuple(sorted(((key, count) for key, count in counter.items() if count), key=lambda item: item[0].value))
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _lookup_count(pairs: tuple[tuple[_CountKey, int], ...], key: _CountKey) -> int:
|
|
385
|
+
return next((count for candidate, count in pairs if candidate is key), 0)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
__all__ = [
|
|
389
|
+
"Aggregation",
|
|
390
|
+
"ClusterCell",
|
|
391
|
+
"HiddenMarkers",
|
|
392
|
+
"TimeCluster",
|
|
393
|
+
"aggregate_markers",
|
|
394
|
+
]
|