Some checks failed
Build / Test on ubuntu-latest, py-3.8 (push) Successful in 4m21s
Lint / lint (push) Successful in 25s
Build / Test on macOS-latest, py-3.8 (push) Has been cancelled
Build / Test on windows-latest, py-3.8 (push) Has been cancelled
CodeQL / Analyze (python) (push) Failing after 1s
151 lines
5.9 KiB
Python
151 lines
5.9 KiB
Python
import logging
|
|
from copy import deepcopy
|
|
from typing import List, Optional, Tuple
|
|
|
|
from aw_core.models import Event
|
|
from timeslot import Timeslot
|
|
|
|
from .filter_period_intersect import _get_event_period
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def merge_subwatcher_fields(
|
|
base_events: List[Event],
|
|
subwatcher_events: List[Event],
|
|
keys: List[str],
|
|
conflict: str = "base_wins",
|
|
) -> List[Event]:
|
|
"""
|
|
Split each event in *base_events* on overlapping subwatcher boundaries and
|
|
copy the named *keys* from the matching subwatcher event into each segment's
|
|
``data`` dict.
|
|
|
|
Unlike the ``concat`` workaround, this does not fabricate extra duration.
|
|
App/title/duration aggregations stay correct because the output still covers
|
|
exactly the same total time as *base_events*; only the segmentation changes
|
|
where subwatcher fields actually change.
|
|
|
|
This is the backend primitive that lets every client (webui, native UIs,
|
|
exporters) categorize by subwatcher fields (browser ``url``/``$domain``;
|
|
editor ``project``/``file``/``language``) without bespoke per-watcher
|
|
client-side code.
|
|
|
|
Args:
|
|
base_events: The canonical window/afk-filtered stream to enrich.
|
|
subwatcher_events: Events from a subwatcher bucket (e.g. aw-watcher-vim,
|
|
aw-watcher-web). Should already be clipped via
|
|
``filter_period_intersect`` before passing here.
|
|
keys: Which keys to copy from the subwatcher event into the base event.
|
|
Keys already present in the base event are left untouched when
|
|
``conflict="base_wins"`` (default).
|
|
conflict: ``"base_wins"`` (default) — base event's existing keys are
|
|
never overwritten; subwatcher fields are purely additive.
|
|
``"sub_wins"`` — subwatcher fields overwrite base fields.
|
|
|
|
Returns:
|
|
A new list of base event segments with subwatcher fields injected.
|
|
Events in *base_events* that have no overlapping subwatcher event are
|
|
returned with their original data unchanged.
|
|
|
|
Example::
|
|
|
|
window_events = query_bucket(bid_window)
|
|
editor_events = flood(query_bucket(bid_editor))
|
|
editor_events = filter_period_intersect(editor_events, window_events)
|
|
window_events = merge_subwatcher_fields(
|
|
window_events, editor_events, ["project", "file", "language"]
|
|
)
|
|
# Now categorize(window_events, ...) can match on "project"/"file"
|
|
|
|
Note on overlap:
|
|
Base events are split at the clipped subwatcher boundaries so each
|
|
output segment only carries the subwatcher fields that were actually
|
|
present during that slice of time. If multiple subwatcher events cover
|
|
the same slice, the most recent one wins so a later transition does not
|
|
get smeared backward by an older, longer pulse.
|
|
"""
|
|
if conflict not in ("base_wins", "sub_wins"):
|
|
raise ValueError(
|
|
f"conflict must be 'base_wins' or 'sub_wins', got {conflict!r}"
|
|
)
|
|
if not subwatcher_events or not keys:
|
|
return [deepcopy(e) for e in base_events]
|
|
|
|
# Build a sorted copy so we can do a linear scan
|
|
sub_sorted = sorted(subwatcher_events, key=lambda e: e.timestamp)
|
|
|
|
result: List[Event] = []
|
|
for base in base_events:
|
|
base_period = _get_event_period(base)
|
|
overlapping: List[Tuple[Event, Timeslot]] = []
|
|
boundaries = {base_period.start, base_period.end}
|
|
|
|
for sub in sub_sorted:
|
|
sub_period = _get_event_period(sub)
|
|
# Once sub starts after base ends we can stop
|
|
if sub_period.start >= base_period.end:
|
|
break
|
|
# Skip sub events that end before base starts
|
|
if sub_period.end <= base_period.start:
|
|
continue
|
|
ip = base_period.intersection(sub_period)
|
|
if ip:
|
|
overlapping.append((sub, sub_period))
|
|
boundaries.add(ip.start)
|
|
boundaries.add(ip.end)
|
|
|
|
if not overlapping:
|
|
result.append(deepcopy(base))
|
|
continue
|
|
|
|
boundary_points = sorted(boundaries)
|
|
base_segments: List[Event] = []
|
|
for start, end in zip(boundary_points, boundary_points[1:]):
|
|
segment_period = Timeslot(start, end)
|
|
best_sub: Optional[Event] = None
|
|
best_sub_period: Optional[Timeslot] = None
|
|
|
|
for sub, sub_period in overlapping:
|
|
if not segment_period.intersection(sub_period):
|
|
continue
|
|
|
|
# Later subwatcher events should supersede older overlapping
|
|
# pulses on the shared slice instead of letting stale data linger.
|
|
if (
|
|
best_sub is None
|
|
or sub.timestamp > best_sub.timestamp
|
|
or (
|
|
sub.timestamp == best_sub.timestamp
|
|
and best_sub_period is not None
|
|
and sub_period.end > best_sub_period.end
|
|
)
|
|
):
|
|
best_sub = sub
|
|
best_sub_period = sub_period
|
|
|
|
enriched = deepcopy(base)
|
|
enriched.timestamp = start
|
|
enriched.duration = end - start
|
|
|
|
if best_sub is not None:
|
|
for key in keys:
|
|
if key in best_sub.data:
|
|
if conflict == "base_wins" and key in enriched.data:
|
|
continue
|
|
enriched.data[key] = deepcopy(best_sub.data[key])
|
|
|
|
if (
|
|
base_segments
|
|
and base_segments[-1].timestamp + base_segments[-1].duration
|
|
== enriched.timestamp
|
|
and base_segments[-1].data == enriched.data
|
|
):
|
|
base_segments[-1].duration += enriched.duration
|
|
else:
|
|
base_segments.append(enriched)
|
|
|
|
result.extend(base_segments)
|
|
|
|
return result
|