snapshot of https://github.com/ActivityWatch/aw-core.git (master, 2026-09-16; blobs > 131072B omitted)
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

This commit is contained in:
upstream-mirror
2026-09-16 00:17:07 +05:00
commit 8757f2fd5e
73 changed files with 8869 additions and 0 deletions

44
aw_transform/__init__.py Normal file
View File

@@ -0,0 +1,44 @@
from .filter_keyvals import filter_keyvals, filter_keyvals_regex
from .filter_period_intersect import filter_period_intersect, period_union, union
from .heartbeats import heartbeat_merge, heartbeat_reduce
from .merge_events_by_keys import merge_events_by_keys
from .chunk_events_by_key import chunk_events_by_key
from .sort_by import (
sort_by_timestamp,
sort_by_duration,
sum_durations,
concat,
limit_events,
)
from .split_url_events import split_url_events
from .merge_subwatcher_fields import merge_subwatcher_fields
from .simplify import simplify_string
from .flood import flood
from .classify import categorize, tag, Rule
from .union_no_overlap import union_no_overlap
__all__ = [
"flood",
"concat",
"categorize",
"tag",
"Rule",
"period_union",
"filter_period_intersect",
"union",
"union_no_overlap",
"concat",
"sum_durations",
"sort_by_timestamp",
"sort_by_duration",
"heartbeat_reduce",
"heartbeat_merge",
"merge_events_by_keys",
"merge_subwatcher_fields",
"chunk_events_by_key",
"limit_events",
"filter_keyvals",
"filter_keyvals_regex",
"split_url_events",
"simplify_string",
]

View File

@@ -0,0 +1,39 @@
import logging
from datetime import timedelta
from typing import List
from aw_core.models import Event
logger = logging.getLogger(__name__)
def chunk_events_by_key(
events: List[Event], key: str, pulsetime: float = 5.0
) -> List[Event]:
"""
"Chunks" adjacent events together which have the same value for a key, and stores the
original events in the :code:`subevents` key of the new event.
"""
chunked_events: List[Event] = []
for event in events:
if key not in event.data:
break
timediff = timedelta(seconds=999999999) # FIXME: ugly but works
if len(chunked_events) > 0:
timediff = event.timestamp - (events[-1].timestamp + events[-1].duration)
if (
len(chunked_events) > 0
and chunked_events[-1].data[key] == event.data[key]
and timediff < timedelta(seconds=pulsetime)
):
chunked_event = chunked_events[-1]
chunked_event.duration += event.duration
chunked_event.data["subevents"].append(event)
else:
data = {key: event.data[key], "subevents": [event]}
chunked_event = Event(
timestamp=event.timestamp, duration=event.duration, data=data
)
chunked_events.append(chunked_event)
return chunked_events

122
aw_transform/classify.py Normal file
View File

@@ -0,0 +1,122 @@
import json
from typing import Pattern, List, Iterable, Tuple, Dict, Optional, Any
import re
from aw_core import Event
Tag = str
Category = List[str]
def _parse_optional_priority(rules: Dict[str, Any]) -> Optional[int]:
if "priority" in rules:
val = rules["priority"]
elif "weight" in rules:
val = rules["weight"]
else:
return None
# bool is a subclass of int
if isinstance(val, bool) or not isinstance(val, int):
raise ValueError("priority/weight must be an integer")
return val
class Rule:
regex: Optional[Pattern]
select_keys: Optional[List[str]]
ignore_case: bool
priority: Optional[int]
def __init__(self, rules: Dict[str, Any]) -> None:
self.select_keys = rules.get("select_keys", None)
self.ignore_case = rules.get("ignore_case", False)
self.priority = _parse_optional_priority(rules)
# NOTE: Also checks that the regex isn't an empty string (which would erroneously match everything)
regex_str = rules.get("regex", None)
self.regex = (
re.compile(
regex_str, (re.IGNORECASE if self.ignore_case else 0) | re.UNICODE
)
if regex_str
else None
)
def match(self, e: Event) -> bool:
if self.select_keys:
values = [e.data.get(key, None) for key in self.select_keys]
else:
values = list(e.data.values())
if self.regex:
for val in values:
if isinstance(val, str) and self.regex.search(val):
return True
return False
def categorize(
events: List[Event], classes: List[Tuple[Category, Rule]]
) -> List[Event]:
cache: Dict[str, Category] = {}
for e in events:
try:
key = json.dumps(e.data, sort_keys=True)
except TypeError:
key = str(id(e.data))
if key not in cache:
cache[key] = _pick_category(
[(_cls, rule) for _cls, rule in classes if rule.match(e)]
)
e.data["$category"] = list(cache[key])
return events
def _categorize_one(e: Event, classes: List[Tuple[Category, Rule]]) -> Event:
e.data["$category"] = _pick_category(
[(_cls, rule) for _cls, rule in classes if rule.match(e)]
)
return e
def tag(events: List[Event], classes: List[Tuple[Tag, Rule]]) -> List[Event]:
cache: Dict[str, List[Tag]] = {}
for e in events:
try:
key = json.dumps(e.data, sort_keys=True)
except TypeError:
key = str(id(e.data))
if key not in cache:
cache[key] = [_cls for _cls, rule in classes if rule.match(e)]
e.data["$tags"] = list(cache[key])
return events
def _tag_one(e: Event, classes: List[Tuple[Tag, Rule]]) -> Event:
e.data["$tags"] = [_cls for _cls, rule in classes if rule.match(e)]
return e
def _effective_rank(category: Category, rule: Rule) -> int:
# Integer-only. Default is depth * 10 so explicit priorities can slot
# between nesting levels (depth 1 → 10, depth 2 → 20). Relative order of
# unprioritized rules is unchanged.
if rule.priority is not None:
return rule.priority
return len(category) * 10
def _pick_category(matches: Iterable[Tuple[Category, Rule]]) -> Category:
category: Category = ["Uncategorized"]
rank: Optional[int] = None
for cat, rule in matches:
if not cat:
continue
item_rank = _effective_rank(cat, rule)
# None means no match yet, so any non-empty category wins — including
# an explicit priority below a signed 64-bit floor. Equal ranks keep
# the later match (same contract as the old depth-only `>=`).
if rank is None or item_rank >= rank:
category = cat
rank = item_rank
return category

View File

@@ -0,0 +1,28 @@
import logging
from typing import List
import re
from aw_core.models import Event
logger = logging.getLogger(__name__)
def filter_keyvals(
events: List[Event], key: str, vals: List[str], exclude=False
) -> List[Event]:
def predicate(event):
return key in event.data and event.data[key] in vals
if exclude:
return [e for e in events if not predicate(e)]
else:
return [e for e in events if predicate(e)]
def filter_keyvals_regex(events: List[Event], key: str, regex: str) -> List[Event]:
r = re.compile(regex)
def predicate(event):
return key in event.data and bool(r.findall(event.data[key]))
return [e for e in events if predicate(e)]

View File

@@ -0,0 +1,173 @@
import logging
from typing import List, Iterable, Tuple
from copy import deepcopy
from aw_core import Event
from timeslot import Timeslot
logger = logging.getLogger(__name__)
def _get_event_period(event: Event) -> Timeslot:
start = event.timestamp
end = start + event.duration
return Timeslot(start, end)
def _replace_event_period(event: Event, period: Timeslot) -> Event:
e = deepcopy(event)
e.timestamp = period.start
e.duration = period.duration
return e
def _intersecting_eventpairs(
events1: List[Event], events2: List[Event]
) -> Iterable[Tuple[Event, Event, Timeslot]]:
"""A generator that yields each overlapping pair of events from two eventlists along with a Timeslot of the intersection"""
events1.sort(key=lambda e: e.timestamp)
events2.sort(key=lambda e: e.timestamp)
e1_i = 0
e2_i = 0
while e1_i < len(events1) and e2_i < len(events2):
e1 = events1[e1_i]
e2 = events2[e2_i]
e1_p = _get_event_period(e1)
e2_p = _get_event_period(e2)
ip = e1_p.intersection(e2_p)
if ip:
# If events intersected, yield events
yield (e1, e2, ip)
if e1_p.end <= e2_p.end:
e1_i += 1
else:
e2_i += 1
else:
# No intersection, check if event is before/after filterevent
if e1_p.end <= e2_p.start:
# Event ended before filter event started
e1_i += 1
elif e2_p.end <= e1_p.start:
# Event started after filter event ended
e2_i += 1
else:
logger.error("Should be unreachable, skipping period")
e1_i += 1
e2_i += 1
def filter_period_intersect(
events: List[Event], filterevents: List[Event]
) -> List[Event]:
"""
Filters away all events or time periods of events in which a
filterevent does not have an intersecting time period.
Useful for example when you want to filter away events or
part of events during which a user was AFK.
Usage:
windowevents_notafk = filter_period_intersect(windowevents, notafkevents)
Example:
.. code-block:: none
events1 | ======= ======== |
events2 | ------ --- --- ---- |
result | ==== = ==== |
A JavaScript version used to exist in aw-webui but was removed in `this PR <https://github.com/ActivityWatch/aw-webui/pull/48>`_.
"""
events = sorted(events)
filterevents = sorted(filterevents)
return [
_replace_event_period(e1, ip)
for (e1, _, ip) in _intersecting_eventpairs(events, filterevents)
]
def period_union(events1: List[Event], events2: List[Event]) -> List[Event]:
"""
Takes a list of two events and returns a new list of events covering the union
of the timeperiods contained in the eventlists with no overlapping events.
.. warning:: This function strips all data from events as it cannot keep it consistent.
Example:
.. code-block:: none
events1 | ------- --------- |
events2 | ------ --- -- ---- |
result | ----------- -- --------- |
"""
events = sorted(events1 + events2)
merged_events = []
if events:
merged_events.append(events.pop(0))
for e in events:
last_event = merged_events[-1]
e_p = _get_event_period(e)
le_p = _get_event_period(last_event)
if not e_p.gap(le_p):
new_period = e_p.union(le_p)
merged_events[-1] = _replace_event_period(last_event, new_period)
else:
merged_events.append(e)
for event in merged_events:
# Clear data
event.data = {}
return merged_events
def union(events1: List[Event], events2: List[Event]) -> List[Event]:
"""
Concatenates and sorts union of 2 event lists and removes duplicates.
Example:
Merges events from a backup-bucket with events from a "living" bucket.
.. code-block:: python
events = union(events_backup, events_living)
"""
events1 = sorted(events1, key=lambda e: (e.timestamp, e.duration))
events2 = sorted(events2, key=lambda e: (e.timestamp, e.duration))
events_union = []
e1_i = 0
e2_i = 0
while e1_i < len(events1) and e2_i < len(events2):
e1 = events1[e1_i]
e2 = events2[e2_i]
if e1 == e2:
events_union.append(e1)
e1_i += 1
e2_i += 1
else:
if e1.timestamp < e2.timestamp:
events_union.append(e1)
e1_i += 1
elif e1.timestamp > e2.timestamp:
events_union.append(e2)
e2_i += 1
elif e1.duration < e2.duration:
events_union.append(e1)
e1_i += 1
else:
events_union.append(e2)
e2_i += 1
if e1_i < len(events1):
events_union.extend(events1[e1_i:])
if e2_i < len(events2):
events_union.extend(events2[e2_i:])
return events_union

121
aw_transform/flood.py Normal file
View File

@@ -0,0 +1,121 @@
import logging
from datetime import timedelta
from copy import deepcopy
from typing import List
from aw_core.models import Event
logger = logging.getLogger(__name__)
def flood(events: List[Event], pulsetime: float = 5) -> List[Event]:
"""Fill short gaps between events and merge nearby equal-data events.
Events are ordered by timestamp and duration. Gaps no larger than
``pulsetime`` are filled. Equal-data neighbours merge across the gap; when
their data differs, both events extend to the midpoint. Splitting an
uncertain interval evenly is independent of the surrounding event lengths
and matches the Rust implementation.
Overlapping equal-data events merge. For overlapping differing-data events,
final normalization gives the later event precedence so the result never
double-counts time.
See https://github.com/ActivityWatch/activitywatch/issues/124 for the data
collection uncertainty that flooding is intended to handle.
"""
# Originally written in aw-research: https://github.com/ActivityWatch/aw-analysis/blob/7da1f2cd8552f866f643501de633d74cdecab168/aw_analysis/flood.py
# NOTE: This algorithm has a lot of smaller details that need to be
# carefully considered by anyone wishing to edit it, see:
# - https://github.com/ActivityWatch/aw-core/pull/73
events = deepcopy(events)
# Sort by (timestamp, duration) so shorter same-timestamp events come first.
# This matches aw-server-rust's sort_by_timestamp behavior and ensures
# a well-defined processing order when events share the same timestamp.
events = sorted(events, key=lambda e: (e.timestamp, e.duration))
# If negative gaps are smaller than this, prune them to become zero
negative_gap_trim_thres = timedelta(seconds=0.1)
warned_about_negative_gap_safe = False
warned_about_negative_gap_unsafe = False
for e1, e2 in zip(events[:-1], events[1:]):
gap = e2.timestamp - (e1.timestamp + e1.duration)
if not gap:
continue
# Sanity check in case events overlap
if gap < timedelta(0) and e1.data == e2.data:
# Events with negative gap but same data can safely be merged
start = min(e1.timestamp, e2.timestamp)
end = max(e1.timestamp + e1.duration, e2.timestamp + e2.duration)
e1.timestamp, e1.duration = start, (end - start)
e2.timestamp, e2.duration = end, timedelta(0)
if not warned_about_negative_gap_safe:
logger.warning(
f"Gap was of negative duration but could be safely merged ({gap.total_seconds()}s). This message will only show once per batch."
)
warned_about_negative_gap_safe = True
elif gap < -negative_gap_trim_thres and not warned_about_negative_gap_unsafe:
# Events with negative gap but differing data cannot be merged safely
logger.warning(
f"Gap was of negative duration and could NOT be safely merged ({gap.total_seconds()}s). This warning will only show once per batch."
)
warned_about_negative_gap_unsafe = True
# logger.warning("Event 1 (id {}): {} {}".format(e1.id, e1.timestamp, e1.duration))
# logger.warning("Event 2 (id {}): {} {}".format(e2.id, e2.timestamp, e2.duration))
elif -negative_gap_trim_thres < gap <= timedelta(seconds=pulsetime):
e2_end = e2.timestamp + e2.duration
if e1.data == e2.data:
# Preserve the longer neighbour's extent while merging across
# the gap, matching the existing semantics for equal data.
if e1.duration >= e2.duration:
e1.duration = e2_end - e1.timestamp
e2.timestamp = e2_end
e2.duration = timedelta(0)
else:
e2.timestamp = e1.timestamp
e2.duration = e2_end - e2.timestamp
e1.duration = timedelta(0)
else:
# The gap is an interval of uncertainty: without evidence that
# either neighbour owns more of it, split it at the midpoint.
midpoint = e1.timestamp + e1.duration + gap / 2
e1.duration = midpoint - e1.timestamp
e2.timestamp = midpoint
e2.duration = e2_end - midpoint
# Pairwise flooding can mutate an event after its previous pair has already
# been processed. Normalize the final stream so downstream consumers never
# double-count overlapping time. For differing data, the later event wins.
normalized: List[Event] = []
for event in (e for e in events if e.duration > timedelta(0)):
while (
normalized
and normalized[-1].timestamp + normalized[-1].duration > event.timestamp
):
previous = normalized[-1]
if previous.data == event.data:
end = max(
previous.timestamp + previous.duration,
event.timestamp + event.duration,
)
previous.duration = end - previous.timestamp
break
previous.duration = event.timestamp - previous.timestamp
if previous.duration <= timedelta(0):
normalized.pop()
continue
normalized.append(event)
break
else:
normalized.append(event)
return normalized

View File

@@ -0,0 +1,56 @@
import logging
from datetime import timedelta
from typing import List, Optional
from aw_core.models import Event
logger = logging.getLogger(__name__)
def heartbeat_reduce(events: List[Event], pulsetime: float) -> List[Event]:
"""Merges consecutive events together according to the rules of `heartbeat_merge`."""
reduced = []
if events:
reduced.append(events.pop(0))
for heartbeat in events:
merged = heartbeat_merge(reduced[-1], heartbeat, pulsetime)
if merged is not None:
# Heartbeat was merged
reduced[-1] = merged
else:
# Heartbeat was not merged
reduced.append(heartbeat)
return reduced
def heartbeat_merge(
last_event: Event, heartbeat: Event, pulsetime: float
) -> Optional[Event]:
"""
Merges two events if they have identical data
and the heartbeat timestamp is within the pulsetime window.
"""
if last_event.data == heartbeat.data:
# Seconds between end of last_event and start of heartbeat
pulseperiod_end = (
last_event.timestamp + last_event.duration + timedelta(seconds=pulsetime)
)
within_pulsetime_window = (
last_event.timestamp <= heartbeat.timestamp <= pulseperiod_end
)
if within_pulsetime_window:
# Seconds between end of last_event and start of timestamp
new_duration = (
heartbeat.timestamp - last_event.timestamp
) + heartbeat.duration
if last_event.duration < timedelta(0):
logger.warning(
"Merging heartbeats would result in a negative duration, refusing to merge."
)
else:
# Taking the max of durations ensures heartbeats that end before the last event don't shorten it
last_event.duration = max((last_event.duration, new_duration))
return last_event
return None

View File

@@ -0,0 +1,40 @@
import logging
from typing import List, Dict, Tuple
from aw_core.models import Event
logger = logging.getLogger(__name__)
def merge_events_by_keys(events, keys) -> List[Event]:
"""
Sums the duration of all events which share a value for a key and returns a new event for each value.
.. note: The result will be a list of events without timestamp since they are merged.
"""
# Call recursively until all keys are consumed
if len(keys) < 1:
return events
merged_events: Dict[Tuple, Event] = {}
for event in events:
composite_key: Tuple = ()
for key in keys:
if key in event.data:
val = event["data"][key]
# Needed for when the value is a list, such as for categories
if isinstance(val, list):
val = tuple(val)
composite_key = composite_key + (val,)
if composite_key not in merged_events:
merged_events[composite_key] = Event(
timestamp=event.timestamp, duration=event.duration, data={}
)
for key in keys:
if key in event.data:
merged_events[composite_key].data[key] = event.data[key]
else:
merged_events[composite_key].duration += event.duration
result = []
for key in merged_events:
result.append(Event(**merged_events[key]))
return result

View File

@@ -0,0 +1,150 @@
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

0
aw_transform/py.typed Normal file
View File

30
aw_transform/simplify.py Normal file
View File

@@ -0,0 +1,30 @@
import re
from copy import deepcopy
from typing import List
from aw_core import Event
def simplify_string(events: List[Event], key: str = "title") -> List[Event]:
events = deepcopy(events)
re_leadingdot = re.compile(r"^(●|\*)\s*")
re_parensprefix = re.compile(r"^\([0-9]+\)\s*")
re_fps = re.compile(r"FPS:\s+[0-9\.]+")
for e in events:
# Remove prefixes that are numbers within parenthesis
# Example: "(2) Facebook" -> "Facebook"
# Example: "(1) YouTube" -> "YouTube"
e.data[key] = re_parensprefix.sub("", e.data[key])
# Things generally specific to window events with the "app" key
if key == "title" and "app" in e["data"]:
# Remove FPS display in window title
# Example: "Cemu - FPS: 59.2 - ..." -> "Cemu - FPS: ... - ..."
e.data[key] = re_fps.sub("FPS: ...", e.data[key])
# For VSCode (uses ●), gedit (uses *), et al
# See: https://github.com/ActivityWatch/aw-watcher-window/issues/32
e.data[key] = re_leadingdot.sub("", e.data[key])
return events

32
aw_transform/sort_by.py Normal file
View File

@@ -0,0 +1,32 @@
import logging
from datetime import timedelta
from typing import List
from aw_core.models import Event
logger = logging.getLogger(__name__)
def sort_by_timestamp(events) -> List[Event]:
"""Sorts a list of events by timestamp"""
return sorted(events, key=lambda e: e.timestamp)
def sort_by_duration(events) -> List[Event]:
"""Sorts a list of events by duration"""
return sorted(events, key=lambda e: e.duration, reverse=True)
def limit_events(events, count) -> List[Event]:
"""Returns the ``count`` first events in the list of events"""
return events[:count]
def sum_durations(events) -> timedelta:
"""Sums the durations for the given events"""
return timedelta(seconds=(sum(event.duration.total_seconds() for event in events)))
def concat(events1, events2) -> List[Event]:
"""Concatenates two lists of events"""
events = events1 + events2
return events

View File

@@ -0,0 +1,32 @@
import logging
from typing import List
from urllib.parse import urlparse
from aw_core.models import Event
logger = logging.getLogger(__name__)
def split_url_events(events: List[Event]) -> List[Event]:
for event in events:
if "url" in event.data:
url = event.data["url"]
parsed_url = urlparse(url)
event.data["$protocol"] = parsed_url.scheme
netloc = parsed_url.netloc
if netloc:
domain = netloc[4:] if netloc[:4] == "www." else netloc
elif parsed_url.scheme:
# For URLs without a domain (e.g. file://, about:),
# use the scheme as domain so they don't all cluster as empty.
domain = parsed_url.scheme
else:
domain = ""
event.data["$domain"] = domain
event.data["$path"] = parsed_url.path
event.data["$params"] = parsed_url.params
event.data["$options"] = parsed_url.query
event.data["$identifier"] = parsed_url.fragment
# TODO: Parse user, port etc aswell
return events

View File

@@ -0,0 +1,85 @@
"""
Originally from aw-research
"""
from copy import deepcopy
from typing import List, Tuple, Optional
from datetime import datetime, timedelta, timezone
from timeslot import Timeslot
from aw_core import Event
def _split_event(e: Event, dt: datetime) -> Tuple[Event, Optional[Event]]:
if e.timestamp < dt < e.timestamp + e.duration:
e1 = deepcopy(e)
e2 = deepcopy(e)
e1.duration = dt - e.timestamp
e2.timestamp = dt
e2.duration = (e.timestamp + e.duration) - dt
return (e1, e2)
else:
return (e, None)
def test_split_event():
now = datetime(2018, 1, 1, 0, 0).astimezone(timezone.utc)
td1h = timedelta(hours=1)
e = Event(timestamp=now, duration=2 * td1h, data={})
e1, e2 = _split_event(e, now + td1h)
assert e1.timestamp == now
assert e1.duration == td1h
assert e2
assert e2.timestamp == now + td1h
assert e2.duration == td1h
def union_no_overlap(events1: List[Event], events2: List[Event]) -> List[Event]:
"""Merges two eventlists and removes overlap, the first eventlist will have precedence
Example:
events1 | xxx xx xxx |
events1 | ---- ------ -- |
result | xxx-- xx ----xxx -- |
"""
events1 = deepcopy(events1)
events2 = deepcopy(events2)
# I looked a lot at aw_transform.union when I wrote this
events_union = []
e1_i = 0
e2_i = 0
while e1_i < len(events1) and e2_i < len(events2):
e1 = events1[e1_i]
e2 = events2[e2_i]
e1_p = Timeslot(e1.timestamp, e1.timestamp + e1.duration)
e2_p = Timeslot(e2.timestamp, e2.timestamp + e2.duration)
if e1_p.intersects(e2_p):
if e1.timestamp <= e2.timestamp:
events_union.append(e1)
e1_i += 1
# If e2 continues after e1, we need to split up the event so we only get the part that comes after
_, e2_next = _split_event(e2, e1.timestamp + e1.duration)
if e2_next:
events2[e2_i] = e2_next
else:
e2_i += 1
else:
e2_next, e2_next2 = _split_event(e2, e1.timestamp)
events_union.append(e2_next)
e2_i += 1
if e2_next2:
events2.insert(e2_i, e2_next2)
else:
if e1.timestamp <= e2.timestamp:
events_union.append(e1)
e1_i += 1
else:
events_union.append(e2)
e2_i += 1
events_union += events1[e1_i:]
events_union += events2[e2_i:]
return events_union