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

1
tests/__init__.py Normal file
View File

@@ -0,0 +1 @@
from . import context # noqa: F401

6
tests/context.py Normal file
View File

@@ -0,0 +1,6 @@
import os
import sys
# This will cause imports to use the packages in the
# source folder instead of those installed on the system.
sys.path.insert(0, os.path.abspath(".."))

64
tests/test_cli_log.py Normal file
View File

@@ -0,0 +1,64 @@
"""Tests for aw_cli.log.find_oldest_log after profile isolation (#149).
With per-profile appname dirs, every .log file in a module dir belongs to the
active profile — no filename-based filtering is needed.
"""
import time
from pathlib import Path
import pytest
from aw_cli.log import find_oldest_log
from . import context # noqa: F401
@pytest.fixture()
def log_dir(tmp_path: Path) -> Path:
return tmp_path / "aw-server"
def test_returns_none_for_missing_dir(tmp_path: Path) -> None:
assert find_oldest_log(tmp_path / "nonexistent") is None
def test_returns_none_for_empty_dir(log_dir: Path) -> None:
log_dir.mkdir()
assert find_oldest_log(log_dir) is None
def test_returns_single_log(log_dir: Path) -> None:
log_dir.mkdir()
f = log_dir / "aw-server_2026-01-01.log"
f.write_text("line\n")
assert find_oldest_log(log_dir) == f
def test_returns_newest_by_mtime(log_dir: Path) -> None:
log_dir.mkdir()
old = log_dir / "aw-server_old.log"
new = log_dir / "aw-server_new.log"
old.write_text("old\n")
time.sleep(0.01)
new.write_text("new\n")
assert find_oldest_log(log_dir) == new
def test_ignores_non_log_files(log_dir: Path) -> None:
log_dir.mkdir()
(log_dir / "notes.txt").write_text("not a log")
assert find_oldest_log(log_dir) is None
def test_no_profile_filtering_in_filenames(log_dir: Path) -> None:
"""All .log files in the dir belong to the active profile — no name filter."""
log_dir.mkdir()
default_log = log_dir / "aw-server_2026-01-01.log"
default_log.write_text("line\n")
# A file named with 'testing' in its name is still returned;
# profile selection happens at the directory level, not the filename level.
testing_named = log_dir / "aw-server_testing_2026-01-02.log"
time.sleep(0.01)
testing_named.write_text("testing-named\n")
assert find_oldest_log(log_dir) == testing_named

102
tests/test_config.py Normal file
View File

@@ -0,0 +1,102 @@
import shutil
from configparser import ConfigParser
from pathlib import Path
import deprecation
import pytest
from aw_core import dirs
from aw_core.config import (
load_config,
load_config_toml,
save_config,
save_config_toml,
)
appname = "aw-core-test"
section = "section"
config_dir = dirs.get_config_dir(appname)
default_config_str = f"""# A default config file, with comments!
[{section}]
somestring = "Hello World!" # A comment
somevalue = 12.3 # Another comment
somearray = ["asd", 123]"""
@pytest.fixture(autouse=True)
def clean_config():
# Remove test config file if it already exists
shutil.rmtree(config_dir, ignore_errors=True)
# Rerun get_config dir to create config directory
dirs.get_config_dir(appname)
yield
# Remove test config file if it already exists
shutil.rmtree(config_dir)
def test_create():
appname = "aw-core-test"
config_dir = dirs.get_config_dir(appname)
assert Path(config_dir).exists()
def test_config_defaults():
# Load non-existing config (will create a out-commented default config file)
config = load_config_toml(appname, default_config_str)
# Check that load_config used defaults
assert config[section]["somestring"] == "Hello World!"
assert config[section]["somevalue"] == 12.3
assert config[section]["somearray"] == ["asd", 123]
def test_config_no_defaults():
# Write defaults to file
save_config_toml(appname, default_config_str)
# Load written defaults without defaults
config = load_config_toml(appname, "")
assert config[section]["somestring"] == "Hello World!"
assert config[section]["somevalue"] == 12.3
assert config[section]["somearray"] == ["asd", 123]
def test_config_override():
# Create a minimal config file with one overridden value
config_str = """[section]
somevalue = 1000.1"""
save_config_toml(appname, config_str)
# Open non-default config file and verify that values are correct
config = load_config_toml(appname, default_config_str)
assert config[section]["somevalue"] == 1000.1
@deprecation.fail_if_not_removed
def test_config_ini():
# Create default config
default_config = ConfigParser()
default_config[section] = {"somestring": "Hello World!", "somevalue": 12.3} # type: ignore
# Load non-existing config (will create a default config file)
config = load_config(appname, default_config)
# Check that current config file is same as default config file
assert config[section]["somestring"] == default_config[section]["somestring"]
assert config[section].getfloat("somevalue") == default_config[section].getfloat(
"somevalue"
)
# Modify and save config file
config[section]["somevalue"] = "1000.1"
save_config(appname, config)
# Open non-default config file and verify that values are correct
new_config = load_config(appname, default_config)
assert new_config[section]["somestring"] == config[section]["somestring"]
assert new_config[section].getfloat("somevalue") == config[section].getfloat(
"somevalue"
)

586
tests/test_datastore.py Normal file
View File

@@ -0,0 +1,586 @@
import logging
import random
from datetime import datetime, timedelta, timezone
import iso8601
import pytest
from aw_core.models import Event
from aw_datastore import get_storage_methods
from aw_datastore.storages import PeeweeStorage
from . import context # noqa: F401
from .utils import param_datastore_objects, param_testing_buckets_cm
logging.basicConfig(level=logging.DEBUG)
# Useful when you just want some placeholder time in your events, saves typing
now = datetime.now(tz=timezone.utc)
td1s = timedelta(seconds=1)
td1d = timedelta(days=1)
def test_get_storage_methods():
assert get_storage_methods()
@pytest.mark.parametrize("datastore", param_datastore_objects())
def test_get_buckets(datastore):
"""
Tests fetching buckets
"""
buckets = datastore.buckets()
for bucket in buckets.values():
assert bucket["id"] in buckets
assert bucket["data"] == {}
@pytest.mark.parametrize("datastore", param_datastore_objects())
def test_create_bucket(datastore):
name = "A label/name for a test bucket"
bid = "test-identifier"
try:
bucket = datastore.create_bucket(
bucket_id=bid,
type="testtype",
client="testclient",
hostname="testhost",
name=name,
created=now,
data={"key": "value"},
)
metadata = bucket.metadata()
assert bid == metadata["id"]
assert name == metadata["name"]
assert "testtype" == metadata["type"]
assert "testclient" == metadata["client"]
assert "testhost" == metadata["hostname"]
assert now == iso8601.parse_date(metadata["created"])
assert bid in datastore.buckets()
assert {"key": "value"} == metadata["data"]
finally:
datastore.delete_bucket(bid)
assert bid not in datastore.buckets()
@pytest.mark.parametrize("datastore", param_datastore_objects())
def test_update_bucket(datastore):
bid = "test-" + str(random.randint(0, 1000000))
try:
datastore.create_bucket(
bucket_id=bid, type="test", client="test", hostname="test", name="test"
)
datastore.update_bucket(bid, name="new name")
assert datastore[bid].metadata()["name"] == "new name"
datastore.update_bucket(bid, data={"key": "value"})
assert datastore[bid].metadata()["data"] == {"key": "value"}
finally:
datastore.delete_bucket(bid)
@pytest.mark.parametrize("datastore", param_datastore_objects())
def test_delete_bucket(datastore):
bid = "test-" + str(random.randint(0, 1000000))
datastore.create_bucket(
bucket_id=bid, type="test", client="test", hostname="test", name="test"
)
datastore.delete_bucket(bid)
assert bid not in datastore.buckets()
with pytest.raises(ValueError):
datastore.delete_bucket(bid)
@pytest.mark.parametrize("datastore", param_datastore_objects())
def test_nonexistent_bucket(datastore):
"""
Tests that a KeyError is raised if you request a non-existent bucket
"""
with pytest.raises(KeyError):
datastore["I-do-not-exist"]
@pytest.mark.parametrize("bucket_cm", param_testing_buckets_cm())
def test_insert_one(bucket_cm):
"""
Tests inserting one event into a bucket
"""
with bucket_cm as bucket:
n_events = len(bucket.get())
event = Event(timestamp=now, duration=td1s, data={"key": "val"})
bucket.insert(event)
fetched_events = bucket.get()
assert n_events + 1 == len(fetched_events)
assert isinstance(fetched_events[0], Event)
assert event == fetched_events[0]
logging.info(event)
logging.info(fetched_events[0].to_json_str())
@pytest.mark.parametrize("bucket_cm", param_testing_buckets_cm())
def test_empty_bucket(bucket_cm):
"""
Ensures empty buckets are empty
"""
with bucket_cm as bucket:
assert 0 == len(bucket.get())
@pytest.mark.parametrize("bucket_cm", param_testing_buckets_cm())
def test_insert_many(bucket_cm):
"""
Tests that you can insert many events at the same time to a bucket
"""
num_events = 5000
with bucket_cm as bucket:
events = num_events * [Event(timestamp=now, duration=td1s, data={"key": "val"})]
bucket.insert(events)
fetched_events = bucket.get(limit=-1)
assert num_events == len(fetched_events)
for e, fe in zip(events, fetched_events):
assert e == fe
@pytest.mark.parametrize("bucket_cm", param_testing_buckets_cm())
def test_insert_many_upsert(bucket_cm):
"""
Tests that you can update/upsert many events at the same time to a bucket
"""
num_events = 10
with bucket_cm as bucket:
events = num_events * [Event(timestamp=now, duration=td1s, data={"key": "val"})]
# insert events to get IDs assigned
bucket.insert(events)
events = bucket.get(limit=-1)
assert num_events == len(events)
for e in events:
assert e.id is not None
e.data["key"] = "new val"
# Upsert the events
bucket.insert(events)
events = bucket.get(limit=-1)
assert num_events == len(events)
for e in events:
assert e.data["key"] == "new val"
@pytest.mark.parametrize("bucket_cm", param_testing_buckets_cm())
def test_delete(bucket_cm):
"""
Tests deleting single events
"""
num_events = 10
with bucket_cm as bucket:
events = num_events * [Event(timestamp=now, duration=td1s, data={"key": "val"})]
bucket.insert(events)
fetched_events = bucket.get(limit=-1)
print(fetched_events[0])
assert num_events == len(fetched_events)
# Test deleting event
assert bucket.delete(fetched_events[0]["id"])
# Test deleting non-existent event
# FIXME: Doesn't work due to lazy evaluation in SqliteDatastore
# assert not bucket.delete(fetched_events[0]["id"])
fetched_events = bucket.get(limit=-1)
assert num_events - 1 == len(fetched_events)
@pytest.mark.parametrize("bucket_cm", param_testing_buckets_cm())
def test_insert_badtype(bucket_cm):
"""
Tests that you cannot insert non-event types into a bucket
"""
with bucket_cm as bucket:
bucket_len = len(bucket.get())
badevent = 1
with pytest.raises(TypeError):
bucket.insert(badevent)
assert bucket_len == len(bucket.get())
@pytest.mark.parametrize("bucket_cm", param_testing_buckets_cm())
def test_get_ordered(bucket_cm):
"""
Makes sure that received events are ordered
"""
with bucket_cm as bucket:
eventcount = 10
events = []
for i in range(10):
events.append(Event(timestamp=now + i * td1s, duration=td1s))
random.shuffle(events)
print(events)
bucket.insert(events)
fetched_events = bucket.get(-1)
for i in range(eventcount - 1):
print("1:" + fetched_events[i].to_json_str())
print("2:" + fetched_events[i + 1].to_json_str())
assert fetched_events[i].timestamp > fetched_events[i + 1].timestamp
@pytest.mark.parametrize("bucket_cm", param_testing_buckets_cm())
def test_get_event_with_timezone(bucket_cm):
"""Tries to retrieve an event using a timezone aware datetime."""
hour = timedelta(hours=1)
td_offset = 2 * hour
tz = timezone(td_offset)
dt_utc = datetime(2017, 10, 27, hour=0, minute=5, tzinfo=timezone.utc)
dt_with_tz = dt_utc.replace(tzinfo=tz)
with bucket_cm as bucket:
bucket.insert(Event(timestamp=dt_with_tz))
fetched_events = bucket.get(
starttime=dt_with_tz - hour, endtime=dt_with_tz + hour
)
assert len(fetched_events) == 1
fetched_events = bucket.get(
starttime=dt_utc - td_offset - hour, endtime=dt_utc - td_offset + hour
)
assert len(fetched_events) == 1
@pytest.mark.parametrize("bucket_cm", param_testing_buckets_cm())
def test_get_datefilter_simple(bucket_cm):
with bucket_cm as bucket:
eventcount = 3
events = [
Event(timestamp=now + i * td1s, duration=td1s) for i in range(eventcount)
]
bucket.insert(events)
# Get first event, but expect only half the event to match the interval
fetched_events = bucket.get(
-1,
starttime=now - 0.5 * td1s,
endtime=now + 0.5 * td1s,
)
assert 1 == len(fetched_events)
# Get first two events, but expect only half of each to match the interval
fetched_events = bucket.get(
-1,
starttime=now + 0.5 * td1s,
endtime=now + 1.5 * td1s,
)
assert 2 == len(fetched_events)
# Get last event, but expect only half to match the interval
fetched_events = bucket.get(
-1,
starttime=now + 2.5 * td1s,
endtime=now + 3.5 * td1s,
)
assert 1 == len(fetched_events)
# Check approx precision
fetched_events = bucket.get(
-1,
starttime=now - 0.01 * td1s,
endtime=now + 0.01 * td1s,
)
assert 1 == len(fetched_events)
# Check precision of start
fetched_events = bucket.get(
-1,
starttime=now,
endtime=now,
)
assert 1 == len(fetched_events)
# Check approx precision of end
fetched_events = bucket.get(
-1,
starttime=now + 2.99 * td1s,
endtime=now + 3.01 * td1s,
)
assert 1 == len(fetched_events)
@pytest.mark.parametrize("bucket_cm", param_testing_buckets_cm())
def test_get_event_by_id(bucket_cm):
"""Test that we can retrieve single events by their IDs"""
with bucket_cm as bucket:
eventcount = 2
# Create 1-day long events
events = [
Event(timestamp=now + i * td1d, duration=td1d) for i in range(eventcount)
]
bucket.insert(events)
# Retrieve stored events
events = bucket.get()
for e in events:
# Query them one-by-one
event = bucket.get_by_id(e.id)
assert e == event
@pytest.mark.parametrize("bucket_cm", param_testing_buckets_cm())
def test_get_event_by_id_notfound(bucket_cm):
"""Test fetching an ID that does not exist"""
with bucket_cm as bucket:
assert bucket.get_by_id(1337 * 10**6) is None
@pytest.mark.parametrize("bucket_cm", param_testing_buckets_cm())
def test_get_event_trimming(bucket_cm):
"""Test that event trimming works correctly (when querying events that intersect with the query range)"""
# TODO: Trimming should be possible to disable
# (needed in raw data view, among other places where event editing is permitted)
with bucket_cm as bucket:
if not isinstance(bucket.ds.storage_strategy, PeeweeStorage):
pytest.skip("Trimming not supported for datastore")
eventcount = 2
# Create 1-day long events
events = [
Event(timestamp=now + i * td1d, duration=td1d) for i in range(eventcount)
]
bucket.insert(events)
# Result should contain half of each event
fetched_events = bucket.get(
-1,
starttime=now + td1d / 2,
endtime=now + 1.5 * td1d,
)
assert 2 == len(fetched_events)
total_duration = sum((e.duration for e in fetched_events), timedelta())
assert td1d == timedelta(seconds=round(total_duration.total_seconds()))
@pytest.mark.parametrize("bucket_cm", param_testing_buckets_cm())
def test_get_datefilter_start(bucket_cm):
"""
Tests the datetimefilter when fetching events
"""
with bucket_cm as bucket:
eventcount = 10
events = [
Event(timestamp=now + i * td1s, duration=td1s) for i in range(eventcount)
]
bucket.insert(events)
# Starttime
for i in range(eventcount):
fetched_events = bucket.get(-1, starttime=events[i].timestamp + 0.01 * td1s)
assert eventcount - i == len(fetched_events)
@pytest.mark.parametrize("bucket_cm", param_testing_buckets_cm())
def test_get_datefilter_end(bucket_cm):
"""
Tests the datetimefilter when fetching events
"""
with bucket_cm as bucket:
eventcount = 10
events = [
Event(timestamp=now + i * td1s, duration=td1s) for i in range(eventcount)
]
bucket.insert(events)
# Endtime
for i in range(eventcount):
fetched_events = bucket.get(-1, endtime=events[i].timestamp - 0.01 * td1s)
assert i == len(fetched_events)
@pytest.mark.parametrize("bucket_cm", param_testing_buckets_cm())
def test_get_datefilter_both(bucket_cm):
"""
Tests the datetimefilter when fetching events
"""
with bucket_cm as bucket:
eventcount = 10
events = [
Event(timestamp=now + i * td1s, duration=td1s) for i in range(eventcount)
]
bucket.insert(events)
# Both
for i in range(eventcount):
for j in range(i + 1, eventcount):
fetched_events = bucket.get(
starttime=events[i].timestamp + timedelta(seconds=0.01),
endtime=events[j].timestamp
+ events[j].duration
- timedelta(seconds=0.01),
)
assert j - i + 1 == len(fetched_events)
@pytest.mark.parametrize("bucket_cm", param_testing_buckets_cm())
def test_insert_invalid(bucket_cm):
with bucket_cm as bucket:
event = "not a real event"
with pytest.raises(TypeError):
bucket.insert(event)
@pytest.mark.parametrize("bucket_cm", param_testing_buckets_cm())
def test_replace(bucket_cm):
"""
Tests the replace event event in bucket functionality
"""
with bucket_cm as bucket:
# Create two events
e1 = bucket.insert(Event(data={"label": "test1"}, timestamp=now))
assert e1
assert e1.id is not None
e2 = bucket.insert(
Event(data={"label": "test2"}, timestamp=now + timedelta(seconds=1))
)
assert e2
assert e2.id is not None
e1.data["label"] = "test1-replaced"
bucket.replace(e1.id, e1)
bucket.insert(
Event(data={"label": "test3"}, timestamp=now + timedelta(seconds=2))
)
e2.data["label"] = "test2-replaced"
bucket.replace(e2.id, e2)
# Assert length
assert 3 == len(bucket.get(-1))
assert bucket.get(-1)[0]["data"]["label"] == "test3"
assert bucket.get(-1)[1]["data"]["label"] == "test2-replaced"
assert bucket.get(-1)[2]["data"]["label"] == "test1-replaced"
@pytest.mark.parametrize("bucket_cm", param_testing_buckets_cm())
def test_replace_last(bucket_cm):
"""
Tests the replace last event in bucket functionality (simple)
"""
with bucket_cm as bucket:
# Create two events
bucket.insert(Event(data={"label": "test1"}, timestamp=now))
bucket.insert(
Event(data={"label": "test2"}, timestamp=now + timedelta(seconds=1))
)
# Create second event to replace with the second one
bucket.replace_last(
Event(
data={"label": "test2-replaced"}, timestamp=now + timedelta(seconds=1)
)
)
bucket.insert(
Event(data={"label": "test3"}, timestamp=now + timedelta(seconds=2))
)
# Assert data
result = bucket.get(-1)
assert 3 == len(result)
assert result[1]["data"]["label"] == "test2-replaced"
@pytest.mark.parametrize("bucket_cm", param_testing_buckets_cm())
def test_replace_last_complex(bucket_cm):
"""
Tests the replace last event in bucket functionality (complex)
"""
with bucket_cm as bucket:
# Create first event
event1 = Event(data={"label": "test1"}, timestamp=now, duration=timedelta(1))
bucket.insert(event1)
eventcount = len(bucket.get(-1))
# Create second event to replace with the first one
event2 = Event(
data={"label": "test2"},
duration=timedelta(0),
timestamp=now + timedelta(seconds=1),
)
bucket.replace_last(event2)
# Assert length and content
result = bucket.get(-1)
assert eventcount == len(result)
assert event2 == result[0]
@pytest.mark.parametrize("bucket_cm", param_testing_buckets_cm())
def test_get_last(bucket_cm):
"""
Tests setting the result limit when fetching events
"""
now = datetime.now()
second = timedelta(seconds=1)
with bucket_cm as bucket:
events = [
Event(data={"label": "test"}, timestamp=ts, duration=timedelta(0))
for ts in [now + second, now + second * 2, now + second * 3]
]
for event in events:
bucket.insert(event)
assert bucket.get(limit=1)[0] == events[-1]
for event in bucket.get(limit=5):
print(event.timestamp, event.data["label"])
@pytest.mark.parametrize("bucket_cm", param_testing_buckets_cm())
def test_limit(bucket_cm):
"""
Tests setting the result limit when fetching events
"""
with bucket_cm as bucket:
for _ in range(5):
bucket.insert(Event(timestamp=now))
assert 0 == len(bucket.get(limit=0))
assert 1 == len(bucket.get(limit=1))
assert 3 == len(bucket.get(limit=3))
assert 5 == len(bucket.get(limit=5))
assert 5 == len(bucket.get(limit=-1))
@pytest.mark.parametrize("bucket_cm", param_testing_buckets_cm())
def test_get_metadata(bucket_cm):
"""
Tests the get_metadata function
"""
with bucket_cm as bucket:
print(bucket.ds.storage_strategy)
metadata = bucket.metadata()
print(metadata)
assert "created" in metadata
assert "client" in metadata
assert "hostname" in metadata
assert "id" in metadata
assert "name" in metadata
assert "type" in metadata
with pytest.raises(ValueError):
bucket.metadata()
@pytest.mark.parametrize("bucket_cm", param_testing_buckets_cm())
def test_get_eventcount(bucket_cm):
"""
Tests the get_eventcount function
"""
with bucket_cm as bucket:
print(bucket.ds.storage_strategy)
assert bucket.get_eventcount() == 0
for _ in range(5):
bucket.insert(Event(timestamp=now))
assert bucket.get_eventcount() == 5
# TODO: Test with timestamps and start/endtime filtering
bucket.insert(Event(timestamp=now + timedelta(seconds=5)))
assert (
bucket.get_eventcount(starttime=now - timedelta(seconds=1), endtime=now)
== 5
)
assert bucket.get_eventcount(endtime=now + timedelta(seconds=1)) == 5
assert bucket.get_eventcount(starttime=now + timedelta(seconds=1)) == 1

201
tests/test_dirs.py Normal file
View File

@@ -0,0 +1,201 @@
"""Tests for profile isolation via AW_PROFILE in aw_core.dirs.
Three profiles (default / testing / research) must yield completely disjoint
directory roots so that an ActivityWatch research build cannot read from or
write to a participant's personal datastore.
The ``testing`` profile additionally follows the new-root-plus-legacy-fallback
rule from ActivityWatch/activitywatch#1399.
"""
import os
from pathlib import Path
from unittest.mock import patch
import pytest
from aw_core.dirs import (
_get_appname,
get_cache_dir,
get_config_dir,
get_data_dir,
legacy_testing_suffix,
using_legacy_testing_root,
)
from . import context # noqa: F401
@pytest.fixture
def fake_dirs(tmp_path, monkeypatch):
"""Point platformdirs at a tmp tree so tests never touch the real home."""
data = tmp_path / "data"
config = tmp_path / "config"
cache = tmp_path / "cache"
def _join(root: Path, appname: str) -> str:
return str(root / appname)
monkeypatch.setattr(
"aw_core.dirs.platformdirs.user_data_dir",
lambda appname, *a, **k: _join(data, appname),
)
monkeypatch.setattr(
"aw_core.dirs.platformdirs.user_config_dir",
lambda appname, *a, **k: _join(config, appname),
)
monkeypatch.setattr(
"aw_core.dirs.platformdirs.user_cache_dir",
lambda appname, *a, **k: _join(cache, appname),
)
monkeypatch.setattr(
"aw_core.dirs.platformdirs.user_cache_path",
lambda appname, *a, **k: cache / appname,
)
monkeypatch.setattr(
"aw_core.dirs.platformdirs.user_log_dir",
lambda appname, *a, **k: str(cache / appname / "log"),
)
monkeypatch.delenv("AW_PROFILE", raising=False)
return {"data": data, "config": config, "cache": cache, "root": tmp_path}
def _plant_legacy_testing_db(fake_dirs) -> Path:
aw_server = fake_dirs["data"] / "activitywatch" / "aw-server"
aw_server.mkdir(parents=True)
marker = aw_server / "peewee-sqlite-testing.v2.db"
marker.write_text("")
return marker
# ---------------------------------------------------------------------------
# _get_appname
# ---------------------------------------------------------------------------
class TestGetAppname:
def test_unset_returns_bare_name(self, fake_dirs):
"""AW_PROFILE absent → bare 'activitywatch', identical to legacy."""
with patch.dict(os.environ, {"AW_PROFILE": ""}):
assert _get_appname() == "activitywatch"
def test_empty_string_returns_bare_name(self, fake_dirs):
"""AW_PROFILE='' is treated the same as unset."""
with patch.dict(os.environ, {"AW_PROFILE": ""}):
assert _get_appname() == "activitywatch"
def test_testing_profile_fresh_uses_new_root(self, fake_dirs):
with patch.dict(os.environ, {"AW_PROFILE": "testing"}):
assert _get_appname() == "activitywatch-testing"
def test_research_profile(self, fake_dirs):
with patch.dict(os.environ, {"AW_PROFILE": "research"}):
assert _get_appname() == "activitywatch-research"
def test_arbitrary_profile(self, fake_dirs):
with patch.dict(os.environ, {"AW_PROFILE": "myproject"}):
assert _get_appname() == "activitywatch-myproject"
# ---------------------------------------------------------------------------
# testing-root fallback (ActivityWatch/activitywatch#1399)
# ---------------------------------------------------------------------------
class TestTestingRootFallback:
def test_fresh_setup_uses_new_root(self, fake_dirs):
with patch.dict(os.environ, {"AW_PROFILE": "testing"}):
assert using_legacy_testing_root() is False
assert _get_appname() == "activitywatch-testing"
assert legacy_testing_suffix(True) == ""
data = get_data_dir()
assert "activitywatch-testing" in data
assert (fake_dirs["data"] / "activitywatch-testing").is_dir()
def test_legacy_artifacts_keep_shared_root(self, fake_dirs):
_plant_legacy_testing_db(fake_dirs)
with patch.dict(os.environ, {"AW_PROFILE": "testing"}):
assert using_legacy_testing_root() is True
assert _get_appname() == "activitywatch"
assert legacy_testing_suffix(True) == "-testing"
data = get_data_dir()
assert "activitywatch-testing" not in data
assert data.endswith("activitywatch") or data.endswith("activitywatch/")
assert not (fake_dirs["data"] / "activitywatch-testing").exists()
def test_new_root_wins_over_legacy_artifacts(self, fake_dirs):
_plant_legacy_testing_db(fake_dirs)
(fake_dirs["data"] / "activitywatch-testing").mkdir()
with patch.dict(os.environ, {"AW_PROFILE": "testing"}):
assert using_legacy_testing_root() is False
assert _get_appname() == "activitywatch-testing"
assert legacy_testing_suffix(True) == ""
def test_config_testing_toml_is_a_legacy_marker(self, fake_dirs):
cfg = fake_dirs["config"] / "activitywatch"
cfg.mkdir(parents=True)
(cfg / "config-testing.toml").write_text("")
with patch.dict(os.environ, {"AW_PROFILE": "testing"}):
assert using_legacy_testing_root() is True
assert _get_appname() == "activitywatch"
def test_testing_without_aw_profile_keeps_filename_suffix(self, fake_dirs):
"""``testing=True`` with no profile still shares the default root."""
assert legacy_testing_suffix(True) == "-testing"
assert legacy_testing_suffix(False) == ""
def test_named_profile_never_suffixes_filenames(self, fake_dirs):
with patch.dict(os.environ, {"AW_PROFILE": "research"}):
assert legacy_testing_suffix(True) == ""
assert legacy_testing_suffix(False) == ""
# ---------------------------------------------------------------------------
# Directory isolation
# ---------------------------------------------------------------------------
def _data_dir(profile: str) -> str:
"""Return get_data_dir() under the given profile (empty string = default)."""
with patch.dict(os.environ, {"AW_PROFILE": profile}):
return get_data_dir()
class TestDirsIsolation:
"""Three profiles must yield fully disjoint directory roots."""
def test_default_has_no_profile_suffix(self, fake_dirs):
d = _data_dir("")
assert "activitywatch" in d
# The bare appname must not contain a dash after "activitywatch"
assert "activitywatch-" not in d
def test_testing_suffix_present(self, fake_dirs):
assert "activitywatch-testing" in _data_dir("testing")
def test_research_suffix_present(self, fake_dirs):
assert "activitywatch-research" in _data_dir("research")
def test_three_profiles_are_disjoint(self, fake_dirs):
"""default, testing, research all produce distinct, non-nested paths."""
default = _data_dir("")
testing = _data_dir("testing")
research = _data_dir("research")
dirs = {default, testing, research}
assert len(dirs) == 3, f"Profiles are not disjoint: {dirs}"
for a in dirs:
for b in dirs:
if a != b:
assert not a.startswith(b + os.sep), f"{a!r} is a subpath of {b!r}"
def test_config_dir_isolated(self, fake_dirs):
with patch.dict(os.environ, {"AW_PROFILE": "research"}):
cfg = get_config_dir()
assert "activitywatch-research" in cfg
def test_cache_dir_isolated(self, fake_dirs):
with patch.dict(os.environ, {"AW_PROFILE": "testing"}):
cache = get_cache_dir()
assert "activitywatch-testing" in cache

41
tests/test_event.py Normal file
View File

@@ -0,0 +1,41 @@
from datetime import datetime, timedelta, timezone
import json
import pytest
from aw_core.models import Event
valid_timestamp = "1937-01-01T12:00:27.87+00:20"
now = datetime.now(timezone.utc)
td1s = timedelta(seconds=1)
def test_create() -> None:
Event(timestamp=now, duration=timedelta(hours=13, minutes=37), data={"key": "val"})
Event(
timestamp=valid_timestamp,
duration=timedelta(hours=13, minutes=37),
data={"key": "val"},
)
def test_json_serialization() -> None:
e = Event(
timestamp=now, duration=timedelta(hours=13, minutes=37), data={"key": "val"}
)
assert e == Event(**json.loads(e.to_json_str()))
def test_set_invalid_duration() -> None:
e = Event()
with pytest.raises(TypeError):
e.duration = "12" # type: ignore
def test_sort() -> None:
e1 = Event(timestamp=now)
e2 = Event(timestamp=now + td1s)
e_sorted = sorted([e2, e1])
assert e_sorted[0] == e1
assert e_sorted[1] == e2

167
tests/test_flood.py Normal file
View File

@@ -0,0 +1,167 @@
from datetime import datetime, timedelta, timezone
from aw_core.models import Event
from aw_transform import flood
now = datetime.now(tz=timezone.utc)
td1s = timedelta(seconds=1)
def test_flood_differing_data_meet_at_gap_midpoint():
events = [
Event(timestamp=now, duration=10, data={"a": 0}),
Event(timestamp=now + 14 * td1s, duration=5, data={"b": 1}),
]
flooded = flood(events)
assert flooded == [
Event(timestamp=now, duration=12, data={"a": 0}),
Event(timestamp=now + 12 * td1s, duration=7, data={"b": 1}),
]
def test_flood_forward_merge():
events = [
Event(timestamp=now, duration=10),
Event(timestamp=now + 15 * td1s, duration=5),
]
flooded = flood(events)
assert len(flooded) == 1
assert flooded[0].duration == timedelta(seconds=20)
def test_flood_backward_merge():
events = [
Event(timestamp=now, duration=5),
Event(timestamp=now + 10 * td1s, duration=10),
]
flooded = flood(events)
assert len(flooded) == 1
assert flooded[0].duration == timedelta(seconds=20)
def test_flood_negative_gap_same_data():
events = [
Event(timestamp=now, duration=100, data={"a": 0}),
Event(timestamp=now, duration=5, data={"a": 0}),
]
flooded = flood(events)
total_duration = sum((e.duration for e in flooded), timedelta(0))
assert len(flooded) == 1
assert total_duration == timedelta(seconds=100)
def test_flood_negative_gap_differing_data():
events = [
Event(timestamp=now, duration=5, data={"a": 0}),
Event(timestamp=now, duration=100, data={"b": 1}),
]
flooded = flood(events)
assert flooded == [events[1]]
def test_flood_zero_duration_chain_does_not_leave_overlaps():
events = [
Event(timestamp=now, duration=0, data={"title": "first"}),
Event(timestamp=now, duration=1, data={"title": "first"}),
Event(timestamp=now, duration=1, data={"title": "second"}),
]
flooded = flood(events)
assert flooded == [events[2]]
assert all(
previous.timestamp + previous.duration <= current.timestamp
for previous, current in zip(flooded, flooded[1:])
)
def test_flood_normalization_preserves_non_overlapping_tail():
events = [
Event(timestamp=now, duration=10, data={"title": "first"}),
Event(timestamp=now + 5 * td1s, duration=2, data={"title": "second"}),
Event(timestamp=now + 12 * td1s, duration=1, data={"title": "third"}),
]
flooded = flood(events)
assert flooded == [
Event(timestamp=now, duration=5, data={"title": "first"}),
Event(timestamp=now + 5 * td1s, duration=4.5, data={"title": "second"}),
Event(timestamp=now + 9.5 * td1s, duration=3.5, data={"title": "third"}),
]
def test_flood_normalization_merges_same_data_after_zero_duration_event():
events = [
Event(timestamp=now, duration=0, data={"title": "zero"}),
Event(timestamp=now, duration=10, data={"title": "same"}),
Event(timestamp=now + 5 * td1s, duration=10, data={"title": "same"}),
]
flooded = flood(events)
assert flooded == [Event(timestamp=now, duration=15, data={"title": "same"})]
def test_flood_negative_small_gap_differing_data():
events = [
Event(timestamp=now, duration=100, data={"b": 1}),
Event(timestamp=now + 99.99 * td1s, duration=100, data={"a": 0}),
]
flooded = flood(events)
duration = sum((e.duration for e in flooded), timedelta(0))
assert duration == timedelta(seconds=100 + 99.99)
def test_flood_with_custom_pulsetime():
# Events with a 30s gap between them (simulating 30s poll_time)
events = [
Event(timestamp=now, duration=5, data={"a": 0}),
Event(timestamp=now + 35 * td1s, duration=5, data={"b": 1}),
]
# Default pulsetime=5: gap (30s) > pulsetime, so no flooding
flooded_default = flood(events)
assert len(flooded_default) == 2
total_duration_default = sum((e.duration for e in flooded_default), timedelta(0))
assert total_duration_default == timedelta(seconds=10)
# pulsetime=31: gap (30s) <= pulsetime, so both events extend to midpoint
flooded_custom = flood(events, pulsetime=31)
assert flooded_custom == [
Event(timestamp=now, duration=20, data={"a": 0}),
Event(timestamp=now + 20 * td1s, duration=20, data={"b": 1}),
]
def test_flood_idempotent():
"""flood() applied repeatedly should produce the same result."""
events = [
# slight overlap, same data
Event(timestamp=now, duration=10, data={"a": 0}),
Event(timestamp=now + 9 * td1s, duration=5, data={"a": 0}),
# different data, no overlap
Event(timestamp=now + 15 * td1s, duration=5, data={"b": 0}),
]
flood_first = flood(events, pulsetime=0)
flooded = flood_first
for _ in range(2):
flooded = flood(flooded, pulsetime=0)
assert flood_first == flooded
assert sum((e.duration for e in flooded), timedelta(0)) == 19 * td1s
def test_flood_unsafe_gap():
"""Overlapping events with differing data must not double-count time."""
events = [
Event(timestamp=now, duration=10, data={"a": 0}),
Event(timestamp=now + 9 * td1s, duration=5, data={"b": 0}),
]
flooded = flood(events, pulsetime=0)
# Total duration must not exceed the span covered by the two events
assert sum((e.duration for e in flooded), timedelta(0)) == 14 * td1s

84
tests/test_heartbeat.py Normal file
View File

@@ -0,0 +1,84 @@
from datetime import datetime, timedelta
from aw_core.models import Event
from aw_transform import heartbeat_merge, heartbeat_reduce
def test_heartbeat_merge():
"""Events should merge"""
now = datetime.now()
td_1s = timedelta(seconds=1)
last_event, heartbeat = Event(timestamp=now), Event(timestamp=now + td_1s)
merged = heartbeat_merge(last_event, heartbeat, pulsetime=2)
assert merged is not None
def test_heartbeat_merge_fail():
"""Merge should not happen"""
now = datetime.now()
td_1s = timedelta(seconds=1)
# timestamp of heartbeat more than pulsetime away
last_event, heartbeat = (
Event(timestamp=now, data={"label": "test"}),
Event(timestamp=now + 3 * td_1s, data={"label": "test"}),
)
merged = heartbeat_merge(last_event, heartbeat, pulsetime=2)
assert merged is None
# labels not identical
last_event, heartbeat = (
Event(timestamp=now, data={"label": "test"}),
Event(timestamp=now + td_1s, data={"label": "test2"}),
)
merged = heartbeat_merge(last_event, heartbeat, pulsetime=2)
assert merged is None
def test_heartbeat_reduce():
"""Events should reduce"""
now = datetime.now()
td_1s = timedelta(seconds=1)
# Check that empty list works
assert not heartbeat_reduce([], pulsetime=1)
events = [
Event(timestamp=now, data={"label": "test"}),
Event(timestamp=now + td_1s, data={"label": "test"}),
]
reduced_events = heartbeat_reduce(events, pulsetime=2)
assert len(reduced_events) == 1
def test_heartbeat_same_timestamp():
now = datetime.now()
td_1s = timedelta(seconds=1)
td_0s = timedelta(seconds=0)
e1 = Event(timestamp=now, duration=td_1s, data={"label": "test"})
e2 = Event(timestamp=now, duration=td_0s, data={"label": "test"})
# Should merge
res = heartbeat_reduce(list([e1, e2]), pulsetime=5)
assert len(res) == 1
assert res[0].duration == td_1s
# Order shouldn't matter, should merge
res = heartbeat_reduce([e2, e1], pulsetime=5)
assert len(res) == 1
assert res[0].duration == td_1s
def test_heartbeat_reduce_fail():
"""Events should not reduce"""
now = datetime.now()
td_1s = timedelta(seconds=1)
events = [
Event(timestamp=now, data={"label": "test"}),
Event(timestamp=now + 3 * td_1s, data={"label": "test"}),
]
reduced_events = heartbeat_reduce(events, pulsetime=2)
assert len(reduced_events) == 2

66
tests/test_peewee.py Normal file
View File

@@ -0,0 +1,66 @@
"""
Peewee-storage-specific tests, mainly for the schema/index migration in
auto_migrate() and for guarding against query-plan regressions.
"""
import logging
from aw_datastore.storages import PeeweeStorage
from aw_datastore.storages import peewee as peewee_storage
from . import context # noqa: F401
logging.basicConfig(level=logging.DEBUG)
def _indexes(db) -> set:
return {row[1] for row in db.execute_sql("PRAGMA index_list(eventmodel)")}
def test_index_migration():
"""auto_migrate() replaces the old single-column indexes with the composite one."""
storage = PeeweeStorage(testing=True)
db = storage.db
path = db.database
assert "eventmodel_bucket_id_timestamp" in _indexes(db)
# Recreate the pre-migration state: single-column indexes, no composite
db.execute_sql("DROP INDEX eventmodel_bucket_id_timestamp")
db.execute_sql("CREATE INDEX eventmodel_bucket_id ON eventmodel (bucket_id)")
db.execute_sql("CREATE INDEX eventmodel_timestamp ON eventmodel (timestamp)")
peewee_storage.auto_migrate(path)
indexes = _indexes(db)
assert "eventmodel_bucket_id_timestamp" in indexes
assert "eventmodel_bucket_id" not in indexes
assert "eventmodel_timestamp" not in indexes
def test_query_plan_uses_composite_index():
"""
The last-event query (issued by replace_last on every merged heartbeat) and
range queries must be served by the composite index in a single pass.
Without it, SQLite reads *all* of a bucket's rows and sorts them in a temp
B-tree even for LIMIT 1, which on multi-million-event buckets meant seconds
of CPU/IO per heartbeat (and terabytes read per day).
"""
storage = PeeweeStorage(testing=True)
db = storage.db
queries = [
# replace_last/_get_last
"SELECT * FROM eventmodel WHERE bucket_id = 1"
" ORDER BY timestamp DESC LIMIT 1",
# get_events with a time range
"SELECT * FROM eventmodel WHERE bucket_id = 1"
" AND timestamp >= '2026-01-01' AND timestamp <= '2026-01-02'"
" ORDER BY timestamp DESC LIMIT 100",
]
for query in queries:
rows = db.execute_sql(f"EXPLAIN QUERY PLAN {query}").fetchall()
plan = "\n".join(str(row) for row in rows)
assert "eventmodel_bucket_id_timestamp" in plan, plan
assert "TEMP B-TREE" not in plan, plan

715
tests/test_query2.py Normal file
View File

@@ -0,0 +1,715 @@
from datetime import datetime, timedelta, timezone
from typing import Any, Dict
import iso8601
import pytest
from aw_core.models import Event
from aw_datastore.storages import MemoryStorage
from aw_query import query
from aw_query.exceptions import (
QueryFunctionException,
QueryInterpretException,
QueryParseException,
)
from aw_query.query2 import (
QDict,
QFunction,
QInteger,
QList,
QString,
QVariable,
_parse_token,
_split_query_statements,
)
from .utils import param_datastore_objects
class MockDatastore(MemoryStorage):
pass
mock_ds = MockDatastore(testing=True)
def test_query2_test_token_parsing():
ns: Dict[str, Any] = {}
(t, token), trash = _parse_token("123", ns)
assert token == "123"
assert t == QInteger
(t, token), trash = _parse_token('"test"', ns)
assert token == '"test"'
assert t == QString
(t, token), trash = _parse_token("'test'", ns)
assert token == "'test'"
assert t == QString
(t, token), trash = _parse_token("'te\\'st'", ns)
assert token == "'te\\'st'"
assert t == QString
(t, token), trash = _parse_token('"te\\"st"', ns)
assert token == '"te\\"st"'
assert t == QString
(t, token), trash = _parse_token("test0xDEADBEEF", ns)
assert token == "test0xDEADBEEF"
assert t == QVariable
(t, token), trash = _parse_token("test1337(')')", ns)
assert token == "test1337(')')"
assert t == QFunction
(t, token), trash = _parse_token("test1337('test\\'test',\"test\\\"test\")", ns)
assert token == "test1337('test\\'test',\"test\\\"test\")"
assert t == QFunction
(t, token), trash = _parse_token("[1, 'a', {}]", ns)
assert token == "[1, 'a', {}]"
assert t == QList
(t, token), trash = _parse_token("{'a': 1, 'b}': 2}", ns)
assert token == "{'a': 1, 'b}': 2}"
assert t == QDict
assert _parse_token("", ns) == ((None, ""), "")
with pytest.raises(QueryParseException):
_parse_token(None, ns) # type: ignore
with pytest.raises(QueryParseException):
_parse_token('"', ns)
with pytest.raises(QueryParseException):
_parse_token("#", ns)
def test_dict():
ds = mock_ds
ns: Dict[str, Any] = {}
d_str = "{'a': {'a': {'a': 1}}, 'b': {'b\\'\"': ':'}}"
d = QDict.parse(d_str, ns)
expected_res = {"a": {"a": {"a": 1}}, "b": {"b'\"": ":"}}
assert expected_res == d.interpret(ds, ns)
# Key in dict is not a string
with pytest.raises(QueryParseException):
d_str = "{b: 1}"
d = QDict.parse(d_str, ns)
# Key in dict without a value
with pytest.raises(QueryParseException):
d_str = "{'test': }"
d = QDict.parse(d_str, ns)
# Char following key string is not a :
with pytest.raises(QueryParseException):
d_str = "{'test'p 1}"
d = QDict.parse(d_str, ns)
with pytest.raises(QueryParseException):
d_str = "{'test': #}"
d = QDict.parse(d_str, ns)
# Semicolon without key
with pytest.raises(QueryParseException):
d_str = "{:}"
d = QDict.parse(d_str, ns)
# Trailing comma
with pytest.raises(QueryParseException):
d_str = "{'test':1,}"
d = QDict.parse(d_str, ns)
def test_list():
ds = mock_ds
ns: Dict[str, Any] = {}
l_str = "[1,2,[[3],4],5]"
ls = QList.parse(l_str, ns)
expected_res = [1, 2, [[3], 4], 5]
assert expected_res == ls.interpret(ds, ns)
l_str = "['\\'',\"\\\"\"]"
ls = QList.parse(l_str, ns)
expected_res = ["'", '"']
assert expected_res == ls.interpret(ds, ns)
l_str = "[]"
ls = QList.parse(l_str, ns)
expected_res = []
assert expected_res == ls.interpret(ds, ns)
# Comma without pre/post value
with pytest.raises(QueryParseException):
l_str = "[,]"
ls = QList.parse(l_str, ns)
# Comma without post value
with pytest.raises(QueryParseException):
l_str = "[1,]"
ls = QList.parse(l_str, ns)
# Comma without pre value
with pytest.raises(QueryParseException):
l_str = "[,2]"
ls = QList.parse(l_str, ns)
def test_query2_bogus_query():
ds = mock_ds
qname = "test"
qstartdate = datetime.now(tz=timezone.utc)
qenddate = qstartdate
# Nothing to assign
with pytest.raises(QueryParseException):
example_query = "a="
query(qname, example_query, qstartdate, qenddate, ds)
# Assign to non-variable
with pytest.raises(QueryParseException):
example_query = "1 = 2"
query(qname, example_query, qstartdate, qenddate, ds)
# Unclosed function
with pytest.raises(QueryParseException):
example_query = "a = unclosed_function(var1"
query(qname, example_query, qstartdate, qenddate, ds)
# Two tokens in assignment
with pytest.raises(QueryParseException):
example_query = "asd nop() = 2"
query(qname, example_query, qstartdate, qenddate, ds)
# Unclosed string
with pytest.raises(QueryParseException):
example_query = 'asd = "something is wrong with me'
query(qname, example_query, qstartdate, qenddate, ds)
# Two tokens in value
with pytest.raises(QueryParseException):
example_query = "asd = asd1 asd2"
query(qname, example_query, qstartdate, qenddate, ds)
def test_query2_query_function_calling():
ds = mock_ds
qname = "asd"
starttime = iso8601.parse_date("1970-01-01")
endtime = iso8601.parse_date("1970-01-02")
# Function which doesn't exist
with pytest.raises(QueryInterpretException):
example_query = "RETURN = asd();"
query(qname, example_query, starttime, endtime, ds)
# Function which does exist with invalid arguments
with pytest.raises(QueryInterpretException):
example_query = "RETURN = nop(badarg);"
query(qname, example_query, starttime, endtime, ds)
# Function which does exist with valid arguments
example_query = "RETURN = nop();"
query(qname, example_query, starttime, endtime, ds)
def test_query2_return_value():
ds = mock_ds
qname = "asd"
starttime = iso8601.parse_date("1970-01-01")
endtime = iso8601.parse_date("1970-01-02")
example_query = "RETURN = 1;"
result = query(qname, example_query, starttime, endtime, ds)
assert result == 1
example_query = "RETURN = 'testing 123'"
result = query(qname, example_query, starttime, endtime, ds)
assert result == "testing 123"
example_query = "RETURN = {'a': 1}"
result = query(qname, example_query, starttime, endtime, ds)
assert result == {"a": 1}
# Nothing to return
with pytest.raises(QueryParseException):
example_query = "a=1"
result = query(qname, example_query, starttime, endtime, ds)
def test_query2_semicolon_in_string():
"""Test that semicolons inside string literals don't break query parsing (issue #145)."""
ds = mock_ds
qname = "asd"
starttime = iso8601.parse_date("1970-01-01")
endtime = iso8601.parse_date("1970-01-02")
# Semicolon in double-quoted string
example_query = 'RETURN = "hello;world"'
result = query(qname, example_query, starttime, endtime, ds)
assert result == "hello;world"
# Semicolon in single-quoted string
example_query = "RETURN = 'hello;world'"
result = query(qname, example_query, starttime, endtime, ds)
assert result == "hello;world"
# Multiple statements where one value contains a semicolon in a string
example_query = 'a = "foo;bar"; RETURN = a'
result = query(qname, example_query, starttime, endtime, ds)
assert result == "foo;bar"
def test_split_query_statements():
"""Unit test for _split_query_statements."""
# Basic split
assert _split_query_statements("a=1;b=2") == ["a=1", "b=2"]
# Semicolon inside double quotes should NOT split
assert _split_query_statements('a="x;y";b=2') == ['a="x;y"', "b=2"]
# Semicolon inside single quotes should NOT split
assert _split_query_statements("a='x;y';b=2") == ["a='x;y'", "b=2"]
# Trailing semicolon — empty trailing part is omitted
assert _split_query_statements("a=1;") == ["a=1"]
def test_query2_multiline():
ds = mock_ds
qname = "asd"
starttime = iso8601.parse_date("1970-01-01")
endtime = iso8601.parse_date("1970-01-02")
example_query = """
my_multiline_string = "a
b";
RETURN = my_multiline_string;
"""
result = query(qname, example_query, starttime, endtime, ds)
assert result == "a\nb"
def test_query2_function_invalid_types():
"""Tests the q2_typecheck decorator"""
ds = mock_ds
qname = "asd"
starttime = iso8601.parse_date("1970-01-01")
endtime = iso8601.parse_date("1970-01-02")
# int instead of str
example_query = """
events = [];
RETURN = filter_keyvals(events, 666, ["invalid_val"]);
"""
with pytest.raises(QueryFunctionException):
query(qname, example_query, starttime, endtime, ds)
# str instead of list
example_query = """
events = [];
RETURN = filter_keyvals(events, "2", "invalid_val");
"""
with pytest.raises(QueryFunctionException):
query(qname, example_query, starttime, endtime, ds)
# FIXME: For unknown reasons, query2 drops the second argument
# when the first argument is a bare []
"""
example_query = '''
RETURN = filter_keyvals([], "2", "invalid_val");
'''
with pytest.raises(QueryFunctionException) as e:
result = query(qname, example_query, starttime, endtime, None)
"""
def test_query2_function_invalid_argument_count():
ds = mock_ds
qname = "asd"
starttime = iso8601.parse_date("1970-01-01")
endtime = iso8601.parse_date("1970-01-02")
example_query = "RETURN=nop(nop())"
with pytest.raises(QueryInterpretException):
query(qname, example_query, starttime, endtime, ds)
def test_query2_merge_subwatcher_fields_invalid_conflict():
ds = mock_ds
qname = "asd"
starttime = iso8601.parse_date("1970-01-01")
endtime = iso8601.parse_date("1970-01-02")
example_query = """
base = [];
sub = [];
RETURN = merge_subwatcher_fields(base, sub, ["project"], "invalid");
"""
with pytest.raises(QueryFunctionException, match="conflict must be"):
query(qname, example_query, starttime, endtime, ds)
def test_query2_categorize_invalid_priority():
ds = mock_ds
qname = "asd"
starttime = iso8601.parse_date("1970-01-01")
endtime = iso8601.parse_date("1970-01-02")
example_query = """
events = [];
RETURN = categorize(events, [[["test"], {"regex": "test", "priority": "high"}]]);
"""
with pytest.raises(QueryFunctionException, match="integer"):
query(qname, example_query, starttime, endtime, ds)
@pytest.mark.parametrize("datastore", param_datastore_objects())
def test_query2_function_in_function(datastore):
qname = "asd"
bid = "test_bucket"
starttime = iso8601.parse_date("1970-01-01")
endtime = iso8601.parse_date("1970-01-02")
example_query = f"""
RETURN=limit_events(query_bucket("{bid}"), 1);
"""
try:
# Setup buckets
bucket1 = datastore.create_bucket(
bucket_id=bid, type="test", client="test", hostname="test", name="test"
)
# Prepare buckets
e1 = Event(data={}, timestamp=starttime, duration=timedelta(seconds=1))
bucket1.insert(e1)
result = query(qname, example_query, starttime, endtime, datastore)
assert 1 == len(result)
finally:
datastore.delete_bucket(bid)
@pytest.mark.parametrize("datastore", param_datastore_objects())
def test_query2_query_functions(datastore):
"""
Just test calling all functions just to see something isn't completely broken
In many cases the functions doesn't change the result at all, so it's not a test
for testing the validity of the data the functions transform
"""
bid = "test_'bucket"
qname = "test"
starttime = iso8601.parse_date("1970")
endtime = starttime + timedelta(hours=1)
example_query = """
bid = "{bid}";
events = query_bucket("{bid}");
events2 = query_bucket('{bid_escaped}');
events2 = filter_keyvals(events2, "label", ["test1"]);
events2 = exclude_keyvals(events2, "label", ["test2"]);
events = filter_period_intersect(events, events2);
events = filter_keyvals_regex(events, "label", ".*");
events = limit_events(events, 1);
events = merge_events_by_keys(events, ["label"]);
events = chunk_events_by_key(events, "label");
events = split_url_events(events);
events = sort_by_timestamp(events);
events = sort_by_duration(events);
events = categorize(events, [[["test", "subtest"], {{"regex": "test1"}}]]);
duration = sum_durations(events);
eventcount = query_bucket_eventcount(bid);
asd = nop();
RETURN = {{"events": events, "eventcount": eventcount}};
""".format(bid=bid, bid_escaped=bid.replace("'", "\\'"))
try:
bucket = datastore.create_bucket(
bucket_id=bid, type="test", client="test", hostname="test", name="asd"
)
bucket.insert(
Event(
data={"label": "test1"},
timestamp=starttime,
duration=timedelta(seconds=1),
)
)
result = query(qname, example_query, starttime, endtime, datastore)
assert result["eventcount"] == 1
assert len(result["events"]) == 1
assert result["events"][0].data["label"] == "test1"
assert result["events"][0].data["$category"] == ["test", "subtest"]
finally:
datastore.delete_bucket(bid)
@pytest.mark.parametrize("datastore", param_datastore_objects())
def test_query2_basic_query(datastore):
name = "A label/name for a test bucket"
bid1 = "bucket1"
bid2 = "bucket2"
qname = "test_query_basic"
starttime = iso8601.parse_date("1970")
endtime = starttime + timedelta(hours=1)
example_query = f"""
bid1 = "{bid1}";
bid2 = "{bid2}";
events = query_bucket(bid1);
intersect_events = query_bucket(bid2);
RETURN = filter_period_intersect(events, intersect_events);
"""
try:
# Setup buckets
bucket1 = datastore.create_bucket(
bucket_id=bid1, type="test", client="test", hostname="test", name=name
)
bucket2 = datastore.create_bucket(
bucket_id=bid2, type="test", client="test", hostname="test", name=name
)
# Prepare buckets
e1 = Event(
data={"label": "test1"}, timestamp=starttime, duration=timedelta(seconds=1)
)
e2 = Event(
data={"label": "test2"},
timestamp=starttime + timedelta(seconds=2),
duration=timedelta(seconds=1),
)
et = Event(
data={"label": "intersect-label"},
timestamp=starttime,
duration=timedelta(seconds=1),
)
bucket1.insert(e1)
bucket1.insert(e2)
bucket2.insert(et)
# Query
result = query(qname, example_query, starttime, endtime, datastore)
# Assert
assert len(result) == 1
assert result[0]["data"]["label"] == "test1"
finally:
datastore.delete_bucket(bid1)
datastore.delete_bucket(bid2)
@pytest.mark.parametrize("datastore", param_datastore_objects())
def test_query2_test_merged_keys(datastore):
name = "A label/name for a test bucket"
bid = "bucket1"
qname = "test_query_merged_keys"
starttime = iso8601.parse_date("2080")
endtime = starttime + timedelta(hours=1)
example_query = f"""
bid1 = "{bid}";
events = query_bucket(bid1);
events = merge_events_by_keys(events, ["label1", "label2"]);
events = sort_by_duration(events);
eventcount = query_bucket_eventcount(bid1);
RETURN = {{"events": events, "eventcount": eventcount}};
"""
try:
# Setup buckets
bucket1 = datastore.create_bucket(
bucket_id=bid, type="test", client="test", hostname="test", name=name
)
# Prepare buckets
e1 = Event(
data={"label1": "test1", "label2": "test1"},
timestamp=starttime,
duration=timedelta(seconds=1),
)
e2 = Event(
data={"label1": "test1", "label2": "test1"},
timestamp=starttime + timedelta(seconds=1),
duration=timedelta(seconds=1),
)
e3 = Event(
data={"label1": "test1", "label2": "test2"},
timestamp=starttime + timedelta(seconds=2),
duration=timedelta(seconds=1),
)
bucket1.insert(e3)
bucket1.insert(e1)
bucket1.insert(e2)
# Query
result = query(qname, example_query, starttime, endtime, datastore)
# Assert
print(result)
assert len(result["events"]) == 2
assert result["eventcount"] == 3
assert result["events"][0]["data"]["label1"] == "test1"
assert result["events"][0]["data"]["label2"] == "test1"
assert result["events"][0]["duration"] == timedelta(seconds=2)
assert result["events"][1]["data"]["label1"] == "test1"
assert result["events"][1]["data"]["label2"] == "test2"
assert result["events"][1]["duration"] == timedelta(seconds=1)
finally:
datastore.delete_bucket(bid)
@pytest.mark.parametrize("datastore", param_datastore_objects())
def test_query2_fancy_query(datastore):
"""
Tests:
- find_bucket
- simplify_window_titles
"""
name = "A label/name for a test bucket"
bid1 = "bucket-the-one"
qname = "test_query_basic_fancy"
starttime = iso8601.parse_date("1970")
endtime = starttime + timedelta(hours=1)
example_query = f"""
bid = find_bucket("{bid1[:10]}");
events = query_bucket(bid);
RETURN = simplify_window_titles(events, "title");
"""
try:
# Setup buckets
bucket_main = datastore.create_bucket(
bucket_id=bid1, type="test", client="test", hostname="test", name=name
)
# Prepare buckets
e1 = Event(
data={"title": "(2) YouTube"},
timestamp=starttime,
duration=timedelta(seconds=1),
)
bucket_main.insert(e1)
# Query
result = query(qname, example_query, starttime, endtime, datastore)
# Assert
assert result[0]["data"]["title"] == "YouTube"
finally:
datastore.delete_bucket(bid1)
@pytest.mark.parametrize("datastore", param_datastore_objects())
def test_query2_query_categorize(datastore):
bid = "test_bucket"
qname = "test"
starttime = iso8601.parse_date("1970")
endtime = starttime + timedelta(hours=1)
example_query = rf"""
events = query_bucket("{bid}");
events = sort_by_timestamp(events);
events = categorize(events, [
[["test"], {{"regex": "test"}}],
[["test", "subtest"], {{"regex": "test\w"}}]
]);
events_by_cat = merge_events_by_keys(events, ["$category"]);
RETURN = {{"events": events, "events_by_cat": events_by_cat}};
"""
try:
bucket = datastore.create_bucket(
bucket_id=bid, type="test", client="test", hostname="test", name="asd"
)
events = [
Event(
data={"label": "test"},
timestamp=starttime,
duration=timedelta(seconds=1),
),
Event(
data={"label": "testwithmoredetail"},
timestamp=starttime + timedelta(seconds=1),
duration=timedelta(seconds=1),
),
Event(
data={"label": "testwithmoredetail"},
timestamp=starttime + timedelta(seconds=2),
duration=timedelta(seconds=1),
),
]
bucket.insert(events)
result = query(qname, example_query, starttime, endtime, datastore)
print(result)
assert len(result["events"]) == 3
assert result["events"][0].data["label"] == "test"
assert result["events"][0].data["$category"] == ["test"]
assert result["events"][1].data["$category"] == ["test", "subtest"]
assert len(result["events_by_cat"]) == 2
assert result["events_by_cat"][0].data["$category"] == ["test"]
assert result["events_by_cat"][1].data["$category"] == ["test", "subtest"]
assert result["events_by_cat"][1].duration == timedelta(seconds=2)
finally:
datastore.delete_bucket(bid)
@pytest.mark.parametrize("datastore", param_datastore_objects())
def test_query2_query_categorize_priority(datastore):
bid = "test_bucket_priority"
qname = "test"
starttime = iso8601.parse_date("1970")
endtime = starttime + timedelta(hours=1)
example_query = rf"""
events = query_bucket("{bid}");
events = categorize(events, [
[["A"], {{"regex": "test", "priority": 25}}],
[["B", "B1"], {{"regex": "test"}}]
]);
RETURN = events;
"""
try:
bucket = datastore.create_bucket(
bucket_id=bid, type="test", client="test", hostname="test", name="asd"
)
bucket.insert(
Event(
data={"label": "test"},
timestamp=starttime,
duration=timedelta(seconds=1),
)
)
result = query(qname, example_query, starttime, endtime, datastore)
assert len(result) == 1
assert result[0].data["$category"] == ["A"]
finally:
datastore.delete_bucket(bid)
@pytest.mark.parametrize("datastore", param_datastore_objects())
def test_query2_flood_with_pulsetime(datastore):
"""Test that flood() accepts an optional pulsetime argument (fixes WorkReport query bug)."""
bid = "test_flood_bucket"
qname = "test_flood"
starttime = iso8601.parse_date("1970")
endtime = starttime + timedelta(hours=1)
try:
bucket = datastore.create_bucket(
bucket_id=bid,
type="test",
client="test",
hostname="test",
name="flood_test",
)
# Two events with a 60-second gap — flood with pulsetime=300 should merge them
bucket.insert(
Event(
data={"app": "Firefox"},
timestamp=starttime,
duration=timedelta(seconds=10),
)
)
bucket.insert(
Event(
data={"app": "Firefox"},
timestamp=starttime + timedelta(seconds=70),
duration=timedelta(seconds=10),
)
)
# flood with default pulsetime (5s) — gap is 60s, events should NOT merge
result_default = query(
qname,
f'events = query_bucket("{bid}"); events = flood(events); RETURN = events;',
starttime,
endtime,
datastore,
)
assert len(result_default) == 2
# flood with pulsetime=300 — gap is 60s < 300s, events SHOULD merge
result_pulsetime = query(
qname,
f'events = query_bucket("{bid}"); events = flood(events, 300); RETURN = events;',
starttime,
endtime,
datastore,
)
assert len(result_pulsetime) == 1
finally:
datastore.delete_bucket(bid)

60
tests/test_schemas.py Normal file
View File

@@ -0,0 +1,60 @@
import unittest
from jsonschema import validate as _validate, FormatChecker
from jsonschema.exceptions import ValidationError
from aw_core import schema
from aw_core.models import Event
# TODO: Include date-time format
# https://python-jsonschema.readthedocs.io/en/latest/validate/#jsonschema.FormatChecker
# The default FormatChecker, uses the date-time checker
fc = FormatChecker(["date-time"])
valid_timestamp = "1937-01-01T12:00:27.87+00:20"
event_schema = schema.get_json_schema("event")
class EventSchemaTest(unittest.TestCase):
def setUp(self):
self.schema = event_schema
def validate(self, obj):
_validate(obj, self.schema, format_checker=fc)
def test_event(self):
event = Event(timestamp=valid_timestamp, data={"label": "test"})
self.validate(event.to_json_dict())
def test_data(self):
self.validate(
{"timestamp": valid_timestamp, "data": {"label": "test", "number": 1.1}}
)
def test_timestamp(self):
self.validate({"timestamp": valid_timestamp})
def test_timestamp_invalid_string(self):
with self.assertRaises(ValidationError):
self.validate({"timestamp": ""})
with self.assertRaises(ValidationError):
self.validate({"timestamp": "123"})
def test_timestamp_invalid_number(self):
with self.assertRaises(ValidationError):
self.validate({"timestamp": 2})
def test_duration(self):
self.validate({"timestamp": valid_timestamp, "duration": 1000})
self.validate({"timestamp": valid_timestamp, "duration": 3.13})
def test_duration_invalid_string(self):
with self.assertRaises(ValidationError):
self.validate({"timestamp": valid_timestamp, "duration": "not a number"})
if __name__ == "__main__":
unittest.main()

830
tests/test_transforms.py Normal file
View File

@@ -0,0 +1,830 @@
from pprint import pprint
from datetime import datetime, timedelta, timezone
import pytest
from aw_core.models import Event
from aw_transform import (
filter_period_intersect,
filter_keyvals_regex,
filter_keyvals,
merge_subwatcher_fields,
period_union,
sort_by_timestamp,
sort_by_duration,
sum_durations,
merge_events_by_keys,
chunk_events_by_key,
split_url_events,
simplify_string,
union,
union_no_overlap,
categorize,
tag,
Rule,
)
from aw_transform.filter_period_intersect import _intersecting_eventpairs
def test_simplify_string():
events = [
Event(data={"label": "(99) Facebook"}),
Event(data={"label": "(14) YouTube"}),
]
assert simplify_string(events, "label")[0].data["label"] == "Facebook"
assert simplify_string(events, "label")[1].data["label"] == "YouTube"
events = [Event(data={"app": "Cemu.exe", "title": "Cemu - FPS: 133.7 - BotW"})]
assert simplify_string(events, "title")[0].data["title"] == "Cemu - FPS: ... - BotW"
events = [
Event(data={"app": "VSCode.exe", "title": "● report.md - Visual Studio Code"})
]
assert (
simplify_string(events, "title")[0].data["title"]
== "report.md - Visual Studio Code"
)
events = [Event(data={"app": "Gedit", "title": "*test.md - gedit"})]
assert simplify_string(events, "title")[0].data["title"] == "test.md - gedit"
def test_filter_keyval():
labels = ["aa", "cc"]
events = [
Event(data={"label": "aa"}),
Event(data={"label": "bb"}),
Event(data={"label": "cc"}),
]
included_events = filter_keyvals(events, "label", labels)
excluded_events = filter_keyvals(events, "label", labels, exclude=True)
assert len(included_events) == 2
assert len(excluded_events) == 1
def test_filter_keyval_regex():
events = [
Event(data={"label": "aa"}),
Event(data={"label": "bb"}),
Event(data={"label": "cc"}),
]
events_re = filter_keyvals_regex(events, "label", "aa|cc")
assert len(events_re) == 2
def test_filter_keyval_regex_keyerror():
events = [
Event(data={"label": "aa"}),
Event(),
Event(data={"label": "cc"}),
]
events_re = filter_keyvals_regex(events, "label", "aa|cc")
assert len(events_re) == 2
def test_intersecting_eventpairs():
td1h = timedelta(hours=1)
now = datetime.now()
# Test with two identical lists
e1 = [
Event(timestamp=now, duration=td1h),
Event(timestamp=now + td1h, duration=td1h),
]
e2 = [
Event(timestamp=now, duration=td1h),
Event(timestamp=now + td1h, duration=td1h),
]
intersecting = list(_intersecting_eventpairs(e1, e2))
assert len(intersecting) == 2
# Test with events in first list being in between events of second list
e1 = [
Event(timestamp=now + td1h, duration=td1h),
]
e2 = [
Event(timestamp=now, duration=td1h),
Event(timestamp=now + 2 * td1h, duration=td1h),
]
intersecting = list(_intersecting_eventpairs(e1, e2))
assert not intersecting
# Test with event in first list being identical to middle event in second list
e1 = [
Event(timestamp=now + td1h, duration=td1h),
]
e2 = [
Event(timestamp=now, duration=td1h),
Event(timestamp=now + 1 * td1h, duration=td1h),
Event(timestamp=now + 2 * td1h, duration=td1h),
]
intersecting = list(_intersecting_eventpairs(e1, e2))
assert len(intersecting) == 1
# Test same as before, but reversed
e1 = list(reversed(e1))
e2 = list(reversed(e2))
intersecting = list(_intersecting_eventpairs(e1, e2))
assert len(intersecting) == 1
def test_filter_period_intersect():
td1h = timedelta(hours=1)
td30min = timedelta(minutes=30)
now = datetime.now()
# Filter 1h event with another 1h event at a 30min offset
to_filter = [Event(timestamp=now, duration=td1h)]
filter_with = [Event(timestamp=now + td30min, duration=td1h)]
filtered_events = filter_period_intersect(to_filter, filter_with)
assert filtered_events[0].duration == td30min
# Filter 2x 30min events with a 15min gap with another 45min event in between intersecting both
to_filter = [
Event(timestamp=now, duration=td30min),
Event(timestamp=now + timedelta(minutes=45), duration=td30min),
]
filter_with = [
Event(timestamp=now + timedelta(minutes=15), duration=timedelta(minutes=45))
]
filtered_events = filter_period_intersect(to_filter, filter_with)
assert len(filtered_events) == 2
assert filtered_events[0].duration == timedelta(minutes=15)
assert filtered_events[1].duration == timedelta(minutes=15)
# Same as previous intersection, but reversing filter and to_filter events
to_filter = [
Event(timestamp=now + timedelta(minutes=15), duration=timedelta(minutes=45))
]
filter_with = [
Event(timestamp=now, duration=td30min),
Event(timestamp=now + timedelta(minutes=45), duration=td30min),
]
filtered_events = filter_period_intersect(to_filter, filter_with)
assert len(filtered_events) == 2
assert filtered_events[0].duration == timedelta(minutes=15)
assert filtered_events[1].duration == timedelta(minutes=15)
def test_period_union():
now = datetime.now(timezone.utc)
# Events overlapping
events1 = [Event(timestamp=now, duration=timedelta(seconds=10))]
events2 = [
Event(timestamp=now + timedelta(seconds=9), duration=timedelta(seconds=10))
]
unioned_events = period_union(events1, events2)
assert len(unioned_events) == 1
# Events adjacent but not overlapping
events1 = [Event(timestamp=now, duration=timedelta(seconds=10))]
events2 = [
Event(timestamp=now + timedelta(seconds=10), duration=timedelta(seconds=10))
]
unioned_events = period_union(events1, events2)
assert len(unioned_events) == 1
# Events not overlapping or adjacent
events1 = [Event(timestamp=now, duration=timedelta(seconds=10))]
events2 = [
Event(timestamp=now + timedelta(seconds=11), duration=timedelta(seconds=10))
]
unioned_events = period_union(events1, events2)
assert len(unioned_events) == 2
def test_sort_by_timestamp():
now = datetime.now(timezone.utc)
events = []
events.append(
Event(timestamp=now + timedelta(seconds=2), duration=timedelta(seconds=1))
)
events.append(
Event(timestamp=now + timedelta(seconds=1), duration=timedelta(seconds=2))
)
events_sorted = sort_by_timestamp(events)
assert events_sorted == events[::-1]
def test_sort_by_duration():
now = datetime.now(timezone.utc)
events = []
events.append(
Event(timestamp=now + timedelta(seconds=2), duration=timedelta(seconds=1))
)
events.append(
Event(timestamp=now + timedelta(seconds=1), duration=timedelta(seconds=2))
)
events_sorted = sort_by_duration(events)
assert events_sorted == events[::-1]
def test_sum_durations():
now = datetime.now(timezone.utc)
events = []
for i in range(10):
events.append(
Event(timestamp=now + timedelta(seconds=i), duration=timedelta(seconds=1))
)
result = sum_durations(events)
assert result == timedelta(seconds=10)
def test_merge_events_by_keys_1():
now = datetime.now(timezone.utc)
events = []
e1_data = {"label": "a"}
e2_data = {"label": "b"}
e1 = Event(data=e1_data, timestamp=now, duration=timedelta(seconds=1))
e2 = Event(data=e2_data, timestamp=now, duration=timedelta(seconds=1))
events = events + [e1] * 10
events = events + [e2] * 5
# Check that an empty key list has no effect
assert merge_events_by_keys(events, []) == events
# Check that trying to merge on unavailable key has no effect
assert len(merge_events_by_keys(events, ["unknown"])) == 1
result = merge_events_by_keys(events, ["label"])
result = sort_by_duration(result)
print(result)
print(len(result))
assert len(result) == 2
assert result[0].duration == timedelta(seconds=10)
assert result[1].duration == timedelta(seconds=5)
def test_merge_events_by_keys_2():
now = datetime.now(timezone.utc)
events = []
e1_data = {"k1": "a", "k2": "a"}
e2_data = {"k1": "a", "k2": "c"}
e3_data = {"k1": "b", "k2": "a"}
e1 = Event(data=e1_data, timestamp=now, duration=timedelta(seconds=1))
e2 = Event(data=e2_data, timestamp=now, duration=timedelta(seconds=1))
e3 = Event(data=e3_data, timestamp=now, duration=timedelta(seconds=1))
events = events + [e1] * 10
events = events + [e2] * 9
events = events + [e3] * 8
result = merge_events_by_keys(events, ["k1", "k2"])
result = sort_by_duration(result)
print(result)
print(len(result))
assert len(result) == 3
assert result[0].data == e1_data
assert result[0].duration == timedelta(seconds=10)
assert result[1].data == e2_data
assert result[1].duration == timedelta(seconds=9)
assert result[2].data == e3_data
assert result[2].duration == timedelta(seconds=8)
def test_chunk_events_by_key():
now = datetime.now(timezone.utc)
events = []
e1_data = {"label1": "1a", "label2": "2a"}
e2_data = {"label1": "1a", "label2": "2b"}
e3_data = {"label1": "1b", "label2": "2b"}
e1 = Event(data=e1_data, timestamp=now, duration=timedelta(seconds=1))
e2 = Event(data=e2_data, timestamp=now, duration=timedelta(seconds=1))
e3 = Event(data=e3_data, timestamp=now, duration=timedelta(seconds=1))
events = [e1, e2, e3]
result = chunk_events_by_key(events, "label1")
print(len(result))
pprint(result)
assert len(result) == 2
# Check root label
assert result[0].data["label1"] == "1a"
assert result[1].data["label1"] == "1b"
# Check timestamp
assert result[0].timestamp == e1.timestamp
assert result[1].timestamp == e3.timestamp
# Check duration
assert result[0].duration == e1.duration + e2.duration
assert result[1].duration == e3.duration
# Check subevents
assert result[0].data["subevents"][0] == e1
assert result[0].data["subevents"][1] == e2
assert result[1].data["subevents"][0] == e3
def test_url_parse_event():
now = datetime.now(timezone.utc)
e = Event(
data={"url": "http://asd.com/test/?a=1"},
timestamp=now,
duration=timedelta(seconds=1),
)
result = split_url_events([e])
print(result)
assert result[0].data["$protocol"] == "http"
assert result[0].data["$domain"] == "asd.com"
assert result[0].data["$path"] == "/test/"
assert result[0].data["$params"] == ""
assert result[0].data["$options"] == "a=1"
assert result[0].data["$identifier"] == ""
e2 = Event(
data={"url": "https://www.asd.asd.com/test/test2/meh;meh2?asd=2&asdf=3#id"},
timestamp=now,
duration=timedelta(seconds=1),
)
result = split_url_events([e2])
print(result)
assert result[0].data["$protocol"] == "https"
assert result[0].data["$domain"] == "asd.asd.com"
assert result[0].data["$path"] == "/test/test2/meh"
assert result[0].data["$params"] == "meh2"
assert result[0].data["$options"] == "asd=2&asdf=3"
assert result[0].data["$identifier"] == "id"
e3 = Event(
data={"url": "file:///home/johan/myfile.txt"},
timestamp=now,
duration=timedelta(seconds=1),
)
result = split_url_events([e3])
print(result)
assert result[0].data["$protocol"] == "file"
assert result[0].data["$domain"] == "file"
assert result[0].data["$path"] == "/home/johan/myfile.txt"
assert result[0].data["$params"] == ""
assert result[0].data["$options"] == ""
assert result[0].data["$identifier"] == ""
# Test about: URLs
e4 = Event(
data={"url": "about:blank"},
timestamp=now,
duration=timedelta(seconds=1),
)
result = split_url_events([e4])
assert result[0].data["$protocol"] == "about"
assert result[0].data["$domain"] == "about"
def test_union():
now = datetime.now(timezone.utc)
e1 = Event(timestamp=now - timedelta(seconds=20), duration=timedelta(seconds=5))
e2 = Event(timestamp=now - timedelta(seconds=10), duration=timedelta(seconds=5))
e3 = Event(timestamp=now, duration=timedelta(seconds=1))
e4 = Event(timestamp=now + timedelta(seconds=20), duration=timedelta(seconds=1))
# union separate event lists with duplicates
events_union = union([e1, e2, e4], [e2, e3])
assert events_union == [e1, e2, e3, e4]
e1 = Event(timestamp=now - timedelta(seconds=20), duration=timedelta(seconds=5))
e2 = Event(timestamp=now - timedelta(seconds=10), duration=timedelta(seconds=5))
e3 = Event(timestamp=now - timedelta(seconds=10), duration=timedelta(seconds=10))
e4 = Event(timestamp=now - timedelta(seconds=5), duration=timedelta(seconds=5))
e5 = Event(timestamp=now, duration=timedelta(seconds=10))
# union event lists with intersecting duplicates
events_union = union([e3, e2, e5], [e1, e3, e4, e5])
assert events_union == [e1, e2, e3, e4, e5]
e1 = Event(timestamp=now - timedelta(seconds=30), duration=timedelta(seconds=15))
e2 = Event(timestamp=now, duration=timedelta(seconds=3))
e3 = Event(timestamp=now, duration=timedelta(seconds=5))
e4 = Event(timestamp=now, duration=timedelta(seconds=10))
# union event lists with same timestamp but different duration duplicates
events_union = union([e1, e2, e4], [e3, e2, e1])
assert events_union == [e1, e2, e3, e4]
def test_categorize():
now = datetime.now(timezone.utc)
classes = [
(["Test"], Rule({"regex": "^just"})),
(["Test", "Subtest"], Rule({"regex": "subtest$"})),
(["Test", "Ignorecase"], Rule({"regex": "ignorecase", "ignore_case": True})),
]
events = [
Event(timestamp=now, duration=0, data={"key": "just a test"}),
Event(timestamp=now, duration=0, data={"key": "just a subtest"}),
Event(timestamp=now, duration=0, data={"key": "just a IGNORECASE test"}),
Event(timestamp=now, duration=0, data={}),
]
events = categorize(events, classes)
assert events[0].data["$category"] == ["Test"]
assert events[1].data["$category"] == ["Test", "Subtest"]
assert events[2].data["$category"] == ["Test", "Ignorecase"]
assert events[3].data["$category"] == ["Uncategorized"]
def _event(value: str = "just a test") -> Event:
return Event(timestamp=datetime.now(timezone.utc), duration=0, data={"key": value})
def test_categorize_depth_wins_without_priority():
events = categorize(
[_event()],
[
(["A"], Rule({"regex": "test"})),
(["B", "B1"], Rule({"regex": "test"})),
],
)
assert events[0].data["$category"] == ["B", "B1"]
def test_categorize_explicit_priority_overrides_depth():
# Default for B1 is depth 2 → 20; 25 beats it.
events = categorize(
[_event()],
[
(["A"], Rule({"regex": "test", "priority": 25})),
(["B", "B1"], Rule({"regex": "test"})),
],
)
assert events[0].data["$category"] == ["A"]
def test_categorize_weight_alias():
events = categorize(
[_event()],
[
(["A"], Rule({"regex": "test", "weight": 25})),
(["B", "B1"], Rule({"regex": "test"})),
],
)
assert events[0].data["$category"] == ["A"]
def test_categorize_inter_level_priority():
between = categorize(
[_event()],
[
(["A"], Rule({"regex": "test"})),
(["A2"], Rule({"regex": "test", "priority": 15})),
],
)
assert between[0].data["$category"] == ["A2"]
still_loses_to_deeper = categorize(
[_event()],
[
(["A2"], Rule({"regex": "test", "priority": 15})),
(["B", "B1"], Rule({"regex": "test"})),
],
)
assert still_loses_to_deeper[0].data["$category"] == ["B", "B1"]
def test_categorize_lower_priority_loses_to_default_depth():
events = categorize(
[_event()],
[
(["A"], Rule({"regex": "test"})),
(["B", "B1"], Rule({"regex": "test", "priority": 0})),
],
)
assert events[0].data["$category"] == ["A"]
def test_categorize_equal_priority_keeps_later_match():
events = categorize(
[_event()],
[
(["First"], Rule({"regex": "test", "priority": 5})),
(["Second"], Rule({"regex": "test", "priority": 5})),
],
)
assert events[0].data["$category"] == ["Second"]
def test_categorize_negative_priority_still_beats_uncategorized():
events = categorize(
[_event()],
[(["Low"], Rule({"regex": "test", "priority": -100}))],
)
assert events[0].data["$category"] == ["Low"]
def test_categorize_priority_below_i64_min_still_beats_uncategorized():
# Python ints are unbounded; a signed-64-bit fallback sentinel would
# incorrectly keep Uncategorized for values below -(2**63).
events = categorize(
[_event()],
[(["Low"], Rule({"regex": "test", "priority": -(2**63) - 1}))],
)
assert events[0].data["$category"] == ["Low"]
def test_categorize_empty_category_keeps_uncategorized():
events = categorize(
[_event()],
[([], Rule({"regex": "test"}))],
)
assert events[0].data["$category"] == ["Uncategorized"]
def test_rule_invalid_priority():
with pytest.raises(ValueError, match="integer"):
Rule({"regex": "test", "priority": 1.5})
with pytest.raises(ValueError, match="integer"):
Rule({"regex": "test", "priority": "high"})
with pytest.raises(ValueError, match="integer"):
Rule({"regex": "test", "priority": True})
def test_categorize_cache_correctness():
"""Cache reuses category for identical data; distinct data gets its own category."""
now = datetime.now(timezone.utc)
classes = [
(["Browser"], Rule({"regex": "Firefox"})),
(["Editor"], Rule({"regex": "vim"})),
]
firefox_data = {"app": "Firefox", "title": "Home"}
vim_data = {"app": "vim", "title": "classify.py"}
# 50 Firefox events, 1 vim event, 50 more Firefox events
events = (
[Event(timestamp=now, duration=0, data=dict(firefox_data)) for _ in range(50)]
+ [Event(timestamp=now, duration=0, data=dict(vim_data))]
+ [Event(timestamp=now, duration=0, data=dict(firefox_data)) for _ in range(50)]
)
result = categorize(events, classes)
for e in result[:50]:
assert e.data["$category"] == ["Browser"]
assert result[50].data["$category"] == ["Editor"]
for e in result[51:]:
assert e.data["$category"] == ["Browser"]
# Mutating one event's category must not affect others sharing the same data fingerprint
result[0].data["$category"].append("MUTATED")
assert result[1].data["$category"] == ["Browser"]
def test_tags():
now = datetime.now(timezone.utc)
classes = [
("Test", Rule({"regex": "value$"})),
("Test", Rule({"regex": "^just"})),
]
events = [
Event(timestamp=now, duration=0, data={"key": "just a test value"}),
Event(timestamp=now, duration=0, data={}),
]
events = tag(events, classes)
assert len(events[0].data["$tags"]) == 2
assert len(events[1].data["$tags"]) == 0
def test_union_no_overlap():
from pprint import pprint
now = datetime(2018, 1, 1, 0, 0)
td1h = timedelta(hours=1)
events1 = [
Event(timestamp=now + 2 * i * td1h, duration=td1h, data={"test": 1})
for i in range(3)
]
events2 = [
Event(timestamp=now + (2 * i + 0.5) * td1h, duration=td1h, data={"test": 2})
for i in range(3)
]
events_union = union_no_overlap(events1, events2)
# pprint(events_union)
dur = sum((e.duration for e in events_union), timedelta(0))
assert dur == timedelta(hours=4, minutes=30)
assert sorted(events_union, key=lambda e: e.timestamp)
events_union = union_no_overlap(events2, events1)
# pprint(events_union)
dur = sum((e.duration for e in events_union), timedelta(0))
assert dur == timedelta(hours=4, minutes=30)
assert sorted(events_union, key=lambda e: e.timestamp)
events1 = [
Event(timestamp=now + (2 * i) * td1h, duration=td1h, data={"test": 1})
for i in range(3)
]
events2 = [Event(timestamp=now, duration=5 * td1h, data={"test": 2})]
events_union = union_no_overlap(events1, events2)
pprint(events_union)
dur = sum((e.duration for e in events_union), timedelta(0))
assert dur == timedelta(hours=5, minutes=0)
assert sorted(events_union, key=lambda e: e.timestamp)
def test_merge_subwatcher_fields_basic():
"""Fully overlapping subwatcher fields are injected without changing duration."""
now = datetime(2024, 1, 1, 12, 0, tzinfo=timezone.utc)
td1h = timedelta(hours=1)
base = [
Event(
timestamp=now,
duration=td1h,
data={"app": "vim", "title": "file.py"},
)
]
sub = [
Event(
timestamp=now,
duration=td1h,
data={"project": "myproject", "file": "file.py", "language": "python"},
)
]
result = merge_subwatcher_fields(base, sub, ["project", "file", "language"])
assert len(result) == 1
# Original base fields preserved
assert result[0].data["app"] == "vim"
assert result[0].data["title"] == "file.py"
# Subwatcher fields injected
assert result[0].data["project"] == "myproject"
assert result[0].data["language"] == "python"
# Exact overlap means no extra segmentation
assert result[0].timestamp == now
assert result[0].duration == td1h
def test_merge_subwatcher_fields_partial_overlap_splits_base():
"""Partial overlap only enriches the covered slice of the base event."""
now = datetime(2024, 1, 1, 12, 0, tzinfo=timezone.utc)
td15m = timedelta(minutes=15)
td30m = timedelta(minutes=30)
td1h = timedelta(hours=1)
base = [Event(timestamp=now, duration=td1h, data={"app": "vim"})]
sub = [
Event(
timestamp=now + td15m,
duration=td30m,
data={"project": "myproject"},
)
]
result = merge_subwatcher_fields(base, sub, ["project"])
assert len(result) == 3
assert [event.duration for event in result] == [td15m, td30m, td15m]
assert [event.timestamp for event in result] == [
now,
now + td15m,
now + td15m + td30m,
]
assert "project" not in result[0].data
assert result[1].data["project"] == "myproject"
assert "project" not in result[2].data
assert sum_durations(result) == td1h
def test_merge_subwatcher_fields_overlapping_subwatchers_prefer_latest_start():
"""Newer overlapping subwatcher events take over from their own start time."""
now = datetime(2024, 1, 1, 12, 0, tzinfo=timezone.utc)
td20m = timedelta(minutes=20)
td40m = timedelta(minutes=40)
td1h = timedelta(hours=1)
base = [Event(timestamp=now, duration=td1h, data={"app": "vim"})]
sub = [
Event(timestamp=now, duration=td40m, data={"project": "alpha"}),
Event(timestamp=now + td20m, duration=td40m, data={"project": "beta"}),
]
result = merge_subwatcher_fields(base, sub, ["project"])
assert len(result) == 2
assert [event.timestamp for event in result] == [now, now + td20m]
assert [event.duration for event in result] == [td20m, td40m]
assert [event.data.get("project") for event in result] == ["alpha", "beta"]
by_project = {
event.data.get("project"): event.duration
for event in merge_events_by_keys(result, ["project"])
}
assert by_project["alpha"] == td20m
assert by_project["beta"] == td40m
assert sum_durations(result) == td1h
def test_merge_subwatcher_fields_no_overlap():
"""Base events with no overlapping subwatcher event are returned unchanged."""
now = datetime(2024, 1, 1, 12, 0, tzinfo=timezone.utc)
td1h = timedelta(hours=1)
base = [Event(timestamp=now, duration=td1h, data={"app": "vim"})]
# Subwatcher event is entirely after the base event
sub = [
Event(
timestamp=now + 2 * td1h,
duration=td1h,
data={"project": "other"},
)
]
result = merge_subwatcher_fields(base, sub, ["project"])
assert len(result) == 1
assert "project" not in result[0].data
assert result[0].data["app"] == "vim"
def test_merge_subwatcher_fields_base_wins_conflict():
"""With conflict='base_wins' (default), existing base keys are not overwritten."""
now = datetime(2024, 1, 1, 12, 0, tzinfo=timezone.utc)
td1h = timedelta(hours=1)
base = [Event(timestamp=now, duration=td1h, data={"app": "vim", "file": "base.py"})]
sub = [Event(timestamp=now, duration=td1h, data={"file": "sub.py", "project": "p"})]
result = merge_subwatcher_fields(
base, sub, ["file", "project"], conflict="base_wins"
)
# base's "file" must not be overwritten
assert result[0].data["file"] == "base.py"
# "project" not in base → injected from sub
assert result[0].data["project"] == "p"
def test_merge_subwatcher_fields_sub_wins_conflict():
"""With conflict='sub_wins', subwatcher fields overwrite base fields."""
now = datetime(2024, 1, 1, 12, 0, tzinfo=timezone.utc)
td1h = timedelta(hours=1)
base = [Event(timestamp=now, duration=td1h, data={"app": "vim", "file": "base.py"})]
sub = [Event(timestamp=now, duration=td1h, data={"file": "sub.py"})]
result = merge_subwatcher_fields(base, sub, ["file"], conflict="sub_wins")
assert result[0].data["file"] == "sub.py"
def test_merge_subwatcher_fields_multiple_subsegments_preserve_duration():
"""Repeated subwatcher values aggregate to their true covered duration."""
now = datetime(2024, 1, 1, 12, 0, tzinfo=timezone.utc)
td15m = timedelta(minutes=15)
td1h = timedelta(hours=1)
base = [Event(timestamp=now, duration=td1h, data={"app": "vim"})]
sub = [
Event(timestamp=now, duration=td15m, data={"project": "alpha"}),
Event(timestamp=now + td15m, duration=td15m, data={"project": "beta"}),
Event(
timestamp=now + 2 * td15m,
duration=td15m,
data={"project": "alpha"},
),
]
result = merge_subwatcher_fields(base, sub, ["project"])
by_project = {
event.data.get("project"): event.duration
for event in merge_events_by_keys(result, ["project"])
}
by_app = {
event.data.get("app"): event.duration
for event in merge_events_by_keys(result, ["app"])
}
assert by_project["alpha"] == 2 * td15m
assert by_project["beta"] == td15m
assert by_project[None] == td15m
assert by_app["vim"] == td1h
assert sum_durations(result) == td1h
def test_merge_subwatcher_fields_empty_inputs():
"""Empty sub or keys returns a defensive copy of base (not the same list)."""
now = datetime(2024, 1, 1, 12, 0, tzinfo=timezone.utc)
td1h = timedelta(hours=1)
base = [Event(timestamp=now, duration=td1h, data={"app": "vim"})]
# Empty subwatcher list — returns a new list, data unchanged
result = merge_subwatcher_fields(base, [], ["project"])
assert result[0].data == {"app": "vim"}
assert result is not base
# Empty keys list
sub = [Event(timestamp=now, duration=td1h, data={"project": "p"})]
result = merge_subwatcher_fields(base, sub, [])
assert "project" not in result[0].data
assert result is not base
# Both empty
result = merge_subwatcher_fields(base, [], [])
assert result[0].data == {"app": "vim"}
assert result is not base
def test_merge_subwatcher_fields_invalid_conflict():
"""Invalid conflict value raises ValueError immediately."""
now = datetime(2024, 1, 1, 12, 0, tzinfo=timezone.utc)
td1h = timedelta(hours=1)
base = [Event(timestamp=now, duration=td1h, data={"app": "vim"})]
sub = [Event(timestamp=now, duration=td1h, data={"project": "p"})]
with pytest.raises(ValueError, match="conflict must be"):
merge_subwatcher_fields(base, sub, ["project"], conflict="invalid")

52
tests/utils.py Normal file
View File

@@ -0,0 +1,52 @@
import logging
import random
from datetime import datetime, timezone
from aw_datastore import Datastore, get_storage_methods
logging.basicConfig(level=logging.DEBUG)
# Useful when you just want some placeholder time in your events, saves typing
now = datetime.now(timezone.utc)
class TempTestBucket:
"""Context manager for creating a test bucket"""
def __init__(self, datastore):
self.ds = datastore
self.bucket_id = f"test-{random.randint(0, 10**4)}"
def __enter__(self):
self.ds.create_bucket(
bucket_id=self.bucket_id,
type="testtype",
client="testclient",
hostname="testhost",
name="testname",
)
return self.ds[self.bucket_id]
def __exit__(self, *_):
self.ds.delete_bucket(bucket_id=self.bucket_id)
def __repr__(self):
return f"<TempTestBucket using {self.ds.storage_strategy.__class__.__name__}>"
_storage_methods = get_storage_methods()
def param_datastore_objects():
return [
Datastore(storage_strategy=strategy, testing=True)
for name, strategy in _storage_methods.items()
]
def param_testing_buckets_cm():
datastores = [
Datastore(storage_strategy=strategy, testing=True)
for name, strategy in _storage_methods.items()
]
return [TempTestBucket(ds) for ds in datastores]