snapshot of https://github.com/ActivityWatch/aw-client.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build / Test on ubuntu-latest (push) Successful in 2m4s
Examples / Test examples (ubuntu-latest, 3.10) (push) Successful in 2m16s
Lint / lint (push) Successful in 1m25s
Build / Test on macOS-latest (push) Has been cancelled
Build / Test on windows-latest (push) Has been cancelled
CodeQL / Analyze (python) (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 00:20:39 +05:00
commit 96755ecd21
36 changed files with 5338 additions and 0 deletions

12
tests/conftest.py Normal file
View File

@@ -0,0 +1,12 @@
import os
import pytest
@pytest.fixture(autouse=True)
def _clear_aw_profile_after_test():
"""export_profile() writes os.environ directly; monkeypatch.delenv does
not record an undo when the var was already unset, so later tests would
inherit a leftover profile."""
yield
os.environ.pop("AW_PROFILE", None)

104
tests/test_auth.py Normal file
View File

@@ -0,0 +1,104 @@
from typing import Any, Dict, Optional
from aw_client import ActivityWatchClient
from aw_client.config import load_local_server_api_key
from aw_client import client as client_module
class DummyResponse:
def __init__(self, data: Optional[Dict[str, Any]] = None):
self._data = data or {}
def raise_for_status(self) -> None:
return None
def json(self) -> Dict[str, Any]:
return self._data
def write_server_config(tmp_path, filename: str, content: str) -> None:
config_dir = tmp_path / "activitywatch" / "aw-server-rust"
config_dir.mkdir(parents=True, exist_ok=True)
(config_dir / filename).write_text(content)
def test_load_local_server_api_key_matches_port(tmp_path, monkeypatch):
monkeypatch.setattr(
"aw_client.config._user_config_dir",
lambda appname: str(tmp_path / appname),
)
monkeypatch.setattr(
"aw_client.config._user_data_dir",
lambda appname: str(tmp_path / "data" / appname),
)
monkeypatch.setattr(
"aw_client.config._user_cache_dir",
lambda appname: str(tmp_path / "cache" / appname),
)
write_server_config(
tmp_path,
"config.toml",
'port = 5601\n\n[auth]\napi_key = "secret123"\n',
)
write_server_config(
tmp_path,
"config-testing.toml",
'port = 5666\n\n[auth]\napi_key = "testing-secret"\n',
)
assert load_local_server_api_key("127.0.0.1", 5601) == "secret123"
assert (
load_local_server_api_key("localhost", "5666", profile="testing")
== "testing-secret"
)
assert load_local_server_api_key("::1", 5601) == "secret123"
assert load_local_server_api_key("127.0.0.1", 5600) is None
assert load_local_server_api_key("example.com", 5601) is None
# Default profile must not pick up the testing server's key.
assert load_local_server_api_key("localhost", "5666") is None
def test_client_sends_authorization_header_for_local_server(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
write_server_config(
tmp_path,
"config.toml",
'port = 5600\n\n[auth]\napi_key = "secret123"\n',
)
monkeypatch.setattr(client_module, "SingleInstance", lambda name: object())
captured = {}
def fake_get(url, params=None, headers=None):
captured["url"] = url
captured["headers"] = headers
return DummyResponse({"hostname": "test-host", "testing": False})
monkeypatch.setattr(client_module.req, "get", fake_get)
client = ActivityWatchClient("test-client", host="127.0.0.1", port=5600)
assert client.get_info()["hostname"] == "test-host"
assert captured["url"] == "http://127.0.0.1:5600/api/0/info"
assert captured["headers"]["Authorization"] == "Bearer secret123"
def test_client_skips_authorization_header_for_remote_server(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
write_server_config(
tmp_path,
"config.toml",
'port = 5600\n\n[auth]\napi_key = "secret123"\n',
)
monkeypatch.setattr(client_module, "SingleInstance", lambda name: object())
captured = {}
def fake_get(url, params=None, headers=None):
captured["headers"] = headers
return DummyResponse({"hostname": "remote-host", "testing": False})
monkeypatch.setattr(client_module.req, "get", fake_get)
client = ActivityWatchClient("test-client", host="aw.example.com", port=5600)
assert client.get_info()["hostname"] == "remote-host"
assert "Authorization" not in captured["headers"]

130
tests/test_client.py Executable file
View File

@@ -0,0 +1,130 @@
#!/usr/bin/env python3
import time
import warnings
from random import random
from datetime import datetime, timedelta, timezone
from requests.exceptions import HTTPError
import pytest
from aw_core.models import Event
from aw_client import ActivityWatchClient
def create_unique_event():
return Event(
timestamp=datetime.now(timezone.utc),
duration=timedelta(),
data={"label": str(random())},
)
def test_full():
now = datetime.now(timezone.utc)
client_name = "aw-test-client"
bucket_name = "test-bucket"
bucket_etype = "test"
# Test context manager
with ActivityWatchClient(client_name, testing=True) as client:
time.sleep(1)
# Check that client name is set correctly
assert client.client_name == client_name
# Delete bucket before creating it, and handle error if it doesn't already exist
try:
client.delete_bucket(bucket_name)
except HTTPError:
pass
# Create bucket
client.create_bucket(bucket_name, bucket_etype)
# Check bucket
buckets = client.get_buckets()
assert bucket_name in buckets
assert bucket_name == buckets[bucket_name]["id"]
assert bucket_etype == buckets[bucket_name]["type"]
# Insert events
e1 = create_unique_event()
e2 = create_unique_event()
e3 = create_unique_event()
events = [e1, e2, e3]
client.insert_events(bucket_name, events)
# Get events
fetched_events = client.get_events(bucket_name, limit=len(events))
# Assert events
assert [(e.timestamp, e.duration, e.data) for e in fetched_events] == [
(e.timestamp, e.duration, e.data) for e in events
]
# Check eventcount
eventcount = client.get_eventcount(bucket_name)
assert eventcount == len(events)
result = client.query(
f'RETURN = query_bucket("{bucket_name}");',
timeperiods=[(now - timedelta(hours=1), now + timedelta(hours=1))],
)
assert len(result) == 1
assert len(result[0]) == 3
# Get single event
e = client.get_event(bucket_name, fetched_events[1].id)
assert e.id == fetched_events[1].id
# Delete single event
client.delete_event(bucket_name, fetched_events[1].id)
assert client.get_event(bucket_name, fetched_events[1].id) is None
# Test exception raising
with pytest.raises(ValueError):
# timeperiod end time does not have a timezone set
result = client.query(
f'RETURN = query_bucket("{bucket_name}");',
timeperiods=[(now - timedelta(hours=1), datetime.now())],
)
# Create and delete an event: check that it no longer exists
e_del = create_unique_event()
client.insert_event(bucket_name, e_del)
fetched_events = client.get_events(bucket_name)
assert (e_del.timestamp, e_del.duration, e_del.data) in [
(e.timestamp, e.duration, e.data) for e in fetched_events
]
e_del_fetched = [e for e in fetched_events if e.data == e_del.data][0]
client.delete_event(bucket_name, e_del_fetched)
fetched_events = client.get_events(bucket_name)
assert (e_del.timestamp, e_del.duration, e_del.data) not in [
(e.timestamp, e.duration, e.data) for e in fetched_events
]
# Delete bucket
client.delete_bucket(bucket_name)
def test_queued_usage_warns_once_before_connect():
client = ActivityWatchClient(f"aw-test-client-{random()}", testing=True)
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
client.create_bucket("test-bucket", "test", queued=True)
client.heartbeat(
"test-bucket",
create_unique_event(),
pulsetime=1,
queued=True,
)
queue_warnings = [
warning
for warning in caught
if "connect()" in str(warning.message) or "with client:" in str(warning.message)
]
assert len(queue_warnings) == 1

56
tests/test_failqueue.py Executable file
View File

@@ -0,0 +1,56 @@
#!/usr/bin/env python3
import time
from pprint import pprint
from random import randint
from datetime import datetime, timedelta, timezone
from aw_core.models import Event
from aw_client import ActivityWatchClient
now = datetime.now(timezone.utc)
def create_unique_event():
return Event(timestamp=now, data={"label": str(randint(0, 10000))})
def test_failqueue():
client_name = "aw-test-client-" + str(randint(0, 10000))
bucket_id = "test-bucket-" + str(randint(0, 10000))
bucket_etype = "test"
input("Make sure aw-server isn't running, then press enter > ")
client1 = ActivityWatchClient(client_name, testing=True)
client1.create_bucket(bucket_id, bucket_etype, queued=True)
print("Creating events")
events = [create_unique_event() for _ in range(3)]
for i, e in enumerate(events):
e.timestamp += timedelta(seconds=i)
client1.heartbeat(bucket_id, e, pulsetime=1, queued=True)
print("Trying to send events (should fail)")
with client1:
time.sleep(1)
input("Start aw-server with --testing, then press enter > ")
client2 = ActivityWatchClient(client_name, testing=True)
client2.create_bucket(bucket_id, bucket_etype, queued=True)
# Here the previously queued events should be sent
with client2:
time.sleep(1)
print("Getting events")
recv_events = client2.get_events(bucket_id)
print("Asserting latest event")
pprint(recv_events)
pprint(recv_events[0].data["label"])
pprint(events[2].data["label"])
assert recv_events[0].data["label"] == events[2].data["label"]
if __name__ == "__main__":
test_failqueue()

94
tests/test_profile.py Normal file
View File

@@ -0,0 +1,94 @@
"""Unit tests for profile resolution."""
import os
import pytest
from aw_client.profile import (
DEFAULT_PROFILE,
TESTING_PROFILE,
export_profile,
is_testing,
profile_from_env,
profile_suffix,
resolve_profile,
validate_profile,
)
class TestResolveProfile:
def test_no_flags_gives_default(self):
assert resolve_profile(None, False) == DEFAULT_PROFILE
def test_testing_flag_is_an_alias(self):
assert resolve_profile(None, True) == TESTING_PROFILE
def test_explicit_profile_wins(self):
assert resolve_profile("research", False) == "research"
def test_testing_with_matching_profile_is_allowed(self):
assert resolve_profile("testing", True) == TESTING_PROFILE
def test_testing_with_conflicting_profile_raises(self):
with pytest.raises(ValueError, match="conflicts"):
resolve_profile("research", True)
@pytest.mark.parametrize(
"name", ["Research", "with space", "a" * 33, "", "../escape", "-leading"]
)
def test_invalid_names_rejected(self, name):
with pytest.raises(ValueError):
validate_profile(name)
@pytest.mark.parametrize("name", ["research", "aw2", "my_profile", "my-profile"])
def test_valid_names_accepted(self, name):
assert validate_profile(name) == name
class TestProfileSuffix:
def test_default_has_no_suffix(self):
assert profile_suffix(DEFAULT_PROFILE) == ""
def test_testing_keeps_legacy_suffix(self):
assert profile_suffix(TESTING_PROFILE) == "-testing"
def test_custom_profile_suffix(self):
assert profile_suffix("research") == "-research"
def test_suffixes_are_disjoint(self):
suffixes = {profile_suffix(p) for p in ("default", "testing", "research")}
assert len(suffixes) == 3
class TestIsTesting:
def test_only_testing_profile_is_testing(self):
assert is_testing(TESTING_PROFILE)
assert not is_testing(DEFAULT_PROFILE)
assert not is_testing("research")
class TestExportProfile:
def test_exported_for_child_processes(self, monkeypatch):
monkeypatch.delenv("AW_PROFILE", raising=False)
export_profile("research")
assert os.environ["AW_PROFILE"] == "research"
def test_default_unsets_env_so_aw_core_keeps_bare_root(self, monkeypatch):
monkeypatch.setenv("AW_PROFILE", "research")
export_profile(DEFAULT_PROFILE)
assert "AW_PROFILE" not in os.environ
class TestProfileFromEnv:
def test_defaults_when_unset(self, monkeypatch):
monkeypatch.delenv("AW_PROFILE", raising=False)
assert profile_from_env(False) == DEFAULT_PROFILE
assert profile_from_env(True) == TESTING_PROFILE
def test_reads_exported_profile(self, monkeypatch):
monkeypatch.setenv("AW_PROFILE", "research")
assert profile_from_env(False) == "research"
def test_ignores_invalid_env_value(self, monkeypatch):
monkeypatch.setenv("AW_PROFILE", "Not A Profile")
assert profile_from_env(False) == DEFAULT_PROFILE

View File

@@ -0,0 +1,294 @@
"""Client constructor, config sections, persistqueue, and rust api-key lookup."""
import logging
import os
from pathlib import Path
import pytest
from aw_client import ActivityWatchClient
from aw_client import client as client_module
from aw_client.config import load_local_server_api_key, rust_server_config_candidates
from aw_client.profile import DEFAULT_PROFILE, TESTING_PROFILE
@pytest.fixture
def isolated_dirs(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config"))
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "data"))
monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path / "cache"))
monkeypatch.delenv("AW_PROFILE", raising=False)
monkeypatch.setattr(client_module, "SingleInstance", lambda name: object())
return tmp_path
class TestClientConstructor:
def test_testing_alias_uses_testing_port_and_exports_env(self, isolated_dirs):
client = ActivityWatchClient("t", testing=True)
assert client.profile == TESTING_PROFILE
assert client.testing is True
assert client.server_address.endswith(":5666")
assert os.environ["AW_PROFILE"] == "testing"
def test_explicit_profile_testing_is_the_same(self, isolated_dirs):
client = ActivityWatchClient("t", profile="testing")
assert client.profile == TESTING_PROFILE
assert client.testing is True
assert client.server_address.endswith(":5666")
def test_default_unsets_env_and_uses_5600(self, isolated_dirs, monkeypatch):
monkeypatch.setenv("AW_PROFILE", "research")
client = ActivityWatchClient("t", profile="default")
assert client.profile == DEFAULT_PROFILE
assert client.testing is False
assert client.server_address.endswith(":5600")
assert "AW_PROFILE" not in os.environ
def test_named_profile_falls_back_to_server_section(self, isolated_dirs, caplog):
with caplog.at_level(logging.WARNING, logger="aw_client.client"):
client = ActivityWatchClient("t", profile="research")
assert client.profile == "research"
assert client.testing is False
assert client.server_address.endswith(":5600")
assert os.environ["AW_PROFILE"] == "research"
assert any(
"falling back to [server]" in rec.message
and "may collide with the default instance" in rec.message
for rec in caplog.records
)
def test_env_from_launcher_is_used_when_no_flags(self, isolated_dirs, monkeypatch):
monkeypatch.setenv("AW_PROFILE", "research")
client = ActivityWatchClient("t")
assert client.profile == "research"
assert os.environ["AW_PROFILE"] == "research"
def test_explicit_testing_overrides_stale_env(self, isolated_dirs, monkeypatch):
monkeypatch.setenv("AW_PROFILE", "research")
client = ActivityWatchClient("t", testing=True)
assert client.profile == TESTING_PROFILE
assert os.environ["AW_PROFILE"] == "testing"
def test_host_and_port_kwargs_still_win(self, isolated_dirs):
client = ActivityWatchClient(
"t", profile="research", host="127.0.0.1", port=5667
)
assert client.server_address == "http://127.0.0.1:5667"
def test_conflicting_flags_raise(self, isolated_dirs):
with pytest.raises(ValueError, match="conflicts"):
ActivityWatchClient("t", testing=True, profile="research")
class TestPersistqueueSuffix:
def test_testing_keeps_legacy_suffix(self, isolated_dirs):
client = ActivityWatchClient("aw-test-client", testing=True)
assert "aw-test-client-testing." in client.request_queue.persistqueue_path
def test_named_profile_suffix_is_disjoint(self, isolated_dirs):
testing = ActivityWatchClient("aw-test-client", testing=True)
research = ActivityWatchClient("aw-test-client", profile="research")
assert (
testing.request_queue.persistqueue_path
!= research.request_queue.persistqueue_path
)
assert "aw-test-client-research." in research.request_queue.persistqueue_path
def test_reconnect_preserves_named_profile_queue_path(
self, isolated_dirs, monkeypatch
):
def profile_data_dir(module):
profile = os.environ.get("AW_PROFILE")
root = "activitywatch" if profile is None else f"activitywatch-{profile}"
return str(isolated_dirs / root / module)
monkeypatch.setattr(client_module, "get_data_dir", profile_data_dir)
research = ActivityWatchClient("aw-test-client", profile="research")
original_path = research.request_queue.persistqueue_path
monkeypatch.setattr(research.request_queue, "_try_connect", lambda: True)
research.request_queue.connected = True
research.connect()
default = ActivityWatchClient("other-client", profile="default")
assert default.request_queue.persistqueue_path != original_path
research.disconnect()
assert research.request_queue.persistqueue_path == original_path
@pytest.fixture
def fake_platform_dirs(tmp_path, monkeypatch):
"""Point rust-config lookup at a tmp tree. platformdirs on macOS/Windows
ignores XDG_* even when set, so patch the wrappers rather than env vars.
"""
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_client.config._user_data_dir", lambda appname: _join(data, appname)
)
monkeypatch.setattr(
"aw_client.config._user_config_dir",
lambda appname: _join(config, appname),
)
monkeypatch.setattr(
"aw_client.config._user_cache_dir",
lambda appname: _join(cache, appname),
)
monkeypatch.delenv("AW_PROFILE", raising=False)
return {"data": data, "config": config, "cache": cache, "root": tmp_path}
def _write_rust_config(
config_root: Path, appname: str, filename: str, content: str
) -> Path:
rust_dir = config_root / appname / "aw-server-rust"
rust_dir.mkdir(parents=True, exist_ok=True)
path = rust_dir / filename
path.write_text(content)
return path
def test_load_local_server_api_key_named_profile(fake_platform_dirs):
config = fake_platform_dirs["config"]
_write_rust_config(
config,
"activitywatch-research",
"config.toml",
'port = 5667\n\n[auth]\napi_key = "research-secret"\n',
)
_write_rust_config(
config,
"activitywatch",
"config.toml",
'port = 5600\n\n[auth]\napi_key = "default-secret"\n',
)
# Suffixed names in the isolated root must not be read — dir isolation
# is the point (ActivityWatch/activitywatch#1399).
_write_rust_config(
config,
"activitywatch-research",
"config-research.toml",
'port = 5667\n\n[auth]\napi_key = "suffixed-must-be-ignored"\n',
)
assert (
load_local_server_api_key("127.0.0.1", 5667, profile="research")
== "research-secret"
)
assert load_local_server_api_key("127.0.0.1", 5600, profile="default") == (
"default-secret"
)
assert load_local_server_api_key("127.0.0.1", 5600, profile="research") is None
class TestTestingRootApiKeyLookup:
"""Rust API-key lookup follows the #1399 testing-root rule."""
def test_fresh_setup_reads_bare_config_in_new_root(self, fake_platform_dirs):
_write_rust_config(
fake_platform_dirs["config"],
"activitywatch-testing",
"config.toml",
'port = 5666\n\n[auth]\napi_key = "new-root-secret"\n',
)
assert (
load_local_server_api_key("127.0.0.1", 5666, profile="testing")
== "new-root-secret"
)
paths = [p for p, _ in rust_server_config_candidates("testing")]
assert paths[0].endswith(
os.path.join("activitywatch-testing", "aw-server-rust", "config.toml")
)
def test_legacy_artifacts_keep_suffixed_shared_root(self, fake_platform_dirs):
_write_rust_config(
fake_platform_dirs["config"],
"activitywatch",
"config-testing.toml",
'port = 5666\n\n[auth]\napi_key = "legacy-secret"\n',
)
assert (
load_local_server_api_key("127.0.0.1", 5666, profile="testing")
== "legacy-secret"
)
paths = [p for p, _ in rust_server_config_candidates("testing")]
assert paths[0].endswith(
os.path.join("activitywatch", "aw-server-rust", "config-testing.toml")
)
def test_new_root_wins_over_legacy_artifacts(self, fake_platform_dirs):
_write_rust_config(
fake_platform_dirs["config"],
"activitywatch",
"config-testing.toml",
'port = 5666\n\n[auth]\napi_key = "legacy-secret"\n',
)
_write_rust_config(
fake_platform_dirs["config"],
"activitywatch-testing",
"config.toml",
'port = 5666\n\n[auth]\napi_key = "new-root-secret"\n',
)
assert (
load_local_server_api_key("127.0.0.1", 5666, profile="testing")
== "new-root-secret"
)
def test_empty_new_root_still_finds_legacy_key(self, fake_platform_dirs):
"""Python creating activitywatch-testing/ must not hide rust's legacy key."""
(fake_platform_dirs["config"] / "activitywatch-testing").mkdir(parents=True)
_write_rust_config(
fake_platform_dirs["config"],
"activitywatch",
"config-testing.toml",
'port = 5666\n\n[auth]\napi_key = "legacy-secret"\n',
)
assert (
load_local_server_api_key("127.0.0.1", 5666, profile="testing")
== "legacy-secret"
)
class TestCliPortOverride:
def test_explicit_port_5600_is_not_discarded(self, monkeypatch):
captured = {}
class FakeClient:
def __init__(self, **kwargs):
captured.update(kwargs)
def get_buckets(self):
return {}
monkeypatch.setattr("aw_client.cli.aw_client.ActivityWatchClient", FakeClient)
from click.testing import CliRunner
from aw_client.cli import main
result = CliRunner().invoke(main, ["--port", "5600", "buckets"])
assert result.exit_code == 0, result.output
assert captured["port"] == 5600
def test_omitted_port_lets_profile_config_win(self, monkeypatch):
captured = {}
class FakeClient:
def __init__(self, **kwargs):
captured.update(kwargs)
def get_buckets(self):
return {}
monkeypatch.setattr("aw_client.cli.aw_client.ActivityWatchClient", FakeClient)
from click.testing import CliRunner
from aw_client.cli import main
result = CliRunner().invoke(main, ["--testing", "buckets"])
assert result.exit_code == 0, result.output
assert captured["port"] is None
assert captured["testing"] is True

View File

@@ -0,0 +1,90 @@
"""
Tests the often-buggy request queue.
WARNING: A shitload of mocking ahead
It is said about testing that it makes you able to refactorize
with confidence, and I need some of that right now.
"""
from time import sleep
from logging import basicConfig, DEBUG
basicConfig(level=DEBUG)
import requests
from aw_client.client import RequestQueue
class MockClient:
client_name = "Mock"
def __init__(self):
self.testing = True
self.create_bucket_calls = []
def get_buckets(self, *args, **kwargs):
print("Called get_buckets")
return [{"id": "test", "name": "Test"}]
def create_bucket(self, *args, **kwargs):
self.create_bucket_calls.append((args, kwargs))
print("Called create_bucket")
def _post(self, *args, **kwargs):
print(args, kwargs)
return requests.Response()
def test_basic():
client = MockClient()
rq = RequestQueue(client) # type: ignore
# Mockeypatching
rq._try_connect = lambda: True # type: ignore
rq.connected = True
rq.start()
rq.add_request("/api/0/buckets/test/heartbeat", {})
sleep(1)
rq.stop()
rq.join()
def test_complex():
client = MockClient()
rq = RequestQueue(client) # type: ignore
# Mockeypatching
rq._try_connect = lambda: False # type: ignore
rq.start()
sleep(1)
rq.stop()
rq.join()
def test_register_bucket_creates_immediately_when_connected():
client = MockClient()
rq = RequestQueue(client) # type: ignore
rq.connected = True
rq.register_bucket("test-bucket", "test-type")
assert client.create_bucket_calls == [(("test-bucket", "test-type"), {})]
def test_register_bucket_marks_queue_disconnected_on_create_failure():
class FailingClient(MockClient):
def create_bucket(self, *args, **kwargs):
super().create_bucket(*args, **kwargs)
raise requests.exceptions.ConnectionError()
client = FailingClient()
rq = RequestQueue(client) # type: ignore
rq.connected = True
rq.register_bucket("test-bucket", "test-type")
assert rq.connected is False
assert client.create_bucket_calls == [(("test-bucket", "test-type"), {})]